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
782pub 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}