Skip to main content

forge/manifest_ledger/
hooks.rs

1// #t(file: rust_alloc_in_loop) source inventory intentionally owns stable report strings.
2// #t(file: rust_clone_in_loop) source inventory expands bounded hook call graphs.
3
4use std::collections::{BTreeMap, BTreeSet};
5
6use anyhow::{Context, Result, bail};
7use ra_ap_syntax::{
8    AstNode, Edition, SourceFile, SyntaxKind, SyntaxNode,
9    ast::{self, HasArgList, HasModuleItem, HasName, LiteralKind},
10};
11use wowlab_fs::{
12    directory::{self, EntryKind},
13    file,
14    path::{Path, PathBuf},
15};
16use wowlab_manifest_schema::{ItemManifestEntry, Manifest};
17
18use super::types::{LedgerDisposition, LedgerFinding, LedgerRow, LedgerSource, OperationCategory};
19
20// #t(rust_duplicate_strings) Each ledger stage owns explicit placeholder evidence without coupling its private modules.
21pub(super) const PLACEHOLDER_EVIDENCE: &str = "hook body requires operation-level source inventory";
22
23#[derive(Clone, Debug)]
24struct HookBinding {
25    manifest_key: String,
26    function: String,
27}
28
29#[derive(Clone, Debug)]
30struct SourceOperation {
31    category: OperationCategory,
32    disposition: LedgerDisposition,
33    evidence: String,
34    aura_reference: Option<AuraReference>,
35    controlled: bool,
36    coupled: bool,
37}
38
39#[derive(Clone, Debug)]
40struct SourceCall {
41    name: String,
42    controlled: bool,
43}
44
45#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
46enum AuraReference {
47    Id(u32),
48    Name(String),
49}
50
51#[derive(Clone, Debug)]
52struct FunctionInfo {
53    file: PathBuf,
54    name: String,
55    operations: Vec<SourceOperation>,
56    calls: Vec<SourceCall>,
57    imperative_procs: usize,
58}
59
60#[derive(Default)]
61struct OperationVisitor {
62    operations: Vec<SourceOperation>,
63    calls: Vec<SourceCall>,
64    imperative_procs: usize,
65}
66
67impl OperationVisitor {
68    fn collect(block: &ast::BlockExpr) -> Self {
69        let mut visitor = Self::default();
70
71        for node in block.syntax().descendants() {
72            let controlled = is_controlled(&node, block);
73
74            if let Some(call) = ast::MethodCallExpr::cast(node.clone()) {
75                let Some(name) = call.name_ref().map(|name| name.text().to_string()) else {
76                    continue;
77                };
78
79                if let Some(mut operation) = classify_call(&name) {
80                    operation.aura_reference = call
81                        .arg_list()
82                        .and_then(|arguments| arguments.args().next())
83                        .as_ref()
84                        .and_then(aura_reference);
85                    operation.controlled = controlled;
86                    visitor.operations.push(operation);
87                }
88
89                continue;
90            }
91
92            if let Some(call) = ast::CallExpr::cast(node.clone()) {
93                let Some(ast::Expr::PathExpr(path)) = call.expr() else {
94                    continue;
95                };
96                let Some(name) = path.path().as_ref().and_then(last_segment) else {
97                    continue;
98                };
99
100                if let Some(mut operation) = classify_call(&name) {
101                    operation.aura_reference = call
102                        .arg_list()
103                        .and_then(|arguments| arguments.args().next())
104                        .as_ref()
105                        .and_then(aura_reference);
106                    operation.controlled = controlled;
107                    visitor.operations.push(operation);
108                }
109
110                visitor.calls.push(SourceCall { name, controlled });
111
112                continue;
113            }
114
115            if ast::RecordExpr::cast(node)
116                .and_then(|record| record.path())
117                .as_ref()
118                .and_then(last_segment)
119                .is_some_and(|name| is_imperative_proc_type(&name))
120            {
121                visitor.imperative_procs += 1;
122            }
123        }
124
125        visitor
126    }
127}
128
129fn is_controlled(node: &SyntaxNode, block: &ast::BlockExpr) -> bool {
130    node.ancestors()
131        .skip(1)
132        .take_while(|ancestor| ancestor != block.syntax())
133        .any(|ancestor| {
134            ast::ForExpr::can_cast(ancestor.kind())
135                || ast::IfExpr::can_cast(ancestor.kind())
136                || ast::LoopExpr::can_cast(ancestor.kind())
137                || ast::MatchExpr::can_cast(ancestor.kind())
138                || ast::WhileExpr::can_cast(ancestor.kind())
139        })
140}
141
142pub(super) fn replace_hook_placeholders(
143    rows: &mut Vec<LedgerRow>,
144    slug: &str,
145    hooks_dir: &Path,
146    manifest: &Manifest,
147) -> Result<()> {
148    let spec_dir = hooks_dir.join(slug);
149    let mut index = SourceIndex::load(&spec_dir)?;
150
151    index.merge(SourceIndex::load(&hooks_dir.join("shared"))?);
152    let aura_ids = manifest
153        .auras
154        .iter()
155        .map(|(name, aura)| (name.clone(), aura.id))
156        .collect();
157
158    replace_bindings(rows, slug, &index, &spec_bindings(manifest), &aura_ids)
159}
160
161pub(super) fn replace_item_hook_placeholders(
162    rows: &mut Vec<LedgerRow>,
163    item_name: &str,
164    hooks_dir: &Path,
165    item: &ItemManifestEntry,
166) -> Result<()> {
167    let owner = format!("item:{item_name}");
168    let module = item_name.to_ascii_lowercase();
169    let file = hooks_dir.join("items").join(format!("{module}.rs"));
170    let index = if directory::inspect(&file)?.is_some() {
171        SourceIndex::load_file(&file)?
172    } else {
173        SourceIndex::default()
174    };
175    let aura_ids = item
176        .auras
177        .iter()
178        .map(|(aura_name, aura)| (aura_name.clone(), aura.id))
179        .collect();
180
181    replace_bindings(
182        rows,
183        &owner,
184        &index,
185        &item_bindings(item_name, item),
186        &aura_ids,
187    )
188}
189
190pub(super) fn collect_imperative_proc_registrations(
191    rows: &mut Vec<LedgerRow>,
192    hooks_dir: &Path,
193) -> Result<()> {
194    let index = SourceIndex::load(hooks_dir)?;
195
196    for function in index.functions.values().flatten() {
197        for occurrence in 0..function.imperative_procs {
198            let owner = owner_from_hook_path(hooks_dir, &function.file);
199
200            rows.push(
201                LedgerRow::new(
202                    LedgerSource::new(
203                        owner,
204                        "(imperative hook registration)",
205                        format!("impact_proc[{occurrence}]"),
206                    ),
207                    LedgerFinding::new(
208                        OperationCategory::Proc,
209                        LedgerDisposition::GenericGap,
210                        "imperative impact proc registration; cross-check against DBC AU42/proc data",
211                    ),
212                )
213                .hook(
214                    &relative_path(hooks_dir, &function.file),
215                    function.name.clone(),
216                ),
217            );
218        }
219    }
220
221    Ok(())
222}
223
224#[cfg(test)]
225pub(super) fn imperative_proc_count(hooks_dir: &Path) -> Result<usize> {
226    let index = SourceIndex::load(hooks_dir)?;
227
228    Ok(index
229        .functions
230        .values()
231        .flatten()
232        .map(|function| function.imperative_procs)
233        .sum())
234}
235
236fn replace_bindings(
237    rows: &mut Vec<LedgerRow>,
238    owner: &str,
239    index: &SourceIndex,
240    bindings: &[HookBinding],
241    aura_ids: &BTreeMap<String, u32>,
242) -> Result<()> {
243    for binding in bindings {
244        let Some(position) = rows.iter().position(|row| {
245            row.spec_or_item == owner
246                && row.manifest_key == binding.manifest_key
247                && row.evidence == PLACEHOLDER_EVIDENCE
248        }) else {
249            bail!(
250                "{owner} hook binding {} has no ledger placeholder",
251                binding.manifest_key
252            );
253        };
254        let placeholder = rows.remove(position);
255        let Some(function) = index.unique(&binding.function)? else {
256            bail!(
257                "{owner} hook function {} for {} was not found below the owning hook module",
258                binding.function,
259                binding.manifest_key
260            );
261        };
262        let operations = index.expanded_operations(function);
263
264        if operations.is_empty() {
265            let mut resolved = placeholder;
266
267            resolved.hook_file = Some(function.file.display().to_string());
268            resolved.hook_function = Some(function.name.clone());
269            resolved.evidence = "hook contains no recognized state mutation; inspect returned formula/control policy".to_string();
270            rows.insert(position, resolved);
271            continue;
272        }
273
274        for (operation_index, operation) in operations.into_iter().enumerate().rev() {
275            let mut row = placeholder
276                .clone()
277                .hook(&function.file, function.name.clone());
278
279            row.hook_operation = Some(operation_index + 1);
280            row.category = operation.category;
281            row.disposition = operation.disposition;
282            row.evidence = operation.evidence;
283            let coupled = operation.coupled;
284
285            if let Some(aura_id) = operation
286                .aura_reference
287                .as_ref()
288                .and_then(|reference| resolve_aura_reference(reference, aura_ids))
289            {
290                row.child_spell_ids.push(aura_id);
291
292                if coupled {
293                    row.evidence = format!("coupled {}", row.evidence);
294                }
295            }
296
297            rows.insert(position, row);
298        }
299    }
300
301    Ok(())
302}
303
304#[derive(Default)]
305struct SourceIndex {
306    functions: BTreeMap<String, Vec<FunctionInfo>>,
307}
308
309impl SourceIndex {
310    fn load(root: &Path) -> Result<Self> {
311        if directory::inspect(root)?.is_none() {
312            return Ok(Self::default());
313        }
314
315        let mut paths = Vec::new();
316
317        collect_rust_paths(root, &mut paths)?;
318        paths.sort();
319        let mut index = Self::default();
320
321        for path in paths {
322            index.parse_file(&path)?;
323        }
324
325        Ok(index)
326    }
327
328    fn load_file(path: &Path) -> Result<Self> {
329        let mut index = Self::default();
330
331        index.parse_file(path)?;
332
333        Ok(index)
334    }
335
336    fn parse_file(&mut self, path: &Path) -> Result<()> {
337        let source = file::read_text(path)
338            .with_context(|| format!("failed to read hook source {}", path.display()))?;
339        let syntax = parse_hook_source(path, &source)?;
340        let mut parsed_proc_count = 0;
341
342        for item in syntax.items() {
343            match item {
344                ast::Item::Fn(function) => {
345                    let (Some(name), Some(block)) = (function.name(), function.body()) else {
346                        continue;
347                    };
348
349                    parsed_proc_count +=
350                        self.insert_function(path, name.text().to_string(), &block);
351                }
352                ast::Item::Impl(implementation) => {
353                    if let Some(items) = implementation.assoc_item_list() {
354                        for item in items.assoc_items() {
355                            if let ast::AssocItem::Fn(function) = item {
356                                let (Some(name), Some(block)) = (function.name(), function.body())
357                                else {
358                                    continue;
359                                };
360
361                                parsed_proc_count +=
362                                    self.insert_function(path, name.text().to_string(), &block);
363                            }
364                        }
365                    }
366                }
367                _ => {}
368            }
369        }
370
371        let token_proc_count = count_proc_structs(syntax.syntax());
372
373        if token_proc_count > parsed_proc_count {
374            let missing = token_proc_count - parsed_proc_count;
375
376            self.functions
377                .entry("<macro registration>".to_string())
378                .or_default()
379                .push(FunctionInfo {
380                    file: path.to_path_buf(),
381                    name: "<macro registration>".to_string(),
382                    operations: Vec::new(),
383                    calls: Vec::new(),
384                    imperative_procs: missing,
385                });
386        }
387
388        Ok(())
389    }
390
391    fn insert_function(&mut self, path: &Path, name: String, block: &ast::BlockExpr) -> usize {
392        let mut visitor = OperationVisitor::collect(block);
393        let duration_mutations: BTreeSet<_> = visitor
394            .operations
395            .iter()
396            .filter(|operation| operation.evidence == "hook operation `extend_aura`")
397            .filter_map(|operation| operation.aura_reference.clone())
398            .collect();
399
400        for operation in &mut visitor.operations {
401            let aura_application = operation.evidence == "hook operation `apply_aura`";
402            let coupled_duration = aura_application
403                && operation
404                    .aura_reference
405                    .as_ref()
406                    .is_some_and(|reference| duration_mutations.contains(reference));
407
408            if is_aura_identity_operation(operation) && (operation.controlled || coupled_duration) {
409                operation.coupled = true;
410            }
411        }
412
413        let function = FunctionInfo {
414            file: path.to_path_buf(),
415            name: name.clone(),
416            operations: visitor.operations,
417            calls: visitor.calls,
418            imperative_procs: visitor.imperative_procs,
419        };
420        let imperative_procs = function.imperative_procs;
421
422        self.functions.entry(name).or_default().push(function);
423
424        imperative_procs
425    }
426
427    fn merge(&mut self, other: Self) {
428        for (name, functions) in other.functions {
429            self.functions.entry(name).or_default().extend(functions);
430        }
431    }
432
433    fn unique(&self, name: &str) -> Result<Option<&FunctionInfo>> {
434        let Some(functions) = self.functions.get(name) else {
435            return Ok(None);
436        };
437
438        if functions.len() != 1 {
439            bail!("hook function {name} is ambiguous across the owning module");
440        }
441
442        Ok(functions.first())
443    }
444
445    fn expanded_operations(&self, root: &FunctionInfo) -> Vec<SourceOperation> {
446        let mut pending = vec![(root, false)];
447        let mut reachable = BTreeMap::new();
448
449        while let Some((function, inherited_control)) = pending.pop() {
450            let identity = (function.file.clone(), function.name.clone());
451
452            match reachable.get_mut(&identity) {
453                Some(controlled) if *controlled && !inherited_control => *controlled = false,
454                Some(_) => continue,
455                None => {
456                    reachable.insert(identity, inherited_control);
457                }
458            }
459
460            for call in &function.calls {
461                let Some(candidates) = self.functions.get(&call.name) else {
462                    continue;
463                };
464
465                if let Some(local) = candidates
466                    .iter()
467                    .find(|candidate| candidate.file == function.file)
468                    .or_else(|| (candidates.len() == 1).then(|| &candidates[0]))
469                {
470                    pending.push((local, inherited_control || call.controlled));
471                }
472            }
473        }
474
475        let mut operations = Vec::new();
476
477        for ((file, name), inherited_control) in reachable {
478            let Some(function) = self
479                .functions
480                .get(&name)
481                .and_then(|candidates| candidates.iter().find(|candidate| candidate.file == file))
482            else {
483                continue;
484            };
485
486            operations.extend(function.operations.iter().cloned().map(|mut operation| {
487                if inherited_control && is_aura_identity_operation(&operation) {
488                    operation.coupled = true;
489                }
490
491                operation
492            }));
493        }
494
495        operations
496    }
497}
498
499fn parse_hook_source(path: &Path, source: &str) -> Result<SourceFile> {
500    let parse = SourceFile::parse(source, Edition::Edition2024);
501    let parse_errors = parse.errors();
502
503    if !parse_errors.is_empty() {
504        let details = parse_errors
505            .iter()
506            .map(ToString::to_string)
507            .collect::<Vec<_>>()
508            .join("; ");
509
510        bail!("failed to parse hook source {}: {details}", path.display());
511    }
512
513    Ok(parse.tree())
514}
515
516fn is_aura_identity_operation(operation: &SourceOperation) -> bool {
517    matches!(
518        operation.evidence.as_str(),
519        "hook operation `apply_aura`" | "hook operation `expire_aura`"
520    )
521}
522
523fn collect_rust_paths(root: &Path, paths: &mut Vec<PathBuf>) -> Result<()> {
524    let mut directories = vec![root.to_path_buf()];
525
526    while let Some(current_dir) = directories.pop() {
527        for entry in directory::entries(&current_dir)
528            .with_context(|| format!("failed to walk hook directory {}", current_dir.display()))?
529        {
530            match entry.kind() {
531                EntryKind::Directory => directories.push(entry.path().to_path_buf()),
532                EntryKind::File
533                    if entry
534                        .path()
535                        .extension()
536                        .is_some_and(|extension| extension == "rs") =>
537                {
538                    paths.push(entry.path().to_path_buf());
539                }
540                _ => {}
541            }
542        }
543    }
544
545    Ok(())
546}
547
548// #t(fn: rust_cyclomatic_complexity) this is the exhaustive manifest-hook binding inventory.
549fn spec_bindings(manifest: &Manifest) -> Vec<HookBinding> {
550    let mut bindings = vec![HookBinding {
551        manifest_key: "mastery.hook".to_string(),
552        function: path_function(&manifest.mastery.hook),
553    }];
554
555    if let Some(handler) = &manifest.spec.custom_handler {
556        bindings.push(HookBinding {
557            manifest_key: "spec.custom_handler".to_string(),
558            function: path_function(handler),
559        });
560    }
561
562    if let Some(hook) = &manifest.mastery.crit_damage_hook {
563        bindings.push(HookBinding {
564            manifest_key: "mastery.crit_damage_hook".to_string(),
565            function: path_function(hook),
566        });
567    }
568
569    for (name, aura) in &manifest.auras {
570        if let Some(hook) = &aura.expire_hook {
571            bindings.push(HookBinding {
572                manifest_key: format!("auras.{name}.expire_hook"),
573                function: suffixed(hook, "_hook"),
574            });
575        }
576
577        if let Some(hook) = &aura.tick_hook {
578            bindings.push(HookBinding {
579                manifest_key: format!("auras.{name}.tick_hook"),
580                function: suffixed(hook, "_hook"),
581            });
582        }
583    }
584
585    for (name, spell) in &manifest.spells {
586        if let Some(hook) = &spell.hook {
587            bindings.push(HookBinding {
588                manifest_key: format!("spells.{name}.hook"),
589                function: suffixed(hook, "_hook"),
590            });
591        }
592
593        if let Some(hook) = spell
594            .channel
595            .as_ref()
596            .and_then(|channel| channel.tick_hook.as_ref())
597        {
598            bindings.push(HookBinding {
599                manifest_key: format!("spells.{name}.channel.tick_hook"),
600                function: suffixed(hook, "_tick_hook"),
601            });
602        }
603    }
604
605    for (name, attack) in &manifest.auto_attacks {
606        if let Some(hook) = &attack.hook {
607            bindings.push(HookBinding {
608                manifest_key: format!("auto_attacks.{name}.hook"),
609                function: path_function(hook),
610            });
611        }
612    }
613
614    bindings
615}
616
617fn item_bindings(name: &str, item: &ItemManifestEntry) -> Vec<HookBinding> {
618    let mut bindings =
619        Vec::with_capacity(item.spells.len() + usize::from(item.player_cast_hook.is_some()));
620
621    for (spell_name, spell) in &item.spells {
622        if let Some(hook) = &spell.hook {
623            bindings.push(HookBinding {
624                manifest_key: format!("spells.{spell_name}.hook"),
625                function: path_function(hook),
626            });
627        }
628    }
629
630    if let Some(hook) = &item.player_cast_hook {
631        bindings.push(HookBinding {
632            manifest_key: format!("items.{name}.player_cast_hook"),
633            function: path_function(hook),
634        });
635    }
636
637    bindings
638}
639
640fn suffixed(path: &str, suffix: &str) -> String {
641    let function = path_function(path);
642
643    if function.ends_with(suffix) {
644        function
645    } else {
646        format!("{function}{suffix}")
647    }
648}
649
650fn path_function(path: &str) -> String {
651    path.rsplit("::").next().unwrap_or(path).to_string()
652}
653
654fn last_segment(path: &ast::Path) -> Option<String> {
655    path.segment()?
656        .name_ref()
657        .map(|name| name.text().to_string())
658}
659
660fn is_imperative_proc_type(name: &str) -> bool {
661    matches!(
662        name,
663        "ImpactProc" | "AccumulatingImpactProc" | "ImpactEffectProc"
664    )
665}
666
667fn count_proc_structs(root: &SyntaxNode) -> usize {
668    let mut count = 0;
669    let mut pending_proc_ident = false;
670
671    for token in root
672        .descendants_with_tokens()
673        .filter_map(ra_ap_syntax::NodeOrToken::into_token)
674        .filter(|token| !token.kind().is_trivia())
675    {
676        if token.kind() == SyntaxKind::IDENT {
677            pending_proc_ident = is_imperative_proc_type(token.text());
678        } else if token.text() == "{" {
679            if pending_proc_ident {
680                count += 1;
681            }
682
683            pending_proc_ident = false;
684        } else if token.text() != ":" {
685            pending_proc_ident = false;
686        }
687    }
688
689    count
690}
691
692// #t(fn: rust_cyclomatic_complexity) one ordered classifier keeps mutation names mutually exclusive.
693fn classify_call(name: &str) -> Option<SourceOperation> {
694    let (category, disposition) = if name.starts_with("deal_")
695        || name == "add_residual_damage"
696        || name == "schedule_ground_effect"
697    {
698        (OperationCategory::Damage, LedgerDisposition::GenericGap)
699    } else if name.starts_with("apply_aura") || name == "extend_aura" {
700        (OperationCategory::AuraApply, LedgerDisposition::GenericGap)
701    } else if name.starts_with("expire_aura")
702        || name.starts_with("consume_aura")
703        || name == "reduce_aura"
704    {
705        (OperationCategory::AuraRemove, LedgerDisposition::GenericGap)
706    } else if name.contains("resource") && (name.starts_with("gain") || name.starts_with("spend")) {
707        (OperationCategory::Resource, LedgerDisposition::GenericGap)
708    } else if name.contains("cooldown") || name.contains("recharge") {
709        (OperationCategory::Cooldown, LedgerDisposition::GenericGap)
710    } else if name.contains("resolved_spell_effect_targets") || name.starts_with("target_") {
711        (OperationCategory::Targeting, LedgerDisposition::GenericGap)
712    } else if name.contains("guardian") || name.contains("pet_") || name == "summon_pet" {
713        (OperationCategory::ActorOrPet, LedgerDisposition::Unresolved)
714    } else if name.contains("replacement") || name.contains("override_spell") {
715        (
716            OperationCategory::Replacement,
717            LedgerDisposition::GenericGap,
718        )
719    } else if name.starts_with("schedule_") || name.contains("spec_runtime_mut") {
720        (
721            OperationCategory::OrderedContent,
722            LedgerDisposition::ContentOrdering,
723        )
724    } else {
725        return None;
726    };
727
728    Some(SourceOperation {
729        category,
730        disposition,
731        evidence: format!("hook operation `{name}`"),
732        aura_reference: None,
733        controlled: false,
734        coupled: false,
735    })
736}
737
738fn aura_reference(expression: &ast::Expr) -> Option<AuraReference> {
739    let mut expression = expression.clone();
740
741    loop {
742        match expression {
743            ast::Expr::FieldExpr(field) => expression = field.expr()?,
744            ast::Expr::Literal(literal) => {
745                return match literal.kind() {
746                    LiteralKind::IntNumber(value) => u32::try_from(value.value().ok()?)
747                        .ok()
748                        .map(AuraReference::Id),
749                    _ => None,
750                };
751            }
752            ast::Expr::ParenExpr(paren) => expression = paren.expr()?,
753            ast::Expr::PathExpr(path) => {
754                return path
755                    .path()
756                    .as_ref()
757                    .and_then(last_segment)
758                    .and_then(|name| {
759                        let normalized = name.strip_prefix("AURA_").unwrap_or(&name);
760
761                        (normalized != "AURA").then(|| AuraReference::Name(normalized.to_string()))
762                    });
763            }
764            ast::Expr::RefExpr(reference) => expression = reference.expr()?,
765            _ => return None,
766        }
767    }
768}
769
770fn resolve_aura_reference(
771    reference: &AuraReference,
772    aura_ids: &BTreeMap<String, u32>,
773) -> Option<u32> {
774    match reference {
775        AuraReference::Id(id) => Some(*id),
776        AuraReference::Name(name) => aura_ids.get(name).copied(),
777    }
778}
779
780fn owner_from_hook_path(root: &Path, file: &Path) -> String {
781    let relative = file.strip_prefix(root).unwrap_or(file);
782    let mut components = relative.components();
783    let first = components.next().map_or_else(
784        || "unknown".to_string(),
785        |component| component.as_os_str().to_string_lossy().into_owned(),
786    );
787
788    if first == "items" {
789        let item = components
790            .next()
791            .and_then(|component| {
792                Path::new(component.as_os_str())
793                    .file_stem()
794                    .map(|stem| stem.to_string_lossy().to_ascii_uppercase())
795            })
796            .unwrap_or_else(|| "UNKNOWN".to_string());
797
798        format!("item:{item}")
799    } else {
800        first
801    }
802}
803
804fn relative_path(root: &Path, file: &Path) -> PathBuf {
805    file.strip_prefix(root)
806        .map_or_else(|_| file.to_path_buf(), Path::to_path_buf)
807}
808
809#[cfg(test)]
810mod tests;