1use std::collections::{BTreeMap, BTreeSet};
3use std::path::Path;
4
5use serde::Serialize;
6
7use lean_ctx::core::artifacts::ResolvedArtifact;
8use lean_ctx::core::tokens::count_tokens;
9
10const DEFAULT_IMPACT_DEPTH: usize = 3;
11const MAX_CHANGED_FILES_SHOWN: usize = 200;
12const MAX_DIFF_BYTES: usize = 1_048_576; #[derive(Debug, Clone, Serialize)]
15struct ChangedFile {
16 path: String,
17 status: String,
18 #[serde(skip_serializing_if = "Option::is_none")]
19 old_path: Option<String>,
20}
21
22#[derive(Debug, Clone, Serialize)]
23struct ImpactEntry {
24 file: String,
25 affected_files: Vec<String>,
26}
27
28#[derive(Debug, Serialize)]
29struct PrPackJson {
30 kind: &'static str,
31 project_root: String,
32 base: String,
33 impact_depth: usize,
34 changed_files: Vec<ChangedFile>,
35 related_tests: Vec<String>,
36 impacts: Vec<ImpactEntry>,
37 context_artifacts: Vec<ResolvedArtifact>,
38 warnings: Vec<String>,
39 tokens: u64,
40}
41
42pub fn handle(
43 action: &str,
44 project_root: &str,
45 base: Option<&str>,
46 format: Option<&str>,
47 depth: Option<usize>,
48 diff: Option<&str>,
49) -> String {
50 match action {
51 "pr" => handle_pr(project_root, base, format, depth, diff),
52 _ => "Unknown action. Use: pr, create, list, info, remove, install, export, import, auto_load, summary".to_string(),
53 }
54}
55
56pub(crate) fn handle_create(
59 project_root: &str,
60 request: &lean_ctx::core::context_package::selection::PackRequest,
61 preview: bool,
62) -> String {
63 let session = request
64 .selection
65 .wants("session")
66 .then(|| lean_ctx::core::session::caller::with_for_project(project_root, Clone::clone))
68 .flatten();
69 let plan = match lean_ctx::core::context_package::selection::plan(
70 project_root,
71 request,
72 session.as_ref(),
73 ) {
74 Ok(plan) => plan,
75 Err(e) => return format!("ERROR: build failed: {e}"),
76 };
77 if preview {
78 return plan.render(true);
79 }
80 let registry = match lean_ctx::core::context_package::LocalRegistry::open() {
81 Ok(r) => r,
82 Err(e) => return format!("ERROR: cannot open registry: {e}"),
83 };
84 match registry.install_local_build(&plan.manifest, &plan.content) {
85 Ok(dir) => format!(
86 "Package created: {}@{} (level {})\n{} Stored: {}",
87 plan.manifest.name,
88 plan.manifest.version,
89 plan.manifest.conformance_level.unwrap_or(1),
90 plan.render(false),
91 dir.display()
92 ),
93 Err(e) => format!("ERROR: install failed: {e}"),
94 }
95}
96
97pub fn handle_list() -> String {
98 let registry = match lean_ctx::core::context_package::LocalRegistry::open() {
99 Ok(r) => r,
100 Err(e) => return format!("ERROR: {e}"),
101 };
102
103 match registry.list() {
104 Ok(entries) => {
105 if entries.is_empty() {
106 return "No packages installed.".to_string();
107 }
108 let mut out = String::new();
109 out.push_str(&format!("{} package(s):\n", entries.len()));
110 for e in &entries {
111 out.push_str(&format!(
112 "- {} v{} [{}] ({} bytes){}\n",
113 e.name,
114 e.version,
115 e.layers.join(", "),
116 e.byte_size,
117 if e.auto_load { " [auto-load]" } else { "" }
118 ));
119 }
120 out
121 }
122 Err(e) => format!("ERROR: {e}"),
123 }
124}
125
126pub fn handle_info(name: &str, version: Option<&str>) -> String {
127 let registry = match lean_ctx::core::context_package::LocalRegistry::open() {
128 Ok(r) => r,
129 Err(e) => return format!("ERROR: {e}"),
130 };
131
132 let resolved_ver;
133 let ver = if let Some(v) = version {
134 v
135 } else {
136 resolved_ver = registry
137 .list()
138 .ok()
139 .and_then(|entries| {
140 entries
141 .iter()
142 .filter(|e| e.name == name)
143 .max_by(|a, b| a.installed_at.cmp(&b.installed_at))
144 .map(|e| e.version.clone())
145 })
146 .unwrap_or_default();
147 &resolved_ver
148 };
149
150 match registry.load_package(name, ver) {
151 Ok((manifest, content)) => {
152 let layers_str = manifest
153 .layers
154 .iter()
155 .map(lean_ctx::core::context_package::PackageLayer::as_str)
156 .collect::<Vec<_>>()
157 .join(", ");
158 let mut out = format!(
159 "Package: {} v{}\nSchema: v{}\nLevel: {}\nLayers: {}\nDescription: {}\n",
160 manifest.name,
161 manifest.version,
162 manifest.schema_version,
163 manifest.conformance_level.unwrap_or(1),
164 layers_str,
165 manifest.description,
166 );
167 if let Some(ref a) = manifest.author {
168 out.push_str(&format!("Author: {a}\n"));
169 }
170 if let Some(ref s) = manifest.scope {
171 out.push_str(&format!("Scope: {s}\n"));
172 }
173 if !manifest.tags.is_empty() {
174 out.push_str(&format!("Tags: {}\n", manifest.tags.join(", ")));
175 }
176 out.push_str(&format!(
177 "Created: {}\nStats:\n Knowledge facts: {}\n Graph nodes: {}\n Graph edges: {}\n Patterns: {}\n Gotchas: {}\n Compression: {:.1}%\n Est. tokens: ~{}\nIntegrity:\n SHA256: {}\n Size: {} bytes\n",
178 manifest.created_at.format("%Y-%m-%d %H:%M UTC"),
179 manifest.stats.knowledge_facts,
180 manifest.stats.graph_nodes,
181 manifest.stats.graph_edges,
182 manifest.stats.pattern_count,
183 manifest.stats.gotcha_count,
184 manifest.stats.compression_ratio * 100.0,
185 content.estimated_token_count(),
186 manifest.integrity.sha256,
187 manifest.integrity.byte_size,
188 ));
189 out
190 }
191 Err(e) => format!("ERROR: {e}"),
192 }
193}
194
195pub fn handle_remove(name: &str, version: Option<&str>) -> String {
196 let registry = match lean_ctx::core::context_package::LocalRegistry::open() {
197 Ok(r) => r,
198 Err(e) => return format!("ERROR: {e}"),
199 };
200
201 match registry.remove(name, version) {
202 Ok(0) => format!("No matching package found: {name}"),
203 Ok(n) => format!("Removed {n} package(s)."),
204 Err(e) => format!("ERROR: {e}"),
205 }
206}
207
208pub fn handle_install(name: &str, version: Option<&str>, project_root: &str) -> String {
209 let registry = match lean_ctx::core::context_package::LocalRegistry::open() {
210 Ok(r) => r,
211 Err(e) => return format!("ERROR: {e}"),
212 };
213
214 let resolved_ver;
215 let ver = if let Some(v) = version {
216 v
217 } else {
218 resolved_ver = registry
219 .list()
220 .ok()
221 .and_then(|entries| {
222 entries
223 .iter()
224 .filter(|e| e.name == name)
225 .max_by(|a, b| a.installed_at.cmp(&b.installed_at))
226 .map(|e| e.version.clone())
227 })
228 .unwrap_or_default();
229 &resolved_ver
230 };
231
232 match registry.load_admitted(name, ver) {
233 Ok(package) => {
234 match lean_ctx::core::context_package::load_admitted_package(&package, project_root) {
235 Ok(report) => format!("{report}\nPackage applied successfully."),
236 Err(e) => format!("ERROR: load failed: {e}"),
237 }
238 }
239 Err(e) => format!("ERROR: {e}"),
240 }
241}
242
243pub fn handle_export(name: &str, version: Option<&str>, output: Option<&str>) -> String {
244 let registry = match lean_ctx::core::context_package::LocalRegistry::open() {
245 Ok(r) => r,
246 Err(e) => return format!("ERROR: {e}"),
247 };
248
249 let resolved_ver;
250 let ver = if let Some(v) = version {
251 v
252 } else {
253 resolved_ver = registry
254 .list()
255 .ok()
256 .and_then(|entries| {
257 entries
258 .iter()
259 .filter(|e| e.name == name)
260 .max_by(|a, b| a.installed_at.cmp(&b.installed_at))
261 .map(|e| e.version.clone())
262 })
263 .unwrap_or_default();
264 &resolved_ver
265 };
266
267 let out_path = output.map_or_else(
268 || lean_ctx::core::contracts::default_package_filename(name, ver),
269 ToString::to_string,
270 );
271
272 match registry.export_to_file(name, ver, &std::path::PathBuf::from(&out_path)) {
273 Ok(bytes) => format!("Exported: {out_path} ({bytes} bytes)"),
274 Err(e) => format!("ERROR: {e}"),
275 }
276}
277
278pub fn handle_import(file_path: &str, apply: bool, project_root: &str) -> String {
279 let registry = match lean_ctx::core::context_package::LocalRegistry::open() {
280 Ok(r) => r,
281 Err(e) => return format!("ERROR: {e}"),
282 };
283
284 let policy = if lean_ctx::core::config::Config::load().mcp_import_allow_unverified {
288 lean_ctx::core::context_package::registry::SignaturePolicy::AllowUnsigned
289 } else {
290 lean_ctx::core::context_package::registry::SignaturePolicy::RequireTrustedSigner
291 };
292 match registry.import_from_file_checked(std::path::Path::new(file_path), policy) {
293 Ok((manifest, coverage)) => {
294 let layers_str = manifest
295 .layers
296 .iter()
297 .map(lean_ctx::core::context_package::PackageLayer::as_str)
298 .collect::<Vec<_>>()
299 .join(", ");
300 let mut out = format!(
301 "Imported: {} v{}\n Signature: {}\n Layers: {}\n Size: {} bytes\n",
302 manifest.name,
303 manifest.version,
304 lean_ctx::core::context_package::registry::describe_package_signature(&manifest),
305 layers_str,
306 manifest.integrity.byte_size,
307 );
308 if let Some(gap) = lean_ctx::core::context_package::signing::signature_gap(coverage) {
310 out.push_str(&format!(" WARNING: package is {gap}\n"));
311 }
312 if apply {
313 match lean_ctx::core::context_package::LocalRegistry::open() {
314 Ok(reg) => match reg.load_admitted(&manifest.name, &manifest.version) {
315 Ok(package) => {
316 match lean_ctx::core::context_package::load_admitted_package(
317 &package,
318 project_root,
319 ) {
320 Ok(report) => {
321 out.push_str(&format!("{report}\nPackage applied."));
322 }
323 Err(e) => out.push_str(&format!("ERROR applying: {e}")),
324 }
325 }
326 Err(e) => out.push_str(&format!("ERROR loading: {e}")),
327 },
328 Err(e) => out.push_str(&format!("ERROR: {e}")),
329 }
330 }
331 out
332 }
333 Err(e) => format!("ERROR: import failed: {e}"),
334 }
335}
336
337pub fn handle_auto_load(
340 project_root: &str,
341 name: Option<&str>,
342 version: Option<&str>,
343 enable: bool,
344) -> String {
345 let registry = match lean_ctx::core::context_package::LocalRegistry::open() {
346 Ok(r) => r,
347 Err(e) => return format!("ERROR: {e}"),
348 };
349
350 let Some(name) = name else {
351 return match registry.auto_load_packages(project_root) {
352 Ok(entries) => {
353 if entries.is_empty() {
354 "No packages set for auto-load in this project.".to_string()
355 } else {
356 let mut out = "Auto-load packages (this project):\n".to_string();
357 for e in &entries {
358 out.push_str(&format!("- {} v{}\n", e.name, e.version));
359 }
360 out
361 }
362 }
363 Err(e) => format!("ERROR: {e}"),
364 };
365 };
366
367 let resolved_ver;
368 let ver = if let Some(v) = version {
369 v
370 } else {
371 resolved_ver = registry
372 .list()
373 .ok()
374 .and_then(|entries| {
375 entries
376 .iter()
377 .filter(|e| e.name == name)
378 .max_by(|a, b| a.installed_at.cmp(&b.installed_at))
379 .map(|e| e.version.clone())
380 })
381 .unwrap_or_default();
382 &resolved_ver
383 };
384
385 match registry.set_auto_load(name, ver, enable, project_root) {
386 Ok(()) => {
387 if enable {
388 format!("Auto-load enabled for {name}@{ver} in this project")
389 } else {
390 format!("Auto-load disabled for {name}@{ver} in this project")
391 }
392 }
393 Err(e) => format!("ERROR: {e}"),
394 }
395}
396
397pub fn handle_summary(project_root: &str) -> String {
398 let phash = lean_ctx::core::project_hash::hash_project_root(project_root);
399
400 let registry = match lean_ctx::core::context_package::LocalRegistry::open() {
401 Ok(r) => r,
402 Err(e) => return format!("ERROR: {e}"),
403 };
404
405 let entries = registry.list().unwrap_or_default();
406 let matching: Vec<_> = entries.iter().collect();
407
408 let mut out = format!("Project: {project_root}\nProject hash: {phash}\n");
409 out.push_str(&format!("Installed packages: {}\n", matching.len()));
410
411 if !matching.is_empty() {
412 out.push_str("\nPackages:\n");
413 for e in &matching {
414 out.push_str(&format!(
415 "- {} v{} [{}]{}\n",
416 e.name,
417 e.version,
418 e.layers.join(", "),
419 if e.auto_loads_into(project_root) {
420 " [auto-load]"
421 } else {
422 ""
423 }
424 ));
425 }
426 }
427
428 let auto_count = matching
429 .iter()
430 .filter(|e| e.auto_loads_into(project_root))
431 .count();
432 out.push_str(&format!("Auto-load: {auto_count} package(s)\n"));
433 out
434}
435
436fn handle_pr(
437 project_root: &str,
438 base: Option<&str>,
439 format: Option<&str>,
440 depth: Option<usize>,
441 diff: Option<&str>,
442) -> String {
443 let root = project_root.to_string();
444 let base = base.map_or_else(
445 || detect_default_base(&root).unwrap_or_else(|| "HEAD~1".to_string()),
446 ToString::to_string,
447 );
448 let impact_depth = depth.unwrap_or(DEFAULT_IMPACT_DEPTH).max(1);
449
450 let mut warnings: Vec<String> = Vec::new();
451 let mut changed = if let Some(d) = diff {
452 if d.len() > MAX_DIFF_BYTES {
453 warnings.push(format!(
454 "Diff input too large ({} bytes, limit {MAX_DIFF_BYTES}). Truncating at char boundary.",
455 d.len()
456 ));
457 let mut boundary = MAX_DIFF_BYTES;
458 while boundary > 0 && !d.is_char_boundary(boundary) {
459 boundary -= 1;
460 }
461 let truncated = &d[..boundary];
462 parse_changes_from_input(truncated)
463 } else {
464 parse_changes_from_input(d)
465 }
466 } else {
467 git_diff_name_status(&root, &base, &mut warnings)
468 };
469
470 if changed.len() > MAX_CHANGED_FILES_SHOWN {
471 warnings.push(format!(
472 "Too many changed files ({}). Truncating to {MAX_CHANGED_FILES_SHOWN}.",
473 changed.len()
474 ));
475 changed.truncate(MAX_CHANGED_FILES_SHOWN);
476 }
477
478 let related_tests = collect_related_tests(&changed, &root);
479 let impacts = collect_impacts(&changed, &root, impact_depth);
480 let context_artifacts = collect_relevant_artifacts(&changed, &root, &mut warnings);
481
482 let format = format.unwrap_or("markdown");
483 match format {
484 "json" => {
485 let mut json = PrPackJson {
486 kind: "leanctx.pr_pack",
487 project_root: root,
488 base,
489 impact_depth,
490 changed_files: changed,
491 related_tests,
492 impacts,
493 context_artifacts,
494 warnings,
495 tokens: 0,
496 };
497 match serde_json::to_string_pretty(&json) {
498 Ok(s) => {
499 json.tokens = count_tokens(&s) as u64;
500 serde_json::to_string_pretty(&json)
501 .unwrap_or_else(|e| format!("{{\"error\": \"serialization failed: {e}\"}}"))
502 }
503 Err(e) => format!("{{\"error\": \"serialization failed: {e}\"}}"),
504 }
505 }
506 _ => format_markdown(
507 project_root,
508 &base,
509 impact_depth,
510 &changed,
511 &related_tests,
512 &impacts,
513 &context_artifacts,
514 &warnings,
515 ),
516 }
517}
518
519fn format_markdown(
520 project_root: &str,
521 base: &str,
522 impact_depth: usize,
523 changed: &[ChangedFile],
524 related_tests: &[String],
525 impacts: &[ImpactEntry],
526 artifacts: &[ResolvedArtifact],
527 warnings: &[String],
528) -> String {
529 let mut out = String::new();
530 out.push_str("# PR Context Pack\n\n");
531 out.push_str(&format!("- Project root: `{project_root}`\n"));
532 out.push_str(&format!("- Base: `{base}`\n"));
533 out.push_str(&format!("- Impact depth: `{impact_depth}`\n\n"));
534
535 if !warnings.is_empty() {
536 out.push_str("## Warnings\n");
537 for w in warnings {
538 out.push_str(&format!("- {w}\n"));
539 }
540 out.push('\n');
541 }
542
543 out.push_str("## Changed files\n");
544 for c in changed {
545 match &c.old_path {
546 Some(old) => out.push_str(&format!("- `{}` ({}) ← `{old}`\n", c.path, c.status)),
547 None => out.push_str(&format!("- `{}` ({})\n", c.path, c.status)),
548 }
549 }
550 out.push('\n');
551
552 if !artifacts.is_empty() {
553 out.push_str("## Context artifacts\n");
554 for a in artifacts {
555 let kind = if a.is_dir { "dir" } else { "file" };
556 let exists = if a.exists { "exists" } else { "missing" };
557 out.push_str(&format!(
558 "- `{}` ({kind}, {exists}) — {}\n",
559 a.path, a.description
560 ));
561 }
562 out.push('\n');
563 }
564
565 if !related_tests.is_empty() {
566 out.push_str("## Related tests\n");
567 for t in related_tests {
568 out.push_str(&format!("- `{t}`\n"));
569 }
570 out.push('\n');
571 }
572
573 if !impacts.is_empty() {
574 out.push_str("## Impact (property graph)\n");
575 for imp in impacts {
576 out.push_str(&format!(
577 "- `{}`: {} affected files\n",
578 imp.file,
579 imp.affected_files.len()
580 ));
581 for f in imp.affected_files.iter().take(30) {
582 out.push_str(&format!(" - `{f}`\n"));
583 }
584 if imp.affected_files.len() > 30 {
585 out.push_str(" - ...\n");
586 }
587 }
588 out.push('\n');
589 }
590
591 let tokens = count_tokens(&out);
592 out.push_str(&format!("[ctx_pack pr: {tokens} tok]\n"));
593 out
594}
595
596fn collect_related_tests(changed: &[ChangedFile], project_root: &str) -> Vec<String> {
597 let mut all: BTreeSet<String> = BTreeSet::new();
598 for c in changed {
599 for t in find_related_tests(&c.path, project_root) {
600 all.insert(t);
601 }
602 }
603 all.into_iter().collect()
604}
605
606fn collect_impacts(changed: &[ChangedFile], project_root: &str, depth: usize) -> Vec<ImpactEntry> {
607 let mut out = Vec::new();
608 for c in changed {
609 if c.status == "D" {
610 continue;
611 }
612 let raw = crate::tools::ctx_impact::handle(
613 "analyze",
614 Some(&c.path),
615 project_root,
616 Some(depth),
617 None,
618 );
619 let affected = parse_ctx_impact_output(&raw);
620 out.push(ImpactEntry {
621 file: c.path.clone(),
622 affected_files: affected,
623 });
624 }
625 out
626}
627
628fn parse_ctx_impact_output(raw: &str) -> Vec<String> {
629 let mut out: Vec<String> = Vec::new();
630 for line in raw.lines() {
631 let l = line.trim_end();
632 if let Some(rest) = l.strip_prefix(" ") {
633 let item = rest.trim().to_string();
634 if item.starts_with("...") {
635 continue;
636 }
637 if !item.is_empty() {
638 out.push(item);
639 }
640 }
641 }
642 out.sort();
643 out.dedup();
644 out
645}
646
647fn collect_relevant_artifacts(
648 changed: &[ChangedFile],
649 project_root: &str,
650 warnings: &mut Vec<String>,
651) -> Vec<ResolvedArtifact> {
652 let root = Path::new(project_root);
653 let resolved = lean_ctx::core::artifacts::load_resolved(root);
654 warnings.extend(resolved.warnings);
655
656 let mut out: Vec<ResolvedArtifact> = Vec::new();
657 for a in resolved.artifacts {
658 if !a.exists {
659 continue;
660 }
661 if is_artifact_relevant(&a, changed) {
662 out.push(a);
663 }
664 }
665 out.sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.name.cmp(&b.name)));
666 out
667}
668
669fn is_artifact_relevant(a: &ResolvedArtifact, changed: &[ChangedFile]) -> bool {
670 if a.path.is_empty() {
671 return false;
672 }
673 if a.is_dir {
674 let prefix = if a.path.ends_with('/') {
675 a.path.clone()
676 } else {
677 format!("{}/", a.path)
678 };
679 return changed.iter().any(|c| c.path.starts_with(&prefix));
680 }
681 changed.iter().any(|c| c.path == a.path)
682}
683
684fn parse_changes_from_input(input: &str) -> Vec<ChangedFile> {
685 if input.contains("diff --git") || input.contains("\n+++ ") {
686 let paths = parse_unified_diff_paths(input);
687 let mut out = Vec::new();
688 for p in paths {
689 out.push(ChangedFile {
690 path: p,
691 status: "M".to_string(),
692 old_path: None,
693 });
694 }
695 return dedup_changes(out);
696 }
697
698 let mut out = Vec::new();
699 for line in input.lines() {
700 let trimmed = line.trim();
701 if trimmed.is_empty() {
702 continue;
703 }
704 let parts: Vec<&str> = trimmed.split_whitespace().collect();
705 if parts.len() >= 2 {
706 let status = parts[0].to_string();
707 if status.starts_with('R') && parts.len() >= 3 {
708 out.push(ChangedFile {
709 path: parts[2].to_string(),
710 status: "R".to_string(),
711 old_path: Some(parts[1].to_string()),
712 });
713 } else {
714 out.push(ChangedFile {
715 path: parts[1].to_string(),
716 status: status.chars().next().unwrap_or('M').to_string(),
717 old_path: None,
718 });
719 }
720 } else {
721 out.push(ChangedFile {
722 path: trimmed.to_string(),
723 status: "M".to_string(),
724 old_path: None,
725 });
726 }
727 }
728 dedup_changes(out)
729}
730
731fn parse_unified_diff_paths(diff: &str) -> Vec<String> {
732 let mut out: BTreeSet<String> = BTreeSet::new();
733 for line in diff.lines() {
734 if let Some(rest) = line.strip_prefix("+++ b/") {
735 let p = rest.trim();
736 if !p.is_empty() && p != "/dev/null" {
737 out.insert(p.to_string());
738 }
739 }
740 if let Some(rest) = line.strip_prefix("--- a/") {
741 let p = rest.trim();
742 if !p.is_empty() && p != "/dev/null" {
743 out.insert(p.to_string());
744 }
745 }
746 }
747 out.into_iter().collect()
748}
749
750fn git_diff_name_status(
751 project_root: &str,
752 base: &str,
753 warnings: &mut Vec<String>,
754) -> Vec<ChangedFile> {
755 let out = std::process::Command::new("git")
756 .args(["diff", "--name-status", &format!("{base}...HEAD")])
757 .current_dir(project_root)
758 .stdout(std::process::Stdio::piped())
759 .stderr(std::process::Stdio::piped())
760 .output();
761 let Ok(o) = out else {
762 warnings.push("Failed to execute git diff".to_string());
763 return Vec::new();
764 };
765 if !o.status.success() {
766 let stderr = String::from_utf8_lossy(&o.stderr);
767 warnings.push(format!("git diff failed: {}", stderr.trim()));
768 return Vec::new();
769 }
770 let s = String::from_utf8_lossy(&o.stdout);
771 parse_changes_from_input(&s)
772}
773
774fn detect_default_base(project_root: &str) -> Option<String> {
775 for cand in ["origin/main", "origin/master", "main", "master"] {
776 let ok = std::process::Command::new("git")
777 .args(["rev-parse", "--verify", cand])
778 .current_dir(project_root)
779 .stdout(std::process::Stdio::null())
780 .stderr(std::process::Stdio::null())
781 .status()
782 .is_ok_and(|s| s.success());
783 if ok {
784 return Some(cand.to_string());
785 }
786 }
787 None
788}
789
790fn dedup_changes(changes: Vec<ChangedFile>) -> Vec<ChangedFile> {
791 let mut seen: BTreeMap<String, usize> = BTreeMap::new();
792 let mut out: Vec<ChangedFile> = Vec::new();
793 for c in changes {
794 let key = c.path.clone();
795 if let Some(&i) = seen.get(&key) {
796 out[i] = c;
797 } else {
798 seen.insert(key, out.len());
799 out.push(c);
800 }
801 }
802 out
803}
804
805fn find_related_tests(file_path: &str, root: &str) -> Vec<String> {
807 let p = std::path::Path::new(file_path);
808 let Some(stem) = p.file_stem().and_then(|s| s.to_str()) else {
809 return vec![];
810 };
811
812 let ext = p.extension().and_then(|e| e.to_str()).unwrap_or("");
813
814 let patterns = vec![
815 format!("{stem}_test.{ext}"),
816 format!("{stem}.test.{ext}"),
817 format!("{stem}.spec.{ext}"),
818 format!("{stem}_spec.{ext}"),
819 format!("test_{stem}.{ext}"),
820 format!("{stem}_tests.{ext}"),
821 format!("{stem}.test.ts"),
822 format!("{stem}.test.tsx"),
823 format!("{stem}.spec.ts"),
824 format!("{stem}.spec.tsx"),
825 format!("{stem}_test.rs"),
826 format!("{stem}_test.py"),
827 format!("test_{stem}.py"),
828 format!("{stem}_test.go"),
829 ];
830
831 let root_path = std::path::Path::new(root);
832 let mut found = Vec::new();
833
834 fn walk_for_tests(
835 dir: &std::path::Path,
836 patterns: &[String],
837 root: &std::path::Path,
838 found: &mut Vec<String>,
839 max_depth: usize,
840 ) {
841 if max_depth == 0 {
842 return;
843 }
844 let Ok(entries) = std::fs::read_dir(dir) else {
845 return;
846 };
847 for entry in entries.flatten() {
848 let path = entry.path();
849 let name = entry.file_name().to_string_lossy().to_string();
850
851 if name.starts_with('.') || name == "node_modules" || name == "target" {
852 continue;
853 }
854
855 if path.is_dir() {
856 walk_for_tests(&path, patterns, root, found, max_depth - 1);
857 } else if patterns.contains(&name) {
858 let rel = path
859 .strip_prefix(root)
860 .unwrap_or(&path)
861 .to_string_lossy()
862 .to_string();
863 found.push(rel);
864 }
865 }
866 }
867
868 walk_for_tests(root_path, &patterns, root_path, &mut found, 8);
869 found.sort();
870 found.dedup();
871 found
872}
873
874#[cfg(test)]
875mod tests {
876 use super::*;
877
878 #[test]
879 fn find_related_tests_patterns() {
880 let dir = tempfile::tempdir().unwrap();
881 let src = dir.path().join("utils.ts");
882 std::fs::write(&src, "export function foo() {}").unwrap();
883 let test_file = dir.path().join("utils.test.ts");
884 std::fs::write(&test_file, "test('foo', () => {})").unwrap();
885 let spec_file = dir.path().join("utils.spec.ts");
886 std::fs::write(&spec_file, "describe('foo', () => {})").unwrap();
887
888 let found = find_related_tests("utils.ts", dir.path().to_str().unwrap());
889 assert!(found.iter().any(|f| f.contains("utils.test.ts")));
890 assert!(found.iter().any(|f| f.contains("utils.spec.ts")));
891 }
892}