Skip to main content

lean_ctx_server/tools/
ctx_pack.rs

1// SPDX-License-Identifier: Apache-2.0
2use 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; // 1 MiB
13
14#[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
56/// `create`, or with `preview` only what create would store (V01): the
57/// same build, nothing written.
58pub(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        // The caller's own session, never the project's latest (I07/S03).
67        .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    // GHSA-gvg8-285q-m62g: this is the MCP door, so the caller may be a
285    // prompt-injected model. Unsigned or untrusted-signer packages need the
286    // user's global opt-in; no tool argument can grant it.
287    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            // K02: local installs take unsigned and identity-only packages.
309            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
337/// Auto-load is bound to `project_root` (V03): enabling or disabling it, and
338/// the listing, concern this project only.
339pub 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
805/// Finds test files related to the given source file by naming conventions.
806fn 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}