Skip to main content

lean_ctx/tools/
ctx_pack.rs

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