Skip to main content

diffkit/
engine.rs

1use std::collections::{BTreeSet, HashMap};
2use std::fs;
3use std::path::{Component, Path, PathBuf};
4use std::process::Command;
5use std::sync::{Mutex, OnceLock};
6
7use crate::DiffkitResult;
8use crate::diff::{
9    DiffNode, collapse_unchanged_subtrees, diff_optional, tree_has_changes, truncate_diff_tree,
10};
11use crate::graph::ProgramGraph;
12use crate::language::ocaml::OcamlFrontend;
13use crate::language::rust::{
14    RustProjectSession, analyze_semantic_file_with_entries, analyze_semantic_project,
15    analyze_semantic_source,
16};
17use crate::language::{FileContext, LanguageBackend, ProjectContext};
18use crate::model::{CallNode, FileAnalysis, SymbolId};
19
20static CARGO_SOURCE_LAYOUTS: OnceLock<Mutex<HashMap<PathBuf, Option<CargoSourceLayout>>>> =
21    OnceLock::new();
22
23#[derive(Clone)]
24struct CargoSourceLayout {
25    package_roots: Vec<PathBuf>,
26    target_sources: Vec<PathBuf>,
27}
28
29#[derive(Clone, Debug)]
30pub struct DiffOptions {
31    pub entries: Vec<String>,
32    pub max_depth: usize,
33}
34
35impl Default for DiffOptions {
36    fn default() -> Self {
37        Self {
38            entries: Vec::new(),
39            max_depth: 8,
40        }
41    }
42}
43
44#[derive(Clone, Debug)]
45pub struct EntryDiff {
46    pub entry: SymbolId,
47    pub tree: DiffNode,
48}
49
50#[derive(Clone, Debug)]
51pub struct DiffReport {
52    pub language: String,
53    pub before: String,
54    pub after: String,
55    pub trees: Vec<EntryDiff>,
56    pub message: Option<String>,
57    pub analyzed_files: BTreeSet<PathBuf>,
58    pub diagnostics: Vec<String>,
59}
60
61#[derive(Clone, Debug)]
62pub struct EntryTree {
63    pub entry: SymbolId,
64    pub tree: CallNode,
65}
66
67#[derive(Clone, Debug)]
68pub struct TreeReport {
69    pub language: String,
70    pub source: String,
71    pub trees: Vec<EntryTree>,
72    pub message: Option<String>,
73    pub diagnostics: Vec<String>,
74}
75
76pub fn rustdiff_sources(
77    before_name: impl Into<String>,
78    before_source: &str,
79    after_name: impl Into<String>,
80    after_source: &str,
81    options: &DiffOptions,
82) -> DiffkitResult<DiffReport> {
83    let before = graph_from_files([analyze_semantic_source(before_source, &options.entries)?])?;
84    let after = graph_from_files([analyze_semantic_source(after_source, &options.entries)?])?;
85    build_report(
86        "rust".to_owned(),
87        before_name.into(),
88        after_name.into(),
89        &before,
90        &after,
91        options,
92    )
93}
94
95pub fn rustdiff_paths(
96    before_path: &Path,
97    after_path: &Path,
98    options: &DiffOptions,
99) -> DiffkitResult<DiffReport> {
100    let before = graph_from_files([analyze_semantic_file_with_entries(
101        before_path,
102        &options.entries,
103    )?])?;
104    let after = graph_from_files([analyze_semantic_file_with_entries(
105        after_path,
106        &options.entries,
107    )?])?;
108    build_report(
109        "rust".to_owned(),
110        before_path.display().to_string(),
111        after_path.display().to_string(),
112        &before,
113        &after,
114        options,
115    )
116}
117
118pub fn rustdiff_project_paths(
119    before_root: &Path,
120    after_root: &Path,
121    wrapper_executable: &Path,
122    options: &DiffOptions,
123) -> DiffkitResult<DiffReport> {
124    let session = RustProjectSession::create()?;
125    rustdiff_project_paths_with_session(
126        before_root,
127        after_root,
128        wrapper_executable,
129        options,
130        &session,
131    )
132}
133
134pub fn rustdiff_project_paths_cached(
135    before_root: &Path,
136    after_root: &Path,
137    cache_project_root: &Path,
138    wrapper_executable: &Path,
139    options: &DiffOptions,
140    verbose: bool,
141) -> DiffkitResult<DiffReport> {
142    let before_session =
143        RustProjectSession::create_cached(cache_project_root, "git-before", verbose)?;
144    let after_session =
145        RustProjectSession::create_cached(cache_project_root, "git-after", verbose)?;
146    rustdiff_project_paths_with_sessions(
147        before_root,
148        after_root,
149        wrapper_executable,
150        options,
151        &before_session,
152        &after_session,
153    )
154}
155
156fn rustdiff_project_paths_with_session(
157    before_root: &Path,
158    after_root: &Path,
159    wrapper_executable: &Path,
160    options: &DiffOptions,
161    session: &RustProjectSession,
162) -> DiffkitResult<DiffReport> {
163    rustdiff_project_paths_with_sessions(
164        before_root,
165        after_root,
166        wrapper_executable,
167        options,
168        session,
169        session,
170    )
171}
172
173fn rustdiff_project_paths_with_sessions(
174    before_root: &Path,
175    after_root: &Path,
176    wrapper_executable: &Path,
177    options: &DiffOptions,
178    before_session: &RustProjectSession,
179    after_session: &RustProjectSession,
180) -> DiffkitResult<DiffReport> {
181    let (before, after) = if std::ptr::eq(before_session, after_session) {
182        (
183            rust_project_graph(
184                before_root,
185                wrapper_executable,
186                &options.entries,
187                before_session,
188            )?,
189            rust_project_graph(
190                after_root,
191                wrapper_executable,
192                &options.entries,
193                after_session,
194            )?,
195        )
196    } else {
197        rust_project_graphs_parallel(
198            before_root,
199            after_root,
200            wrapper_executable,
201            &options.entries,
202            before_session,
203            after_session,
204        )?
205    };
206    build_report(
207        "rust".to_owned(),
208        before_root.display().to_string(),
209        after_root.display().to_string(),
210        &before,
211        &after,
212        options,
213    )
214}
215
216pub fn rustdiff_project_files(
217    before_file: &Path,
218    after_file: &Path,
219    wrapper_executable: &Path,
220    options: &DiffOptions,
221    verbose: bool,
222) -> DiffkitResult<Option<DiffReport>> {
223    let before_root = find_cargo_root(before_file)?;
224    let after_root = find_cargo_root(after_file)?;
225    let before_session = RustProjectSession::create_cached(&before_root, "file-before", verbose)?;
226    let after_session = RustProjectSession::create_cached(&after_root, "file-after", verbose)?;
227    let (before, after) = rust_project_graphs_parallel(
228        &before_root,
229        &after_root,
230        wrapper_executable,
231        &options.entries,
232        &before_session,
233        &after_session,
234    )?;
235    if !before.analyzes_file(before_file) && !after.analyzes_file(after_file) {
236        return Ok(None);
237    }
238    build_file_report(
239        "rust".to_owned(),
240        before_file,
241        after_file,
242        &before,
243        &after,
244        options,
245    )
246    .map(Some)
247}
248
249pub fn ocamldiff_sources(
250    before_name: impl Into<String>,
251    before_source: &str,
252    after_name: impl Into<String>,
253    after_source: &str,
254    options: &DiffOptions,
255) -> DiffkitResult<DiffReport> {
256    let frontend = OcamlFrontend;
257    diff_sources(
258        before_name.into(),
259        before_source,
260        after_name.into(),
261        after_source,
262        &frontend,
263        options,
264    )
265}
266
267pub fn ocamldiff_paths(
268    before_path: &Path,
269    after_path: &Path,
270    options: &DiffOptions,
271) -> DiffkitResult<DiffReport> {
272    let frontend = OcamlFrontend;
273    let before = ocaml_optional_project_graph(before_path, &frontend)?;
274    let after = ocaml_optional_project_graph(after_path, &frontend)?;
275    build_report(
276        frontend.language().0,
277        before_path.display().to_string(),
278        after_path.display().to_string(),
279        &before,
280        &after,
281        options,
282    )
283}
284
285pub fn ocamldiff_project_files(
286    before_file: &Path,
287    after_file: &Path,
288    options: &DiffOptions,
289) -> DiffkitResult<DiffReport> {
290    let frontend = OcamlFrontend;
291    let before_root = find_ocaml_root(before_file);
292    let after_root = find_ocaml_root(after_file);
293    let before = ocaml_project_graph(&before_root, &frontend)?;
294    let after = ocaml_project_graph(&after_root, &frontend)?;
295    build_file_report(
296        frontend.language().0,
297        before_file,
298        after_file,
299        &before,
300        &after,
301        options,
302    )
303}
304
305pub fn rusttree_path(path: &Path, options: &DiffOptions) -> DiffkitResult<TreeReport> {
306    let graph = graph_from_files([analyze_semantic_file_with_entries(path, &options.entries)?])?;
307    build_tree_report("rust", path, &graph, options)
308}
309
310pub fn rusttree_project_file(
311    path: &Path,
312    wrapper_executable: &Path,
313    options: &DiffOptions,
314    verbose: bool,
315) -> DiffkitResult<Option<TreeReport>> {
316    let root = find_cargo_root(path)?;
317    let session = RustProjectSession::create_cached(&root, "tree", verbose)?;
318    let graph = rust_project_graph(&root, wrapper_executable, &options.entries, &session)?;
319    if !graph.analyzes_file(path) {
320        return Ok(None);
321    }
322    build_tree_report("rust", path, &graph, options).map(Some)
323}
324
325pub fn ocamltree_path(path: &Path, options: &DiffOptions) -> DiffkitResult<TreeReport> {
326    let frontend = OcamlFrontend;
327    let graph = load_path(path, &frontend)?;
328    build_tree_report("ocaml", path, &graph, options)
329}
330
331pub fn ocamltree_project_file(path: &Path, options: &DiffOptions) -> DiffkitResult<TreeReport> {
332    let frontend = OcamlFrontend;
333    let root = find_ocaml_root(path);
334    let graph = ocaml_project_graph(&root, &frontend)?;
335    build_tree_report("ocaml", path, &graph, options)
336}
337
338fn diff_sources(
339    before_name: String,
340    before_source: &str,
341    after_name: String,
342    after_source: &str,
343    frontend: &impl LanguageBackend,
344    options: &DiffOptions,
345) -> DiffkitResult<DiffReport> {
346    let empty_module = Vec::new();
347    let before_analysis = frontend.analyze_file(
348        &FileContext {
349            path: Path::new(&before_name),
350            module: &empty_module,
351        },
352        before_source,
353    )?;
354    let after_analysis = frontend.analyze_file(
355        &FileContext {
356            path: Path::new(&after_name),
357            module: &empty_module,
358        },
359        after_source,
360    )?;
361    let before = graph_from_files([before_analysis])?;
362    let after = graph_from_files([after_analysis])?;
363    build_report(
364        frontend.language().0,
365        before_name,
366        after_name,
367        &before,
368        &after,
369        options,
370    )
371}
372
373fn build_report(
374    language: String,
375    before_name: String,
376    after_name: String,
377    before: &ProgramGraph,
378    after: &ProgramGraph,
379    options: &DiffOptions,
380) -> DiffkitResult<DiffReport> {
381    let candidate_entries = if options.entries.is_empty() {
382        ordered_root_union(before, after)
383    } else {
384        resolve_explicit_entries(before, after, &options.entries)?
385            .into_iter()
386            .collect()
387    };
388
389    let trees = changed_entries(before, after, candidate_entries, options.max_depth);
390    let mut report = report_from_trees(language, before_name, after_name, trees);
391    report.analyzed_files.extend(before.source_files());
392    report.analyzed_files.extend(after.source_files());
393    report.diagnostics = comparison_diagnostics(before, after);
394    Ok(report)
395}
396
397fn build_file_report(
398    language: String,
399    before_path: &Path,
400    after_path: &Path,
401    before: &ProgramGraph,
402    after: &ProgramGraph,
403    options: &DiffOptions,
404) -> DiffkitResult<DiffReport> {
405    let candidate_entries = if options.entries.is_empty() {
406        ordered_file_root_union(before, after, before_path, after_path)
407    } else {
408        resolve_explicit_file_entries(before, after, before_path, after_path, &options.entries)?
409            .into_iter()
410            .collect()
411    };
412    let trees = candidate_entries
413        .into_iter()
414        .filter_map(|entry| {
415            let before_tree = before.build_call_tree_in_file(&entry, usize::MAX, Some(before_path));
416            let after_tree = after.build_call_tree_in_file(&entry, usize::MAX, Some(after_path));
417            let mut tree = diff_optional(before_tree.as_ref(), after_tree.as_ref())?;
418            tree_has_changes(&tree).then(|| {
419                collapse_unchanged_subtrees(&mut tree);
420                truncate_diff_tree(&mut tree, options.max_depth);
421                EntryDiff { entry, tree }
422            })
423        })
424        .collect();
425    let mut report = report_from_trees(
426        language,
427        before_path.display().to_string(),
428        after_path.display().to_string(),
429        trees,
430    );
431    report.analyzed_files.extend(before.source_files());
432    report.analyzed_files.extend(after.source_files());
433    report.diagnostics = comparison_diagnostics(before, after);
434    Ok(report)
435}
436
437fn build_tree_report(
438    language: &str,
439    path: &Path,
440    graph: &ProgramGraph,
441    options: &DiffOptions,
442) -> DiffkitResult<TreeReport> {
443    let entries = if options.entries.is_empty() {
444        graph.roots_in_file(path).into_iter().collect::<Vec<_>>()
445    } else {
446        let mut resolved = Vec::new();
447        for entry in &options.entries {
448            let matches = graph
449                .resolve_entries_in_file(entry, path)
450                .map_err(std::io::Error::other)?;
451            if matches.is_empty() {
452                return Err(std::io::Error::other(format!(
453                    "entry not found in selected file: {entry}"
454                ))
455                .into());
456            }
457            resolved.extend(matches);
458        }
459        resolved
460    };
461
462    let trees = entries
463        .into_iter()
464        .filter_map(|entry| {
465            graph
466                .build_call_tree_in_file(&entry, options.max_depth, Some(path))
467                .map(|tree| EntryTree { entry, tree })
468        })
469        .collect::<Vec<_>>();
470    let message = trees
471        .is_empty()
472        .then(|| format!("No {language} call trees in {}.", path.display()));
473    Ok(TreeReport {
474        language: language.to_owned(),
475        source: path.display().to_string(),
476        trees,
477        message,
478        diagnostics: graph.resolution_diagnostics(),
479    })
480}
481
482fn ordered_root_union(before: &ProgramGraph, after: &ProgramGraph) -> Vec<SymbolId> {
483    let before_roots = before.inferred_roots();
484    let after_roots = after.inferred_roots();
485    let mut common = before_roots
486        .intersection(&after_roots)
487        .cloned()
488        .collect::<Vec<_>>();
489    let mut removed = before_roots
490        .difference(&after_roots)
491        .cloned()
492        .collect::<Vec<_>>();
493    let mut added = after_roots
494        .difference(&before_roots)
495        .cloned()
496        .collect::<Vec<_>>();
497    sort_by_source(&mut common, before);
498    sort_by_source(&mut removed, before);
499    sort_by_source(&mut added, after);
500    common.extend(removed);
501    common.extend(added);
502    common
503}
504
505fn ordered_file_root_union(
506    before: &ProgramGraph,
507    after: &ProgramGraph,
508    before_path: &Path,
509    after_path: &Path,
510) -> Vec<SymbolId> {
511    let before_roots = before.roots_in_file(before_path);
512    let after_roots = after.roots_in_file(after_path);
513    let mut common = before_roots
514        .intersection(&after_roots)
515        .cloned()
516        .collect::<Vec<_>>();
517    let mut removed = before_roots
518        .difference(&after_roots)
519        .cloned()
520        .collect::<Vec<_>>();
521    let mut added = after_roots
522        .difference(&before_roots)
523        .cloned()
524        .collect::<Vec<_>>();
525    sort_by_source(&mut common, before);
526    sort_by_source(&mut removed, before);
527    sort_by_source(&mut added, after);
528    common.extend(removed);
529    common.extend(added);
530    common
531}
532
533fn sort_by_source(symbols: &mut [SymbolId], graph: &ProgramGraph) {
534    symbols.sort_by_key(|symbol| {
535        graph.functions().get(symbol).map_or_else(
536            || (PathBuf::new(), usize::MAX, usize::MAX, symbol.clone()),
537            |function| {
538                (
539                    function.span.file.clone(),
540                    function.span.start_line,
541                    function.span.start_column,
542                    symbol.clone(),
543                )
544            },
545        )
546    });
547}
548
549fn report_from_trees(
550    language: String,
551    before: String,
552    after: String,
553    trees: Vec<EntryDiff>,
554) -> DiffReport {
555    let message = trees
556        .is_empty()
557        .then(|| format!("No {language} call changes between {before} and {after}."));
558    DiffReport {
559        language,
560        before,
561        after,
562        trees,
563        message,
564        analyzed_files: BTreeSet::new(),
565        diagnostics: Vec::new(),
566    }
567}
568
569fn comparison_diagnostics(before: &ProgramGraph, after: &ProgramGraph) -> Vec<String> {
570    before
571        .resolution_diagnostics()
572        .into_iter()
573        .map(|diagnostic| format!("before: {diagnostic}"))
574        .chain(
575            after
576                .resolution_diagnostics()
577                .into_iter()
578                .map(|diagnostic| format!("after: {diagnostic}")),
579        )
580        .collect()
581}
582
583fn resolve_explicit_entries(
584    before: &ProgramGraph,
585    after: &ProgramGraph,
586    entries: &[String],
587) -> DiffkitResult<BTreeSet<SymbolId>> {
588    let mut resolved = BTreeSet::new();
589    for entry in entries {
590        let before_entries = before
591            .resolve_entries(entry)
592            .map_err(std::io::Error::other)?;
593        let after_entries = after
594            .resolve_entries(entry)
595            .map_err(std::io::Error::other)?;
596        if before_entries.is_empty() && after_entries.is_empty() {
597            return Err(std::io::Error::other(format!("entry not found: {entry}")).into());
598        }
599        resolved.extend(before_entries);
600        resolved.extend(after_entries);
601    }
602    Ok(resolved)
603}
604
605fn resolve_explicit_file_entries(
606    before: &ProgramGraph,
607    after: &ProgramGraph,
608    before_path: &Path,
609    after_path: &Path,
610    entries: &[String],
611) -> DiffkitResult<BTreeSet<SymbolId>> {
612    let mut resolved = BTreeSet::new();
613    for entry in entries {
614        let before_entries = before
615            .resolve_entries_in_file(entry, before_path)
616            .map_err(std::io::Error::other)?;
617        let after_entries = after
618            .resolve_entries_in_file(entry, after_path)
619            .map_err(std::io::Error::other)?;
620        if before_entries.is_empty() && after_entries.is_empty() {
621            return Err(std::io::Error::other(format!(
622                "entry not found in selected file: {entry}"
623            ))
624            .into());
625        }
626        resolved.extend(before_entries);
627        resolved.extend(after_entries);
628    }
629    Ok(resolved)
630}
631
632fn changed_entries(
633    before: &ProgramGraph,
634    after: &ProgramGraph,
635    entries: impl IntoIterator<Item = SymbolId>,
636    max_depth: usize,
637) -> Vec<EntryDiff> {
638    entries
639        .into_iter()
640        .filter_map(|entry| {
641            let before_tree = before.build_call_tree(&entry, usize::MAX);
642            let after_tree = after.build_call_tree(&entry, usize::MAX);
643            let mut tree = diff_optional(before_tree.as_ref(), after_tree.as_ref())?;
644            tree_has_changes(&tree).then(|| {
645                collapse_unchanged_subtrees(&mut tree);
646                truncate_diff_tree(&mut tree, max_depth);
647                EntryDiff { entry, tree }
648            })
649        })
650        .collect()
651}
652
653fn load_path(path: &Path, frontend: &impl LanguageBackend) -> DiffkitResult<ProgramGraph> {
654    if !path.exists() {
655        return Err(std::io::Error::new(
656            std::io::ErrorKind::NotFound,
657            format!("path does not exist: {}", path.display()),
658        )
659        .into());
660    }
661
662    let files = collect_source_files(path, frontend.extensions())?;
663    if files.is_empty() {
664        return Err(std::io::Error::other(format!(
665            "no {} source files found under {}",
666            frontend.language(),
667            path.display()
668        ))
669        .into());
670    }
671
672    let single_file = path.is_file();
673    let root = if single_file {
674        path.parent().unwrap_or_else(|| Path::new("."))
675    } else {
676        path
677    };
678    let mut analyses = Vec::with_capacity(files.len());
679    for file in files {
680        let source = fs::read_to_string(&file)?;
681        let module = if single_file {
682            Vec::new()
683        } else {
684            module_path(root, &file)
685        };
686        analyses.push(frontend.analyze_file(
687            &FileContext {
688                path: &file,
689                module: &module,
690            },
691            &source,
692        )?);
693    }
694    graph_from_files(analyses)
695}
696
697fn graph_from_files(files: impl IntoIterator<Item = FileAnalysis>) -> DiffkitResult<ProgramGraph> {
698    ProgramGraph::from_files(files)
699        .map_err(std::io::Error::other)
700        .map_err(Into::into)
701}
702
703fn rust_project_graph(
704    root: &Path,
705    wrapper_executable: &Path,
706    entries: &[String],
707    session: &RustProjectSession,
708) -> DiffkitResult<ProgramGraph> {
709    if !root.join("Cargo.toml").is_file() {
710        return graph_from_files(std::iter::empty());
711    }
712    graph_from_files([analyze_semantic_project(
713        root,
714        wrapper_executable,
715        entries,
716        session,
717    )?])
718}
719
720fn rust_project_graphs_parallel(
721    before_root: &Path,
722    after_root: &Path,
723    wrapper_executable: &Path,
724    entries: &[String],
725    before_session: &RustProjectSession,
726    after_session: &RustProjectSession,
727) -> DiffkitResult<(ProgramGraph, ProgramGraph)> {
728    std::thread::scope(|scope| {
729        let before = scope
730            .spawn(|| rust_project_graph(before_root, wrapper_executable, entries, before_session));
731        let after = scope
732            .spawn(|| rust_project_graph(after_root, wrapper_executable, entries, after_session));
733        let before = before
734            .join()
735            .map_err(|_| std::io::Error::other("before Rust analysis worker panicked"))??;
736        let after = after
737            .join()
738            .map_err(|_| std::io::Error::other("after Rust analysis worker panicked"))??;
739        Ok((before, after))
740    })
741}
742
743fn ocaml_project_graph(
744    root: &Path,
745    frontend: &impl LanguageBackend,
746) -> DiffkitResult<ProgramGraph> {
747    if root.is_dir() {
748        return graph_from_files([frontend.analyze_project(&ProjectContext {
749            root,
750            driver_executable: None,
751            entries: &[],
752            cache: None,
753        })?]);
754    }
755    load_path(root, frontend)
756}
757
758fn ocaml_optional_project_graph(
759    root: &Path,
760    frontend: &impl LanguageBackend,
761) -> DiffkitResult<ProgramGraph> {
762    if !root.exists() {
763        return graph_from_files(std::iter::empty());
764    }
765    ocaml_project_graph(root, frontend)
766}
767
768pub fn find_cargo_root(path: &Path) -> DiffkitResult<PathBuf> {
769    let start = if path.is_dir() {
770        path
771    } else {
772        path.parent().unwrap_or_else(|| Path::new("."))
773    };
774    for ancestor in start.ancestors() {
775        if ancestor.join("Cargo.toml").is_file() {
776            return Ok(ancestor.canonicalize()?);
777        }
778    }
779    Err(std::io::Error::other(format!("no Cargo.toml found above {}", path.display())).into())
780}
781
782/// Return whether Cargo can plausibly compile `path` as part of one of the
783/// package targets in the surrounding workspace. Merely living somewhere
784/// below a Cargo.toml is not enough (documentation snippets are a common
785/// counterexample).
786pub fn rust_file_is_project_source(path: &Path) -> bool {
787    let Ok(root) = find_cargo_root(path) else {
788        return false;
789    };
790    let absolute = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
791    let Some(layout) = cargo_source_layout(&root) else {
792        return conventional_rust_source(&absolute, &root);
793    };
794    layout
795        .package_roots
796        .iter()
797        .any(|root| conventional_rust_source(&absolute, root))
798        || layout
799            .target_sources
800            .iter()
801            .any(|source| target_contains_source(source, &absolute))
802}
803
804fn cargo_source_layout(root: &Path) -> Option<CargoSourceLayout> {
805    let cache = CARGO_SOURCE_LAYOUTS.get_or_init(|| Mutex::new(HashMap::new()));
806    if let Some(layout) = cache.lock().ok()?.get(root).cloned() {
807        return layout;
808    }
809
810    let output = Command::new("cargo")
811        .args(["metadata", "--no-deps", "--format-version", "1"])
812        .current_dir(root)
813        .output()
814        .ok();
815    let layout = output
816        .filter(|output| output.status.success())
817        .and_then(|output| serde_json::from_slice::<serde_json::Value>(&output.stdout).ok())
818        .and_then(|metadata| {
819            let packages = metadata.get("packages")?.as_array()?;
820            let package_roots = packages
821                .iter()
822                .filter_map(|package| package.get("manifest_path"))
823                .filter_map(serde_json::Value::as_str)
824                .filter_map(|manifest| Path::new(manifest).parent())
825                .map(Path::to_path_buf)
826                .collect();
827            let target_sources = packages
828                .iter()
829                .flat_map(|package| {
830                    package
831                        .get("targets")
832                        .and_then(serde_json::Value::as_array)
833                        .into_iter()
834                        .flatten()
835                })
836                .filter_map(|target| target.get("src_path"))
837                .filter_map(serde_json::Value::as_str)
838                .map(PathBuf::from)
839                .collect();
840            Some(CargoSourceLayout {
841                package_roots,
842                target_sources,
843            })
844        });
845    if let Ok(mut cache) = cache.lock() {
846        cache.insert(root.to_path_buf(), layout.clone());
847    }
848    layout
849}
850
851fn conventional_rust_source(file: &Path, package_root: &Path) -> bool {
852    file.starts_with(package_root.join("src"))
853}
854
855fn target_contains_source(target: &Path, file: &Path) -> bool {
856    if target == file {
857        return true;
858    }
859    let Some(parent) = target.parent() else {
860        return false;
861    };
862    match target.file_name().and_then(|name| name.to_str()) {
863        Some("lib.rs" | "main.rs" | "mod.rs") => file.starts_with(parent),
864        _ => target
865            .file_stem()
866            .map(|stem| file.starts_with(parent.join(stem)))
867            .unwrap_or(false),
868    }
869}
870
871fn collect_source_files(path: &Path, extensions: &[&str]) -> DiffkitResult<Vec<PathBuf>> {
872    let mut files = Vec::new();
873    if path.is_file() {
874        if has_extension(path, extensions) {
875            files.push(path.to_path_buf());
876        }
877    } else {
878        collect_directory(path, extensions, &mut files)?;
879    }
880    files.sort();
881    Ok(files)
882}
883
884fn collect_directory(
885    directory: &Path,
886    extensions: &[&str],
887    files: &mut Vec<PathBuf>,
888) -> DiffkitResult<()> {
889    let mut entries = fs::read_dir(directory)?.collect::<Result<Vec<_>, _>>()?;
890    entries.sort_by_key(std::fs::DirEntry::path);
891    for entry in entries {
892        let file_type = entry.file_type()?;
893        let path = entry.path();
894        if file_type.is_dir() {
895            if !matches!(
896                path.file_name().and_then(|name| name.to_str()),
897                Some("target" | ".git" | "_build" | "node_modules" | ".zig-cache" | "zig-out")
898            ) {
899                collect_directory(&path, extensions, files)?;
900            }
901        } else if file_type.is_file() && has_extension(&path, extensions) {
902            files.push(path);
903        }
904    }
905    Ok(())
906}
907
908fn find_ocaml_root(path: &Path) -> PathBuf {
909    let start = if path.is_dir() {
910        path
911    } else {
912        path.parent().unwrap_or_else(|| Path::new("."))
913    };
914    for ancestor in start.ancestors() {
915        if ancestor.join("dune-project").is_file() {
916            return ancestor.to_path_buf();
917        }
918    }
919    start.to_path_buf()
920}
921
922fn has_extension(path: &Path, extensions: &[&str]) -> bool {
923    path.extension()
924        .and_then(|extension| extension.to_str())
925        .is_some_and(|extension| extensions.contains(&extension))
926}
927
928fn module_path(root: &Path, file: &Path) -> Vec<String> {
929    let relative = file.strip_prefix(root).unwrap_or(file);
930    let mut parts = relative
931        .components()
932        .filter_map(|component| match component {
933            Component::Normal(value) => value.to_str().map(ToOwned::to_owned),
934            _ => None,
935        })
936        .collect::<Vec<_>>();
937
938    if parts.first().is_some_and(|part| part == "src") {
939        parts.remove(0);
940    }
941    if let Some(last) = parts.last_mut() {
942        *last = Path::new(last)
943            .file_stem()
944            .and_then(|stem| stem.to_str())
945            .unwrap_or(last)
946            .to_owned();
947    }
948    if parts
949        .last()
950        .is_some_and(|part| matches!(part.as_str(), "lib" | "main" | "mod"))
951    {
952        parts.pop();
953    }
954    parts
955}
956
957#[cfg(test)]
958mod tests {
959    use super::*;
960
961    #[test]
962    fn cargo_test_targets_are_project_sources() {
963        let test = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/cli.rs");
964        assert!(rust_file_is_project_source(&test));
965    }
966}