Skip to main content

cargo_context_core/pack/
mod.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::path::{Path, PathBuf};
3
4use globset::{Glob, GlobSet, GlobSetBuilder};
5use serde::{Deserialize, Serialize};
6
7use crate::budget::{
8    self, Budget, BudgetDecision, BudgetStrategy, P_DIFF, P_ENTRY, P_ERROR, P_EXEMPT, P_MAP,
9    P_TESTS, Priority,
10};
11use crate::collect::{self, Diff};
12use crate::error::{Error, Result};
13use crate::expand::{self, ExpandMode};
14use crate::impact::Finding;
15use crate::options::PackOptions;
16use crate::scrub::{ScrubReport, Scrubber};
17use crate::tokenize::Tokenizer;
18
19pub mod impact;
20pub mod render;
21
22pub(crate) use render::lang_for_path;
23use render::{
24    mk_section, project_name, render_diagnostics, render_diff_ordered, render_entry, render_map,
25    render_tests,
26};
27
28#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "lowercase")]
30pub enum Preset {
31    Fix,
32    Feature,
33    #[default]
34    Custom,
35}
36
37impl Preset {
38    fn label(self) -> &'static str {
39        match self {
40            Self::Fix => "fix",
41            Self::Feature => "feature",
42            Self::Custom => "custom",
43        }
44    }
45}
46
47impl BudgetStrategy {
48    fn label(self) -> &'static str {
49        match self {
50            Self::Priority => "priority",
51            Self::Proportional => "proportional",
52            Self::Truncate => "truncate",
53        }
54    }
55}
56
57impl DiffSource {
58    fn label(&self) -> &str {
59        match self {
60            Self::WorkingTree => "working-tree",
61            Self::Range(range) => range.as_str(),
62        }
63    }
64}
65
66#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(rename_all = "lowercase")]
68pub enum Format {
69    #[default]
70    Markdown,
71    Xml,
72    Json,
73    Plain,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct Section {
78    pub name: String,
79    pub content: String,
80    pub token_estimate: usize,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct Pack {
85    pub schema: String,
86    pub project: String,
87    pub sections: Vec<Section>,
88    pub tokens_used: usize,
89    pub tokens_budget: usize,
90    pub tokenizer: String,
91    pub dropped: Vec<String>,
92    #[serde(default)]
93    pub manifest: PackManifest,
94    #[serde(default)]
95    pub scrub: crate::scrub::ScrubReport,
96}
97
98impl Pack {
99    pub fn render(&self, format: Format) -> Result<String> {
100        match format {
101            Format::Markdown => Ok(self.render_markdown()),
102            Format::Xml => Ok(self.render_xml()),
103            Format::Json => self.render_json(),
104            Format::Plain => Ok(self.render_plain()),
105        }
106    }
107
108    pub fn render_markdown(&self) -> String {
109        let mut out = String::new();
110        out.push_str(&format!("# PROJECT CONTEXT PACK: {}\n", self.project));
111        let dropped = if self.dropped.is_empty() {
112            String::new()
113        } else {
114            format!(" | dropped: {}", self.dropped.join(", "))
115        };
116        out.push_str(&format!(
117            "<!-- schema: {} | tokens: {}/{} | tokenizer: {}{} -->\n\n",
118            self.schema, self.tokens_used, self.tokens_budget, self.tokenizer, dropped
119        ));
120        out.push_str("## ๐Ÿงพ Context Manifest\n\n");
121        out.push_str(&self.manifest.render_markdown());
122        out.push_str("\n\n");
123        for s in &self.sections {
124            out.push_str(&format!("## {}\n\n{}\n\n", s.name, s.content));
125        }
126        out
127    }
128
129    pub fn render_xml(&self) -> String {
130        let mut out = String::new();
131        out.push_str(&format!(
132            "<pack schema=\"{}\" project=\"{}\" tokens=\"{}/{}\" tokenizer=\"{}\">\n",
133            self.schema, self.project, self.tokens_used, self.tokens_budget, self.tokenizer
134        ));
135        out.push_str("  <manifest>\n");
136        out.push_str(&self.manifest.render_plain_indented("    "));
137        out.push_str("  </manifest>\n");
138        for s in &self.sections {
139            out.push_str(&format!(
140                "  <section name=\"{}\" tokens=\"{}\">\n{}\n  </section>\n",
141                s.name, s.token_estimate, s.content
142            ));
143        }
144        out.push_str("</pack>\n");
145        out
146    }
147
148    pub fn render_plain(&self) -> String {
149        let mut parts = vec![format!(
150            "Context Manifest\n{}",
151            self.manifest.render_markdown()
152        )];
153        parts.extend(
154            self.sections
155                .iter()
156                .map(|s| s.content.as_str())
157                .map(str::to_string),
158        );
159        parts.join("\n\n")
160    }
161
162    pub fn render_json(&self) -> Result<String> {
163        Ok(serde_json::to_string_pretty(self)?)
164    }
165}
166
167#[derive(Debug, Default, Clone, Serialize, Deserialize)]
168pub struct PackManifest {
169    pub preset: Preset,
170    pub diff_source: DiffSource,
171    pub collectors: Vec<String>,
172    pub path_filters: PathFilterManifest,
173    pub files: Vec<FileProvenance>,
174    pub budget: BudgetManifest,
175    pub scrub: ScrubManifest,
176}
177
178impl PackManifest {
179    fn render_markdown(&self) -> String {
180        let mut out = String::new();
181        out.push_str(&format!("- Preset: `{}`\n", self.preset.label()));
182        out.push_str(&format!("- Diff source: `{}`\n", self.diff_source.label()));
183        if !self.collectors.is_empty() {
184            out.push_str(&format!(
185                "- Collectors: `{}`\n",
186                self.collectors.join("`, `")
187            ));
188        }
189        out.push_str(&format!(
190            "- Budget: `{}` strategy, {}/{} tokens",
191            self.budget.strategy.label(),
192            self.budget.tokens_used,
193            self.budget.tokens_budget
194        ));
195        if self.budget.manifest_tokens > 0 {
196            out.push_str(&format!(" (manifest: {})", self.budget.manifest_tokens));
197        }
198        let changed = self
199            .budget
200            .decisions
201            .iter()
202            .filter(|d| !matches!(d.outcome, budget::BudgetOutcome::Kept))
203            .count();
204        if changed > 0 {
205            out.push_str(&format!(", {changed} section decision(s)"));
206        }
207        out.push('\n');
208        out.push_str(&format!(
209            "- Scrub: {} redaction(s){}\n",
210            self.scrub.redactions,
211            if self.scrub.enabled {
212                ""
213            } else {
214                " (disabled)"
215            }
216        ));
217        if !self.path_filters.include.is_empty() {
218            out.push_str(&format!(
219                "- Include filters: `{}`\n",
220                self.path_filters.include.join("`, `")
221            ));
222        }
223        if !self.path_filters.exclude.is_empty() {
224            out.push_str(&format!(
225                "- Exclude filters: `{}`\n",
226                self.path_filters.exclude.join("`, `")
227            ));
228        }
229        let included = self
230            .files
231            .iter()
232            .filter(|f| matches!(f.status, FileProvenanceStatus::Included))
233            .count();
234        let suppressed = self
235            .files
236            .iter()
237            .filter(|f| matches!(f.status, FileProvenanceStatus::Suppressed))
238            .count();
239        if included > 0 || suppressed > 0 {
240            out.push_str(&format!(
241                "- Files: {included} included, {suppressed} suppressed\n"
242            ));
243        }
244        out
245    }
246
247    fn render_plain_indented(&self, indent: &str) -> String {
248        self.render_markdown()
249            .lines()
250            .map(|line| format!("{indent}{line}\n"))
251            .collect()
252    }
253
254    fn scrub_strings(&mut self, scrubber: &Scrubber) -> ScrubReport {
255        let mut report = ScrubReport::default();
256
257        if let DiffSource::Range(range) = &mut self.diff_source {
258            scrub_string(range, scrubber, &mut report);
259        }
260        scrub_strings(&mut self.collectors, scrubber, &mut report);
261        scrub_strings(&mut self.path_filters.include, scrubber, &mut report);
262        scrub_strings(&mut self.path_filters.exclude, scrubber, &mut report);
263        scrub_strings(
264            &mut self.path_filters.unmatched_include,
265            scrubber,
266            &mut report,
267        );
268        for file in &mut self.files {
269            scrub_string(&mut file.path, scrubber, &mut report);
270            scrub_strings(&mut file.sources, scrubber, &mut report);
271            if let Some(reason) = &mut file.reason {
272                scrub_string(reason, scrubber, &mut report);
273            }
274        }
275        for decision in &mut self.budget.decisions {
276            scrub_string(&mut decision.section, scrubber, &mut report);
277            if let Some(reason) = &mut decision.reason {
278                scrub_string(reason, scrubber, &mut report);
279            }
280        }
281        scrub_string(&mut self.scrub.summary, scrubber, &mut report);
282
283        report
284    }
285}
286
287fn scrub_strings(values: &mut [String], scrubber: &Scrubber, report: &mut ScrubReport) {
288    for value in values {
289        scrub_string(value, scrubber, report);
290    }
291}
292
293fn scrub_string(value: &mut String, scrubber: &Scrubber, report: &mut ScrubReport) {
294    let (scrubbed, string_report) = scrubber.scrub_with_report(value);
295    *value = scrubbed;
296    report.redactions.extend(string_report.redactions);
297}
298
299fn manifest_token_estimate(manifest: &PackManifest, tokenizer: &Tokenizer) -> usize {
300    let rendered_tokens = tokenizer.count(&manifest.render_markdown());
301    let json_tokens = serde_json::to_string(manifest)
302        .map(|json| tokenizer.count(&json))
303        .unwrap_or(rendered_tokens);
304    rendered_tokens.max(json_tokens)
305}
306
307#[derive(Debug, Default, Clone, Serialize, Deserialize)]
308pub struct PathFilterManifest {
309    pub include: Vec<String>,
310    pub exclude: Vec<String>,
311    pub unmatched_include: Vec<String>,
312}
313
314#[derive(Debug, Clone, Serialize, Deserialize)]
315pub struct FileProvenance {
316    pub path: String,
317    pub sources: Vec<String>,
318    pub status: FileProvenanceStatus,
319    pub reason: Option<String>,
320}
321
322#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
323#[serde(rename_all = "snake_case")]
324pub enum FileProvenanceStatus {
325    Included,
326    Suppressed,
327}
328
329#[derive(Debug, Default, Clone, Serialize, Deserialize)]
330pub struct BudgetManifest {
331    pub max_tokens: usize,
332    pub reserve_tokens: usize,
333    pub tokens_budget: usize,
334    pub tokens_used: usize,
335    #[serde(default)]
336    pub manifest_tokens: usize,
337    pub strategy: BudgetStrategy,
338    pub decisions: Vec<BudgetDecision>,
339}
340
341#[derive(Debug, Default, Clone, Serialize, Deserialize)]
342pub struct ScrubManifest {
343    pub enabled: bool,
344    pub redactions: usize,
345    pub summary: String,
346}
347
348#[derive(Debug, Default, Clone, Serialize, Deserialize)]
349#[serde(tag = "kind", content = "range", rename_all = "snake_case")]
350pub enum DiffSource {
351    #[default]
352    WorkingTree,
353    Range(String),
354}
355
356#[derive(Debug, Clone)]
357pub struct PackBuilder {
358    preset: Preset,
359    budget: Budget,
360    tokenizer: Tokenizer,
361    scrub: bool,
362    expand_mode: ExpandMode,
363    include_paths: Vec<String>,
364    exclude_paths: Vec<String>,
365    diff_range: Option<String>,
366    project_root: Option<std::path::PathBuf>,
367    stdin_prompt: Option<String>,
368    files_from: Vec<std::path::PathBuf>,
369    impact_findings: Vec<Finding>,
370    impact_per_finding: bool,
371}
372
373impl Default for PackBuilder {
374    fn default() -> Self {
375        Self {
376            preset: Preset::Custom,
377            budget: Budget::default(),
378            tokenizer: Tokenizer::Llama3,
379            scrub: true,
380            expand_mode: ExpandMode::default(),
381            include_paths: Vec::new(),
382            exclude_paths: Vec::new(),
383            diff_range: None,
384            project_root: None,
385            stdin_prompt: None,
386            files_from: Vec::new(),
387            impact_findings: Vec::new(),
388            impact_per_finding: false,
389        }
390    }
391}
392
393impl PackBuilder {
394    pub fn new() -> Self {
395        Self::default()
396    }
397
398    pub fn from_options(options: PackOptions) -> Self {
399        Self::new().options(options)
400    }
401
402    pub fn options(mut self, options: PackOptions) -> Self {
403        self.preset = options.preset;
404        self.budget = options.budget;
405        self.tokenizer = options.tokenizer;
406        self.scrub = options.scrub;
407        self.expand_mode = options.expand_mode;
408        self.include_paths = options.include_paths;
409        self.exclude_paths = options.exclude_paths;
410        self.diff_range = options.diff_range;
411        self.project_root = options.project_root;
412        self.stdin_prompt = options.stdin_prompt;
413        self.files_from = options.files_from;
414        self.impact_findings = options.impact_findings;
415        self.impact_per_finding = options.impact_per_finding;
416        self
417    }
418
419    pub fn preset(mut self, p: Preset) -> Self {
420        self.preset = p;
421        self
422    }
423    pub fn budget(mut self, b: Budget) -> Self {
424        self.budget = b;
425        self
426    }
427    pub fn max_tokens(mut self, n: usize) -> Self {
428        self.budget.max_tokens = n;
429        self
430    }
431    pub fn reserve_tokens(mut self, n: usize) -> Self {
432        self.budget.reserve_tokens = n;
433        self
434    }
435    pub fn tokenizer(mut self, t: Tokenizer) -> Self {
436        self.tokenizer = t;
437        self
438    }
439    pub fn scrub(mut self, on: bool) -> Self {
440        self.scrub = on;
441        self
442    }
443    pub fn expand_mode(mut self, m: ExpandMode) -> Self {
444        self.expand_mode = m;
445        self
446    }
447    pub fn include_path(mut self, path: impl Into<String>) -> Self {
448        self.include_paths.push(path.into());
449        self
450    }
451    pub fn exclude_path(mut self, path: impl Into<String>) -> Self {
452        self.exclude_paths.push(path.into());
453        self
454    }
455
456    /// Use an explicit Git diff range when collecting the Intent section.
457    ///
458    /// By default packs use the working tree and index diff against `HEAD`.
459    /// Passing a range such as `HEAD~3..HEAD` makes the diff collector use
460    /// that range instead; related-test discovery is seeded from the same
461    /// filtered diff.
462    pub fn diff_range(mut self, range: impl Into<String>) -> Self {
463        self.diff_range = Some(range.into());
464        self
465    }
466    pub fn project_root(mut self, p: impl Into<std::path::PathBuf>) -> Self {
467        self.project_root = Some(p.into());
468        self
469    }
470    pub fn stdin_prompt(mut self, prompt: impl Into<String>) -> Self {
471        self.stdin_prompt = Some(prompt.into());
472        self
473    }
474
475    pub fn files_from(mut self, paths: Vec<std::path::PathBuf>) -> Self {
476        self.files_from = paths;
477        self
478    }
479
480    pub fn impact_findings(mut self, findings: Vec<Finding>) -> Self {
481        self.impact_findings = findings;
482        self
483    }
484
485    pub fn impact_per_finding(mut self, on: bool) -> Self {
486        self.impact_per_finding = on;
487        self
488    }
489
490    pub fn build(self) -> Result<Pack> {
491        let root = self
492            .project_root
493            .clone()
494            .unwrap_or_else(|| std::path::PathBuf::from("."));
495
496        let scrubber = if self.scrub {
497            Scrubber::with_workspace(&root)?
498        } else {
499            Scrubber::empty()
500        };
501        let mut scrub_report = ScrubReport::default();
502
503        let mut candidates: Vec<(Priority, Section)> = Vec::new();
504
505        if let Some(prompt) = &self.stdin_prompt {
506            candidates.push((
507                P_EXEMPT,
508                mk_section("๐Ÿ“ User Prompt", prompt, &self.tokenizer),
509            ));
510        }
511
512        let wants = SectionWants::for_preset(self.preset);
513        let path_filters = UserPathFilters::new(&self.include_paths, &self.exclude_paths)?;
514        let mut filter_report = ScopeFilterReport::new(&path_filters);
515        let mut provenance = ProvenanceBuilder::new(&root);
516
517        let diagnostics = if wants.errors {
518            collect::last_error(&root).ok()
519        } else {
520            None
521        };
522        let error_files: Vec<std::path::PathBuf> = diagnostics
523            .as_ref()
524            .map(|d| d.referenced_files())
525            .unwrap_or_default();
526        for path in &error_files {
527            provenance.record(path, "compiler_error", &path_filters);
528        }
529        let visible_error_files: Vec<std::path::PathBuf> = error_files
530            .iter()
531            .filter(|p| path_filters.allows(p))
532            .cloned()
533            .collect();
534
535        if let Some(d) = diagnostics.as_ref()
536            && !d.is_empty()
537        {
538            let content = render_diagnostics(d);
539            candidates.push((
540                P_ERROR,
541                mk_section("๐Ÿšจ Current State (Errors)", &content, &self.tokenizer),
542            ));
543        }
544
545        let diff = if wants.diff || wants.tests {
546            collect::git_diff(&root, self.diff_range.as_deref())
547                .ok()
548                .map(|d| {
549                    for f in &d.files {
550                        provenance.record(&f.path, "diff", &path_filters);
551                    }
552                    filter_report.diff_files_excluded += d
553                        .files
554                        .iter()
555                        .filter(|f| !path_filters.allows(&f.path))
556                        .count();
557                    path_filters.filter_diff(d)
558                })
559        } else {
560            None
561        };
562
563        if wants.diff
564            && let Some(d) = diff.as_ref()
565            && !d.is_empty()
566        {
567            candidates.push((
568                P_DIFF,
569                mk_section(
570                    "โšก Intent (Git Diff)",
571                    &render_diff_ordered(d, &visible_error_files, &scrubber),
572                    &self.tokenizer,
573                ),
574            ));
575        }
576        if wants.map
577            && let Some(content) = try_collect_map(&root)
578        {
579            candidates.push((
580                P_MAP,
581                mk_section("๐Ÿ—บ๏ธ Project Map", &content, &self.tokenizer),
582            ));
583        }
584        if wants.entry
585            && let Some((content, paths)) = try_collect_entry(&root, &path_filters)
586        {
587            for path in paths {
588                provenance.record_included(&path, "entry_point");
589            }
590            candidates.push((
591                P_ENTRY,
592                mk_section("๐Ÿงญ Entry Points", &content, &self.tokenizer),
593            ));
594        }
595        filter_report.scoped_files_excluded += self
596            .files_from
597            .iter()
598            .filter(|p| !path_filters.allows(p))
599            .count();
600        for path in &self.files_from {
601            provenance.record(path, "files_from", &path_filters);
602        }
603        let files_from = path_filters.filter_paths(self.files_from);
604        filter_report.impact_findings_excluded += self
605            .impact_findings
606            .iter()
607            .filter(|f| !path_filters.allows(&f.primary_path))
608            .count();
609        for finding in &self.impact_findings {
610            let source = finding
611                .id
612                .as_ref()
613                .map(|id| format!("impact_scope:{id}"))
614                .unwrap_or_else(|| "impact_scope".to_string());
615            provenance.record(&finding.primary_path, &source, &path_filters);
616        }
617        let impact_findings = path_filters.filter_findings(self.impact_findings);
618        let force_include = path_filters.force_include_paths(&root);
619        filter_report.include_patterns_unmatched = force_include.unmatched_patterns;
620        filter_report.include_paths_excluded = force_include.excluded_matches;
621        let force_include_paths = force_include.paths;
622        for path in &force_include_paths {
623            provenance.record_included(path, "include_path");
624        }
625
626        if !impact_findings.is_empty() {
627            if self.impact_per_finding {
628                for (idx, f) in impact_findings.iter().enumerate() {
629                    if let Some((name, collected)) =
630                        impact::try_collect_per_finding(&root, f, idx, &scrubber)
631                    {
632                        scrub_report.redactions.extend(collected.report.redactions);
633                        candidates.push((
634                            P_DIFF,
635                            mk_section(&name, &collected.content, &self.tokenizer),
636                        ));
637                    }
638                }
639            } else if let Some(collected) =
640                impact::try_collect_scoped_findings(&root, &impact_findings, &scrubber)
641            {
642                scrub_report.redactions.extend(collected.report.redactions);
643                candidates.push((
644                    P_DIFF,
645                    mk_section("๐Ÿ“‚ Scoped Files", &collected.content, &self.tokenizer),
646                ));
647            }
648        } else if !files_from.is_empty()
649            && let Some(collected) = impact::try_collect_scoped(&root, &files_from, &scrubber)
650        {
651            scrub_report.redactions.extend(collected.report.redactions);
652            candidates.push((
653                P_DIFF,
654                mk_section("๐Ÿ“‚ Scoped Files", &collected.content, &self.tokenizer),
655            ));
656        }
657        if !force_include_paths.is_empty()
658            && let Some(collected) =
659                impact::try_collect_scoped(&root, &force_include_paths, &scrubber)
660        {
661            scrub_report.redactions.extend(collected.report.redactions);
662            candidates.push((
663                P_DIFF,
664                mk_section("๐Ÿ“Œ Included Paths", &collected.content, &self.tokenizer),
665            ));
666        }
667
668        if wants.tests {
669            let mut changed: Vec<std::path::PathBuf> = diff
670                .as_ref()
671                .map(|d| d.files.iter().map(|f| f.path.clone()).collect())
672                .unwrap_or_default();
673            changed.extend(files_from.iter().cloned());
674            changed.extend(impact_findings.iter().map(|f| f.primary_path.clone()));
675            changed.extend(force_include_paths.iter().cloned());
676            if !changed.is_empty()
677                && let Some((content, paths)) = try_collect_tests(&root, &changed)
678            {
679                for path in paths {
680                    provenance.record(&path, "related_test", &path_filters);
681                }
682                candidates.push((
683                    P_TESTS,
684                    mk_section("๐ŸŽฏ Related Tests", &content, &self.tokenizer),
685                ));
686            }
687        }
688        if self.expand_mode != ExpandMode::Off
689            && let Some((content, paths)) =
690                try_collect_expansion(&root, self.expand_mode, diff.as_ref(), &path_filters)
691        {
692            for path in paths {
693                provenance.record_included(&path, "macro_expansion");
694            }
695            candidates.push((
696                P_ENTRY,
697                mk_section("๐Ÿ” Expanded Macros", &content, &self.tokenizer),
698            ));
699        }
700        if let Some(content) = filter_report.render() {
701            candidates.push((
702                P_MAP,
703                mk_section("๐Ÿ”Ž Scope Filters", &content, &self.tokenizer),
704            ));
705        }
706
707        if self.scrub {
708            for (_, s) in candidates.iter_mut() {
709                let (scrubbed_name, name_report) = scrubber.scrub_with_report(&s.name);
710                s.name = scrubbed_name;
711                scrub_report.redactions.extend(name_report.redactions);
712
713                let (scrubbed_content, content_report) = scrubber.scrub_with_report(&s.content);
714                s.content = scrubbed_content;
715                s.token_estimate = self.tokenizer.count(&s.content);
716                scrub_report.redactions.extend(content_report.redactions);
717            }
718        }
719
720        let total_budget = self.budget.effective();
721        let diff_source = self
722            .diff_range
723            .clone()
724            .map(DiffSource::Range)
725            .unwrap_or(DiffSource::WorkingTree);
726        let collectors = wants.labels();
727        let path_filter_manifest = PathFilterManifest {
728            include: path_filters.include_paths.clone(),
729            exclude: path_filters.exclude_paths.clone(),
730            unmatched_include: filter_report.include_patterns_unmatched.clone(),
731        };
732        let files = provenance.finish();
733
734        let preliminary_manifest = PackManifest {
735            preset: self.preset,
736            diff_source: diff_source.clone(),
737            collectors: collectors.clone(),
738            path_filters: path_filter_manifest.clone(),
739            files: files.clone(),
740            budget: BudgetManifest {
741                max_tokens: self.budget.max_tokens,
742                reserve_tokens: self.budget.reserve_tokens,
743                tokens_budget: total_budget,
744                tokens_used: 0,
745                manifest_tokens: 0,
746                strategy: self.budget.strategy,
747                decisions: Vec::new(),
748            },
749            scrub: ScrubManifest {
750                enabled: self.scrub,
751                redactions: scrub_report.redactions.len(),
752                summary: scrub_report.summary(),
753            },
754        };
755        let manifest_reserve = manifest_token_estimate(&preliminary_manifest, &self.tokenizer);
756        let section_budget = Budget {
757            max_tokens: total_budget.saturating_sub(manifest_reserve),
758            reserve_tokens: 0,
759            strategy: self.budget.strategy,
760        };
761
762        let alloc = budget::allocate(candidates, &section_budget, &self.tokenizer);
763
764        let mut project = project_name(self.project_root.as_deref());
765        if self.scrub {
766            let (scrubbed_project, project_report) = scrubber.scrub_with_report(&project);
767            project = scrubbed_project;
768            scrub_report.redactions.extend(project_report.redactions);
769        }
770
771        let mut manifest = PackManifest {
772            preset: self.preset,
773            diff_source,
774            collectors,
775            path_filters: path_filter_manifest,
776            files,
777            budget: BudgetManifest {
778                max_tokens: self.budget.max_tokens,
779                reserve_tokens: self.budget.reserve_tokens,
780                tokens_budget: total_budget,
781                tokens_used: alloc.tokens_used,
782                manifest_tokens: manifest_reserve,
783                strategy: self.budget.strategy,
784                decisions: alloc.decisions.clone(),
785            },
786            scrub: ScrubManifest {
787                enabled: self.scrub,
788                redactions: scrub_report.redactions.len(),
789                summary: scrub_report.summary(),
790            },
791        };
792        if self.scrub {
793            let manifest_report = manifest.scrub_strings(&scrubber);
794            scrub_report.redactions.extend(manifest_report.redactions);
795        }
796        let manifest_tokens = manifest_token_estimate(&manifest, &self.tokenizer);
797        let tokens_used = alloc.tokens_used + manifest_tokens;
798        manifest.budget.tokens_used = tokens_used;
799        manifest.budget.manifest_tokens = manifest_tokens;
800        manifest.scrub.redactions = scrub_report.redactions.len();
801        manifest.scrub.summary = scrub_report.summary();
802
803        if self.scrub {
804            scrubber.log_redactions(&scrub_report)?;
805        }
806
807        Ok(Pack {
808            schema: "cargo-context/v1".into(),
809            project,
810            sections: alloc.kept,
811            tokens_used,
812            tokens_budget: total_budget,
813            tokenizer: self.tokenizer.label().into(),
814            dropped: alloc.dropped,
815            manifest,
816            scrub: scrub_report,
817        })
818    }
819}
820
821#[derive(Debug, Clone, Copy)]
822struct SectionWants {
823    map: bool,
824    errors: bool,
825    diff: bool,
826    entry: bool,
827    tests: bool,
828}
829
830impl SectionWants {
831    fn for_preset(p: Preset) -> Self {
832        match p {
833            Preset::Fix => Self {
834                map: false,
835                errors: true,
836                diff: true,
837                entry: false,
838                tests: true,
839            },
840            Preset::Feature => Self {
841                map: true,
842                errors: false,
843                diff: true,
844                entry: true,
845                tests: true,
846            },
847            Preset::Custom => Self {
848                map: true,
849                errors: false,
850                diff: true,
851                entry: true,
852                tests: true,
853            },
854        }
855    }
856
857    fn labels(self) -> Vec<String> {
858        let mut out = Vec::new();
859        if self.errors {
860            out.push("errors".into());
861        }
862        if self.diff {
863            out.push("diff".into());
864        }
865        if self.map {
866            out.push("map".into());
867        }
868        if self.entry {
869            out.push("entry".into());
870        }
871        if self.tests {
872            out.push("tests".into());
873        }
874        out
875    }
876}
877
878fn try_collect_map(root: &Path) -> Option<String> {
879    collect::cargo_metadata(root).ok().map(render_map)
880}
881
882fn try_collect_expansion(
883    root: &Path,
884    mode: ExpandMode,
885    diff: Option<&Diff>,
886    path_filters: &UserPathFilters,
887) -> Option<(String, Vec<PathBuf>)> {
888    if matches!(mode, ExpandMode::Off) {
889        return None;
890    }
891    if !expand::expand_available() {
892        return None;
893    }
894    let meta = collect::cargo_metadata(root).ok()?;
895
896    if matches!(mode, ExpandMode::Auto) {
897        let has_rust = diff
898            .map(|d| {
899                d.files
900                    .iter()
901                    .any(|f| f.path.extension().and_then(|e| e.to_str()) == Some("rs"))
902            })
903            .unwrap_or(false);
904        if !has_rust {
905            return None;
906        }
907    }
908
909    let mut out = String::new();
910    let mut expanded_any = false;
911    let mut paths = Vec::new();
912    for member in &meta.members {
913        let dir = match member.manifest_path.parent() {
914            Some(d) => d,
915            None => continue,
916        };
917        let lib = dir.join("src/lib.rs");
918        let main = dir.join("src/main.rs");
919        let target = if lib.exists() {
920            lib
921        } else if main.exists() {
922            main
923        } else {
924            continue;
925        };
926        if !path_filters.allows(&target) {
927            continue;
928        }
929        match expand::expand_file(&meta.workspace_root, &member.name, &target) {
930            Ok(Some(text)) => {
931                paths.push(target.clone());
932                out.push_str(&format!(
933                    "### `{}` โ€” {} (expanded)\n```rust\n{}\n```\n\n",
934                    target.display(),
935                    member.name,
936                    text.trim_end()
937                ));
938                expanded_any = true;
939            }
940            Ok(None) | Err(_) => continue,
941        }
942    }
943    if expanded_any {
944        Some((out, paths))
945    } else {
946        None
947    }
948}
949
950fn try_collect_tests(
951    root: &Path,
952    changed: &[std::path::PathBuf],
953) -> Option<(String, Vec<PathBuf>)> {
954    let rt = collect::related_tests(root, changed).ok()?;
955    if rt.is_empty() {
956        None
957    } else {
958        let paths = rt.files.iter().map(|f| f.path.clone()).collect();
959        Some((render_tests(&rt), paths))
960    }
961}
962
963fn try_collect_entry(
964    root: &Path,
965    path_filters: &UserPathFilters,
966) -> Option<(String, Vec<PathBuf>)> {
967    let mut ep = collect::entry_points(root).ok()?;
968    ep.files.retain(|f| path_filters.allows(&f.path));
969    if ep.is_empty() {
970        None
971    } else {
972        let paths = ep.files.iter().map(|f| f.path.clone()).collect();
973        Some((render_entry(&ep), paths))
974    }
975}
976
977#[derive(Debug)]
978struct ProvenanceBuilder {
979    root: PathBuf,
980    files: BTreeMap<String, FileTrace>,
981}
982
983#[derive(Debug, Default)]
984struct FileTrace {
985    sources: BTreeSet<String>,
986    status: Option<FileProvenanceStatus>,
987    reason: Option<String>,
988}
989
990impl ProvenanceBuilder {
991    fn new(root: &Path) -> Self {
992        Self {
993            root: root.to_path_buf(),
994            files: BTreeMap::new(),
995        }
996    }
997
998    fn record(&mut self, path: &Path, source: &str, filters: &UserPathFilters) {
999        let path_key = self.path_key(path);
1000        let allowed = filters.allows(path);
1001        let trace = self.files.entry(path_key).or_default();
1002        trace.sources.insert(source.to_string());
1003        if allowed {
1004            trace.status = Some(FileProvenanceStatus::Included);
1005        } else {
1006            trace.status = Some(FileProvenanceStatus::Suppressed);
1007            trace.reason = Some("--exclude-path".into());
1008        }
1009    }
1010
1011    fn record_included(&mut self, path: &Path, source: &str) {
1012        let path_key = self.path_key(path);
1013        let trace = self.files.entry(path_key).or_default();
1014        trace.sources.insert(source.to_string());
1015        trace.status = Some(FileProvenanceStatus::Included);
1016    }
1017
1018    fn path_key(&self, path: &Path) -> String {
1019        path.strip_prefix(&self.root)
1020            .unwrap_or(path)
1021            .to_string_lossy()
1022            .replace('\\', "/")
1023    }
1024
1025    fn finish(self) -> Vec<FileProvenance> {
1026        self.files
1027            .into_iter()
1028            .map(|(path, trace)| FileProvenance {
1029                path,
1030                sources: trace.sources.into_iter().collect(),
1031                status: trace.status.unwrap_or(FileProvenanceStatus::Included),
1032                reason: trace.reason,
1033            })
1034            .collect()
1035    }
1036}
1037
1038#[derive(Debug, Default)]
1039struct UserPathFilters {
1040    include_paths: Vec<String>,
1041    exclude_paths: Vec<String>,
1042    exclude: Option<GlobSet>,
1043}
1044
1045impl UserPathFilters {
1046    fn new(include_paths: &[String], exclude_paths: &[String]) -> Result<Self> {
1047        Ok(Self {
1048            include_paths: include_paths.to_vec(),
1049            exclude_paths: exclude_paths.to_vec(),
1050            exclude: build_globset(exclude_paths)?,
1051        })
1052    }
1053
1054    fn allows(&self, path: &Path) -> bool {
1055        !self.matches_exclude(path)
1056    }
1057
1058    fn matches_exclude(&self, path: &Path) -> bool {
1059        self.exclude
1060            .as_ref()
1061            .map(|gs| gs.is_match(path))
1062            .unwrap_or(false)
1063    }
1064
1065    fn filter_paths(&self, paths: Vec<PathBuf>) -> Vec<PathBuf> {
1066        paths.into_iter().filter(|p| self.allows(p)).collect()
1067    }
1068
1069    fn filter_findings(&self, findings: Vec<Finding>) -> Vec<Finding> {
1070        findings
1071            .into_iter()
1072            .filter(|f| self.allows(&f.primary_path))
1073            .collect()
1074    }
1075
1076    fn filter_diff(&self, mut diff: Diff) -> Diff {
1077        diff.files.retain(|f| self.allows(&f.path));
1078        diff
1079    }
1080
1081    fn force_include_paths(&self, root: &Path) -> ForceIncludeResult {
1082        let mut out = Vec::new();
1083        let mut seen = std::collections::HashSet::new();
1084        let mut unmatched = Vec::new();
1085        let mut excluded_matches = 0_usize;
1086        for pattern in &self.include_paths {
1087            if is_glob_pattern(pattern) {
1088                let Ok(glob) = Glob::new(pattern) else {
1089                    continue;
1090                };
1091                let mut builder = GlobSetBuilder::new();
1092                builder.add(glob);
1093                let Ok(set) = builder.build() else {
1094                    continue;
1095                };
1096                let before = out.len();
1097                excluded_matches +=
1098                    self.collect_matching_files(root, root, &set, &mut seen, &mut out);
1099                if out.len() == before {
1100                    unmatched.push(pattern.clone());
1101                }
1102            } else {
1103                let path = PathBuf::from(pattern);
1104                if self.allows(&path) {
1105                    if seen.insert(path.clone()) {
1106                        out.push(path);
1107                    }
1108                } else {
1109                    excluded_matches += 1;
1110                }
1111            }
1112        }
1113        ForceIncludeResult {
1114            paths: out,
1115            unmatched_patterns: unmatched,
1116            excluded_matches,
1117        }
1118    }
1119
1120    fn collect_matching_files(
1121        &self,
1122        root: &Path,
1123        dir: &Path,
1124        glob: &GlobSet,
1125        seen: &mut std::collections::HashSet<PathBuf>,
1126        out: &mut Vec<PathBuf>,
1127    ) -> usize {
1128        let Ok(entries) = std::fs::read_dir(dir) else {
1129            return 0;
1130        };
1131        let mut excluded = 0_usize;
1132        for entry in entries.flatten() {
1133            let path = entry.path();
1134            let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
1135                continue;
1136            };
1137            if name == ".git" || name == "target" {
1138                continue;
1139            }
1140            if path.is_dir() {
1141                excluded += self.collect_matching_files(root, &path, glob, seen, out);
1142                continue;
1143            }
1144            if !path.is_file() {
1145                continue;
1146            }
1147            let rel = path.strip_prefix(root).unwrap_or(&path).to_path_buf();
1148            if glob.is_match(&rel) {
1149                if self.allows(&rel) {
1150                    if seen.insert(rel.clone()) {
1151                        out.push(rel);
1152                    }
1153                } else {
1154                    excluded += 1;
1155                }
1156            }
1157        }
1158        excluded
1159    }
1160}
1161
1162#[derive(Debug, Default)]
1163struct ForceIncludeResult {
1164    paths: Vec<PathBuf>,
1165    unmatched_patterns: Vec<String>,
1166    excluded_matches: usize,
1167}
1168
1169#[derive(Debug, Default)]
1170struct ScopeFilterReport {
1171    include_patterns: Vec<String>,
1172    exclude_patterns: Vec<String>,
1173    diff_files_excluded: usize,
1174    scoped_files_excluded: usize,
1175    impact_findings_excluded: usize,
1176    include_paths_excluded: usize,
1177    include_patterns_unmatched: Vec<String>,
1178}
1179
1180impl ScopeFilterReport {
1181    fn new(filters: &UserPathFilters) -> Self {
1182        Self {
1183            include_patterns: filters.include_paths.clone(),
1184            exclude_patterns: filters.exclude_paths.clone(),
1185            ..Self::default()
1186        }
1187    }
1188
1189    fn render(&self) -> Option<String> {
1190        if self.include_patterns.is_empty() && self.exclude_patterns.is_empty() {
1191            return None;
1192        }
1193        let mut out = String::new();
1194        if !self.include_patterns.is_empty() {
1195            out.push_str(&format!(
1196                "- Include patterns: `{}`\n",
1197                self.include_patterns.join("`, `")
1198            ));
1199        }
1200        if !self.exclude_patterns.is_empty() {
1201            out.push_str(&format!(
1202                "- Exclude patterns: `{}`\n",
1203                self.exclude_patterns.join("`, `")
1204            ));
1205        }
1206
1207        let total_excluded = self.diff_files_excluded
1208            + self.scoped_files_excluded
1209            + self.impact_findings_excluded
1210            + self.include_paths_excluded;
1211        if total_excluded > 0 {
1212            out.push_str(&format!(
1213                "- Excluded by `--exclude-path`: {total_excluded} candidate(s)"
1214            ));
1215            let details = [
1216                ("diff", self.diff_files_excluded),
1217                ("scoped", self.scoped_files_excluded),
1218                ("impact", self.impact_findings_excluded),
1219                ("include", self.include_paths_excluded),
1220            ]
1221            .into_iter()
1222            .filter(|(_, count)| *count > 0)
1223            .map(|(label, count)| format!("{label}:{count}"))
1224            .collect::<Vec<_>>();
1225            if !details.is_empty() {
1226                out.push_str(&format!(" ({})", details.join(", ")));
1227            }
1228            out.push('\n');
1229        }
1230        if !self.include_patterns_unmatched.is_empty() {
1231            out.push_str(&format!(
1232                "- Include patterns with no included files: `{}`\n",
1233                self.include_patterns_unmatched.join("`, `")
1234            ));
1235        }
1236
1237        Some(out)
1238    }
1239}
1240
1241fn is_glob_pattern(pattern: &str) -> bool {
1242    pattern.contains('*') || pattern.contains('?') || pattern.contains('[') || pattern.contains('{')
1243}
1244
1245fn build_globset(patterns: &[String]) -> Result<Option<GlobSet>> {
1246    if patterns.is_empty() {
1247        return Ok(None);
1248    }
1249    let mut builder = GlobSetBuilder::new();
1250    for pattern in patterns {
1251        let glob = Glob::new(pattern)
1252            .map_err(|e| Error::Glob(format!("invalid glob `{pattern}`: {e}")))?;
1253        builder.add(glob);
1254    }
1255    builder
1256        .build()
1257        .map(Some)
1258        .map_err(|e| Error::Glob(format!("globset: {e}")))
1259}
1260
1261#[cfg(test)]
1262mod tests {
1263    use super::impact::*;
1264    use super::render::lang_for_path;
1265    use super::*;
1266
1267    #[test]
1268    fn builder_includes_prompt_section() {
1269        let pack = PackBuilder::new()
1270            .preset(Preset::Fix)
1271            .max_tokens(4000)
1272            .stdin_prompt("why does this fail?")
1273            .project_root(std::env::temp_dir())
1274            .build()
1275            .expect("build pack");
1276        assert_eq!(pack.schema, "cargo-context/v1");
1277        assert!(pack.sections.iter().any(|s| s.name.contains("Prompt")));
1278    }
1279
1280    #[test]
1281    fn builder_empty_workspace_is_valid() {
1282        let pack = PackBuilder::new()
1283            .preset(Preset::Fix)
1284            .project_root(std::env::temp_dir())
1285            .build()
1286            .expect("build pack");
1287        assert_eq!(pack.schema, "cargo-context/v1");
1288    }
1289
1290    #[test]
1291    fn user_path_filters_exclude_diff_files() {
1292        let filters = UserPathFilters::new(&[], &["**/secret.rs".to_string()]).unwrap();
1293        let diff = Diff {
1294            range: None,
1295            files: vec![
1296                crate::collect::FileDiff {
1297                    path: std::path::PathBuf::from("src/lib.rs"),
1298                    old_path: None,
1299                    status: crate::collect::FileStatus::Modified,
1300                    hunks: Vec::new(),
1301                    binary: false,
1302                },
1303                crate::collect::FileDiff {
1304                    path: std::path::PathBuf::from("src/secret.rs"),
1305                    old_path: None,
1306                    status: crate::collect::FileStatus::Modified,
1307                    hunks: Vec::new(),
1308                    binary: false,
1309                },
1310            ],
1311        };
1312
1313        let filtered = filters.filter_diff(diff);
1314
1315        assert_eq!(filtered.files.len(), 1);
1316        assert_eq!(
1317            filtered.files[0].path,
1318            std::path::PathBuf::from("src/lib.rs")
1319        );
1320    }
1321
1322    #[test]
1323    fn include_paths_force_scope_unless_excluded() {
1324        let filters = UserPathFilters::new(
1325            &["src/lib.rs".to_string(), "src/secret.rs".to_string()],
1326            &["**/secret.rs".to_string()],
1327        )
1328        .unwrap();
1329
1330        assert_eq!(
1331            filters.force_include_paths(Path::new(".")).paths,
1332            vec![std::path::PathBuf::from("src/lib.rs")]
1333        );
1334    }
1335
1336    #[test]
1337    fn include_paths_expand_globs_from_root() {
1338        let tmp = tempfile::tempdir().unwrap();
1339        std::fs::create_dir_all(tmp.path().join("src")).unwrap();
1340        std::fs::write(tmp.path().join("src/lib.rs"), "").unwrap();
1341        std::fs::write(tmp.path().join("src/secret.rs"), "").unwrap();
1342        let filters =
1343            UserPathFilters::new(&["src/*.rs".to_string()], &["**/secret.rs".to_string()]).unwrap();
1344
1345        assert_eq!(
1346            filters.force_include_paths(tmp.path()).paths,
1347            vec![std::path::PathBuf::from("src/lib.rs")]
1348        );
1349    }
1350
1351    #[test]
1352    fn scope_filter_report_surfaces_excluded_candidates() {
1353        let filters =
1354            UserPathFilters::new(&["src/*.rs".to_string()], &["**/secret.rs".to_string()]).unwrap();
1355        let mut report = ScopeFilterReport::new(&filters);
1356        report.diff_files_excluded = 1;
1357        report.include_paths_excluded = 1;
1358        report.include_patterns_unmatched = vec!["missing/*.rs".to_string()];
1359
1360        let rendered = report.render().unwrap();
1361
1362        assert!(rendered.contains("Exclude patterns"));
1363        assert!(rendered.contains("Excluded by `--exclude-path`: 2 candidate(s)"));
1364        assert!(rendered.contains("diff:1"));
1365        assert!(rendered.contains("include:1"));
1366        assert!(rendered.contains("missing/*.rs"));
1367    }
1368
1369    #[test]
1370    fn json_roundtrip() {
1371        let pack = PackBuilder::new()
1372            .project_root(std::env::temp_dir())
1373            .build()
1374            .unwrap();
1375        let s = pack.render_json().unwrap();
1376        let _: Pack = serde_json::from_str(&s).unwrap();
1377    }
1378
1379    #[test]
1380    fn manifest_records_diff_range_filters_and_budget_decisions() {
1381        let tmp = tempfile::tempdir().unwrap();
1382        let pack = PackBuilder::new()
1383            .preset(Preset::Fix)
1384            .diff_range("HEAD~1..HEAD")
1385            .include_path("src/lib.rs")
1386            .exclude_path("src/secret.rs")
1387            .budget(Budget {
1388                max_tokens: 10,
1389                reserve_tokens: 0,
1390                strategy: BudgetStrategy::Priority,
1391            })
1392            .project_root(tmp.path())
1393            .build()
1394            .unwrap();
1395
1396        assert_eq!(pack.manifest.preset, Preset::Fix);
1397        assert!(matches!(
1398            pack.manifest.diff_source,
1399            DiffSource::Range(ref range) if range == "HEAD~1..HEAD"
1400        ));
1401        assert_eq!(pack.manifest.path_filters.include, vec!["src/lib.rs"]);
1402        assert_eq!(pack.manifest.path_filters.exclude, vec!["src/secret.rs"]);
1403        assert_eq!(pack.manifest.budget.strategy, BudgetStrategy::Priority);
1404        assert!(
1405            pack.render_markdown().contains("Context Manifest"),
1406            "markdown should expose the human-readable manifest"
1407        );
1408    }
1409
1410    #[test]
1411    fn manifest_strings_are_scrubbed_before_output() {
1412        let tmp = tempfile::tempdir().unwrap();
1413        std::fs::create_dir_all(tmp.path().join(".cargo-context")).unwrap();
1414        std::fs::write(
1415            tmp.path().join(".cargo-context/scrub.yaml"),
1416            r#"
1417version: 1
1418patterns:
1419  - id: secret_path
1420    regex: 'secret-project'
1421    category: path
1422    severity: high
1423"#,
1424        )
1425        .unwrap();
1426
1427        let pack = PackBuilder::new()
1428            .include_path("src/secret-project.rs")
1429            .diff_range("secret-project..HEAD")
1430            .project_root(tmp.path())
1431            .build()
1432            .unwrap();
1433        let json = pack.render_json().unwrap();
1434
1435        assert!(!json.contains("secret-project"));
1436        assert!(json.contains("<REDACTED:path:"));
1437        assert!(!pack.scrub.is_empty());
1438        assert_eq!(pack.manifest.scrub.redactions, pack.scrub.redactions.len());
1439    }
1440
1441    #[test]
1442    fn scoped_file_path_redactions_are_reported() {
1443        let tmp = tempfile::tempdir().unwrap();
1444        std::fs::create_dir_all(tmp.path().join(".cargo-context")).unwrap();
1445        std::fs::create_dir_all(tmp.path().join("src")).unwrap();
1446        std::fs::write(tmp.path().join("src/secret.rs"), "DB_PASSWORD=hunter2\n").unwrap();
1447        std::fs::write(
1448            tmp.path().join(".cargo-context/scrub.yaml"),
1449            r#"
1450version: 1
1451paths:
1452  redact_whole:
1453    - "src/secret.rs"
1454"#,
1455        )
1456        .unwrap();
1457
1458        let pack = PackBuilder::new()
1459            .files_from(vec![PathBuf::from("src/secret.rs")])
1460            .project_root(tmp.path())
1461            .build()
1462            .unwrap();
1463        let out = pack.render_markdown();
1464
1465        assert!(out.contains("[REDACTED FILE: src/secret.rs]"));
1466        assert!(!out.contains("hunter2"));
1467        assert!(
1468            pack.scrub
1469                .redactions
1470                .iter()
1471                .any(|r| r.rule_id == "path_redact_whole")
1472        );
1473        assert_eq!(pack.manifest.scrub.redactions, pack.scrub.redactions.len());
1474    }
1475
1476    #[test]
1477    fn manifest_tokens_are_counted_in_pack_budget() {
1478        let tmp = tempfile::tempdir().unwrap();
1479        let pack = PackBuilder::new().project_root(tmp.path()).build().unwrap();
1480
1481        assert!(pack.manifest.budget.manifest_tokens > 0);
1482        assert_eq!(pack.tokens_budget, pack.manifest.budget.tokens_budget);
1483        assert_eq!(pack.tokens_used, pack.manifest.budget.tokens_used);
1484        assert!(pack.tokens_used >= pack.manifest.budget.manifest_tokens);
1485    }
1486
1487    #[test]
1488    fn provenance_records_suppressed_and_multi_source_files() {
1489        let tmp = tempfile::tempdir().unwrap();
1490        std::fs::create_dir_all(tmp.path().join("src")).unwrap();
1491        std::fs::write(tmp.path().join("src/lib.rs"), "pub fn lib() {}\n").unwrap();
1492        std::fs::write(tmp.path().join("src/secret.rs"), "pub fn secret() {}\n").unwrap();
1493
1494        let findings = vec![Finding {
1495            id: Some("f-lib".into()),
1496            primary_path: PathBuf::from("src/lib.rs"),
1497            kind: None,
1498            confidence: Some(0.9),
1499            severity: None,
1500            tier: None,
1501            evidence: None,
1502            suggested_action: None,
1503        }];
1504
1505        let pack = PackBuilder::new()
1506            .files_from(vec![
1507                PathBuf::from("src/lib.rs"),
1508                PathBuf::from("src/secret.rs"),
1509            ])
1510            .impact_findings(findings)
1511            .exclude_path("src/secret.rs")
1512            .project_root(tmp.path())
1513            .build()
1514            .unwrap();
1515
1516        let lib = pack
1517            .manifest
1518            .files
1519            .iter()
1520            .find(|f| f.path == "src/lib.rs")
1521            .expect("lib provenance");
1522        assert_eq!(lib.status, FileProvenanceStatus::Included);
1523        assert!(lib.sources.contains(&"files_from".to_string()));
1524        assert!(lib.sources.contains(&"impact_scope:f-lib".to_string()));
1525
1526        let secret = pack
1527            .manifest
1528            .files
1529            .iter()
1530            .find(|f| f.path == "src/secret.rs")
1531            .expect("secret provenance");
1532        assert_eq!(secret.status, FileProvenanceStatus::Suppressed);
1533        assert_eq!(secret.reason.as_deref(), Some("--exclude-path"));
1534    }
1535
1536    #[test]
1537    fn render_diff_puts_error_files_first() {
1538        use crate::collect::{Diff, FileDiff, FileStatus};
1539        let d = Diff {
1540            range: None,
1541            files: vec![
1542                FileDiff {
1543                    path: std::path::PathBuf::from("src/unrelated.rs"),
1544                    old_path: None,
1545                    status: FileStatus::Modified,
1546                    hunks: Vec::new(),
1547                    binary: false,
1548                },
1549                FileDiff {
1550                    path: std::path::PathBuf::from("src/broken.rs"),
1551                    old_path: None,
1552                    status: FileStatus::Modified,
1553                    hunks: Vec::new(),
1554                    binary: false,
1555                },
1556                FileDiff {
1557                    path: std::path::PathBuf::from("src/also_clean.rs"),
1558                    old_path: None,
1559                    status: FileStatus::Modified,
1560                    hunks: Vec::new(),
1561                    binary: false,
1562                },
1563            ],
1564        };
1565        let errors = vec![std::path::PathBuf::from("src/broken.rs")];
1566        let scrubber = Scrubber::empty();
1567        let rendered = render_diff_ordered(&d, &errors, &scrubber);
1568        let broken_pos = rendered.find("broken.rs").unwrap();
1569        let unrelated_pos = rendered.find("unrelated.rs").unwrap();
1570        let clean_pos = rendered.find("also_clean.rs").unwrap();
1571        assert!(
1572            broken_pos < unrelated_pos && broken_pos < clean_pos,
1573            "error-touched file should render first; got:\n{rendered}"
1574        );
1575        assert!(
1576            rendered.contains('โš '),
1577            "expected warning marker on errored file"
1578        );
1579    }
1580
1581    #[test]
1582    fn render_diff_no_errors_falls_back_to_alpha_order() {
1583        use crate::collect::{Diff, FileDiff, FileStatus};
1584        let d = Diff {
1585            range: None,
1586            files: vec![
1587                FileDiff {
1588                    path: std::path::PathBuf::from("b.rs"),
1589                    old_path: None,
1590                    status: FileStatus::Modified,
1591                    hunks: Vec::new(),
1592                    binary: false,
1593                },
1594                FileDiff {
1595                    path: std::path::PathBuf::from("a.rs"),
1596                    old_path: None,
1597                    status: FileStatus::Modified,
1598                    hunks: Vec::new(),
1599                    binary: false,
1600                },
1601            ],
1602        };
1603        let scrubber = Scrubber::empty();
1604        let rendered = render_diff_ordered(&d, &[], &scrubber);
1605        assert!(rendered.find("a.rs").unwrap() < rendered.find("b.rs").unwrap());
1606        assert!(!rendered.contains('โš '));
1607    }
1608
1609    #[test]
1610    fn render_diff_path_rules_redact_matching_files() {
1611        use crate::collect::{Diff, DiffHunk, FileDiff, FileStatus};
1612        use crate::scrub::ScrubConfig;
1613
1614        let d = Diff {
1615            range: None,
1616            files: vec![
1617                FileDiff {
1618                    path: std::path::PathBuf::from("src/lib.rs"),
1619                    old_path: None,
1620                    status: FileStatus::Modified,
1621                    hunks: vec![DiffHunk {
1622                        old_start: 1,
1623                        old_lines: 1,
1624                        new_start: 1,
1625                        new_lines: 1,
1626                        body: "-old\n+new\n".into(),
1627                    }],
1628                    binary: false,
1629                },
1630                FileDiff {
1631                    path: std::path::PathBuf::from(".env"),
1632                    old_path: None,
1633                    status: FileStatus::Modified,
1634                    hunks: vec![DiffHunk {
1635                        old_start: 1,
1636                        old_lines: 1,
1637                        new_start: 1,
1638                        new_lines: 1,
1639                        body: "-SECRET=old\n+SECRET=new\n".into(),
1640                    }],
1641                    binary: false,
1642                },
1643            ],
1644        };
1645        let config = ScrubConfig {
1646            paths: crate::scrub::paths::PathRulesRaw {
1647                redact_whole: vec!["**/.env".into()],
1648                exclude: vec![],
1649            },
1650            ..Default::default()
1651        };
1652        let scrubber = Scrubber::from_config(&config).unwrap();
1653        let rendered = render_diff_ordered(&d, &[], &scrubber);
1654
1655        assert!(
1656            !rendered.contains("SECRET=new"),
1657            "redacted hunk content leaked into diff render: {rendered}"
1658        );
1659        assert!(rendered.contains("[REDACTED FILE: .env"));
1660        assert!(
1661            rendered.contains("+new"),
1662            "non-redacted file should still render normally"
1663        );
1664        assert!(
1665            rendered.contains('๐Ÿ”’'),
1666            "redacted file should carry lock marker"
1667        );
1668        assert!(
1669            rendered.contains("1 redacted by path rules"),
1670            "header should report path-redacted count; got:\n{rendered}"
1671        );
1672    }
1673
1674    #[test]
1675    fn try_collect_scoped_includes_real_files_and_skips_missing() {
1676        let tmp = tempfile::tempdir().unwrap();
1677        let real = tmp.path().join("real.rs");
1678        std::fs::write(&real, "fn answer() -> u8 { 42 }\n").unwrap();
1679        let scrubber = Scrubber::empty();
1680
1681        let paths = vec![
1682            std::path::PathBuf::from("real.rs"),
1683            std::path::PathBuf::from("does_not_exist.rs"),
1684        ];
1685        let out = try_collect_scoped(tmp.path(), &paths, &scrubber)
1686            .expect("at least one real file โ†’ Some");
1687
1688        assert!(out.contains("real.rs"));
1689        assert!(out.contains("fn answer"));
1690        assert!(out.contains("1 file(s) included via --files-from"));
1691        assert!(
1692            out.contains("1 listed path(s) skipped"),
1693            "missing path should bump the skipped counter; got:\n{out}"
1694        );
1695    }
1696
1697    #[test]
1698    fn try_collect_scoped_returns_none_when_all_missing() {
1699        let tmp = tempfile::tempdir().unwrap();
1700        let scrubber = Scrubber::empty();
1701        let paths = vec![
1702            std::path::PathBuf::from("nope1.rs"),
1703            std::path::PathBuf::from("nope2.rs"),
1704        ];
1705        assert!(try_collect_scoped(tmp.path(), &paths, &scrubber).is_none());
1706    }
1707
1708    #[test]
1709    fn try_collect_scoped_applies_path_redaction() {
1710        use crate::scrub::ScrubConfig;
1711        let tmp = tempfile::tempdir().unwrap();
1712        let env_file = tmp.path().join(".env");
1713        std::fs::write(&env_file, "DB_PASSWORD=hunter2\n").unwrap();
1714
1715        let config = ScrubConfig {
1716            paths: crate::scrub::paths::PathRulesRaw {
1717                redact_whole: vec!["**/.env".into()],
1718                exclude: vec![],
1719            },
1720            ..Default::default()
1721        };
1722        let scrubber = Scrubber::from_config(&config).unwrap();
1723
1724        let paths = vec![std::path::PathBuf::from(".env")];
1725        let out = try_collect_scoped(tmp.path(), &paths, &scrubber).unwrap();
1726        assert!(
1727            !out.contains("hunter2"),
1728            "redacted file content leaked: {out}"
1729        );
1730        assert!(out.contains("[REDACTED FILE:"));
1731    }
1732
1733    #[test]
1734    fn impact_aggregated_sorts_by_caller_order_and_reports_skipped() {
1735        let tmp = tempfile::tempdir().unwrap();
1736        std::fs::write(tmp.path().join("hot.rs"), "fn hot() {}\n").unwrap();
1737        std::fs::write(tmp.path().join("warm.rs"), "fn warm() {}\n").unwrap();
1738
1739        let scrubber = Scrubber::empty();
1740        let findings = vec![
1741            Finding {
1742                id: Some("f-hot".into()),
1743                primary_path: std::path::PathBuf::from("hot.rs"),
1744                kind: Some("trait_impl".into()),
1745                confidence: Some(0.95),
1746                severity: Some("high".into()),
1747                tier: Some("likely".into()),
1748                evidence: None,
1749                suggested_action: None,
1750            },
1751            Finding {
1752                id: Some("f-warm".into()),
1753                primary_path: std::path::PathBuf::from("warm.rs"),
1754                kind: None,
1755                confidence: Some(0.50),
1756                severity: None,
1757                tier: None,
1758                evidence: None,
1759                suggested_action: None,
1760            },
1761            Finding {
1762                id: Some("f-cold".into()),
1763                primary_path: std::path::PathBuf::from("cold.rs"),
1764                kind: None,
1765                confidence: Some(0.10),
1766                severity: None,
1767                tier: None,
1768                evidence: None,
1769                suggested_action: None,
1770            },
1771        ];
1772        let out = try_collect_scoped_findings(tmp.path(), &findings, &scrubber)
1773            .expect("at least one finding resolves");
1774        assert!(
1775            out.find("hot.rs").unwrap() < out.find("warm.rs").unwrap(),
1776            "hot.rs should render before warm.rs:\n{out}"
1777        );
1778        assert!(out.contains("f-hot: trait_impl, high/likely, conf=0.95"));
1779        assert!(
1780            out.contains("1 listed path(s) skipped"),
1781            "expected skipped counter in header: {out}"
1782        );
1783        assert!(out.contains("2 file(s) included via --impact-scope"));
1784    }
1785
1786    #[test]
1787    fn impact_aggregated_dedupes_co_located_findings_into_one_block() {
1788        let tmp = tempfile::tempdir().unwrap();
1789        std::fs::write(tmp.path().join("shared.rs"), "fn shared() {}\n").unwrap();
1790        let scrubber = Scrubber::empty();
1791
1792        let findings = vec![
1793            Finding {
1794                id: Some("f1".into()),
1795                primary_path: std::path::PathBuf::from("shared.rs"),
1796                kind: Some("trait_impl".into()),
1797                confidence: Some(0.9),
1798                severity: None,
1799                tier: None,
1800                evidence: None,
1801                suggested_action: None,
1802            },
1803            Finding {
1804                id: Some("f2".into()),
1805                primary_path: std::path::PathBuf::from("shared.rs"),
1806                kind: Some("dyn_dispatch".into()),
1807                confidence: Some(0.6),
1808                severity: None,
1809                tier: None,
1810                evidence: None,
1811                suggested_action: None,
1812            },
1813        ];
1814        let out = try_collect_scoped_findings(tmp.path(), &findings, &scrubber).unwrap();
1815
1816        assert_eq!(out.matches("### `shared.rs`").count(), 1);
1817        assert!(out.contains("f1"));
1818        assert!(out.contains("f2"));
1819        assert!(out.contains("1 file(s) included via --impact-scope"));
1820    }
1821
1822    #[test]
1823    fn impact_per_finding_emits_one_section_each_with_metadata() {
1824        let tmp = tempfile::tempdir().unwrap();
1825        std::fs::write(tmp.path().join("a.rs"), "fn a() {}\n").unwrap();
1826        std::fs::write(tmp.path().join("b.rs"), "fn b() {}\n").unwrap();
1827        let scrubber = Scrubber::empty();
1828
1829        let fa = Finding {
1830            id: Some("f-aaa".into()),
1831            primary_path: std::path::PathBuf::from("a.rs"),
1832            kind: Some("trait_impl".into()),
1833            confidence: Some(0.95),
1834            severity: Some("high".into()),
1835            tier: Some("likely".into()),
1836            evidence: Some("Trait change affects downstream callers".into()),
1837            suggested_action: Some("cargo nextest run -E 'test(a)'".into()),
1838        };
1839        let fb = Finding {
1840            id: Some("f-bbb".into()),
1841            primary_path: std::path::PathBuf::from("b.rs"),
1842            kind: None,
1843            confidence: None,
1844            severity: None,
1845            tier: None,
1846            evidence: None,
1847            suggested_action: None,
1848        };
1849
1850        let (name_a, body_a) = try_collect_per_finding(tmp.path(), &fa, 0, &scrubber).unwrap();
1851        assert_eq!(
1852            name_a,
1853            "๐Ÿ“‚ Impact: f-aaa (trait_impl, high/likely, conf=0.95)"
1854        );
1855        assert!(body_a.contains("**Evidence:** Trait change"));
1856        assert!(body_a.contains("**Suggested action:** `cargo nextest run"));
1857        assert!(body_a.contains("fn a() {}"));
1858
1859        let (name_b, body_b) = try_collect_per_finding(tmp.path(), &fb, 1, &scrubber).unwrap();
1860        assert_eq!(name_b, "๐Ÿ“‚ Impact: f-bbb");
1861        assert!(!body_b.contains("**Evidence:**"));
1862        assert!(body_b.contains("fn b() {}"));
1863    }
1864
1865    #[test]
1866    fn impact_per_finding_falls_back_to_positional_label_when_id_missing() {
1867        let tmp = tempfile::tempdir().unwrap();
1868        std::fs::write(tmp.path().join("x.rs"), "fn x() {}\n").unwrap();
1869        let scrubber = Scrubber::empty();
1870        let f = Finding {
1871            id: None,
1872            primary_path: std::path::PathBuf::from("x.rs"),
1873            kind: None,
1874            confidence: None,
1875            severity: None,
1876            tier: None,
1877            evidence: None,
1878            suggested_action: None,
1879        };
1880        let (name, _) = try_collect_per_finding(tmp.path(), &f, 3, &scrubber).unwrap();
1881        assert_eq!(name, "๐Ÿ“‚ Impact: finding-4");
1882    }
1883
1884    #[test]
1885    fn impact_per_finding_skips_missing_file() {
1886        let tmp = tempfile::tempdir().unwrap();
1887        let scrubber = Scrubber::empty();
1888        let f = Finding {
1889            id: Some("f-gone".into()),
1890            primary_path: std::path::PathBuf::from("does_not_exist.rs"),
1891            kind: None,
1892            confidence: None,
1893            severity: None,
1894            tier: None,
1895            evidence: None,
1896            suggested_action: None,
1897        };
1898        assert!(try_collect_per_finding(tmp.path(), &f, 0, &scrubber).is_none());
1899    }
1900
1901    #[test]
1902    fn impact_findings_take_precedence_over_files_from_in_builder() {
1903        let tmp = tempfile::tempdir().unwrap();
1904        std::fs::write(tmp.path().join("from_finding.rs"), "fn ff() {}\n").unwrap();
1905        std::fs::write(tmp.path().join("from_files_list.rs"), "fn fl() {}\n").unwrap();
1906
1907        let pack = PackBuilder::new()
1908            .project_root(tmp.path())
1909            .files_from(vec![std::path::PathBuf::from("from_files_list.rs")])
1910            .impact_findings(vec![Finding {
1911                id: Some("f-only".into()),
1912                primary_path: std::path::PathBuf::from("from_finding.rs"),
1913                kind: None,
1914                confidence: Some(0.9),
1915                severity: None,
1916                tier: None,
1917                evidence: None,
1918                suggested_action: None,
1919            }])
1920            .build()
1921            .unwrap();
1922
1923        let scoped = pack
1924            .sections
1925            .iter()
1926            .find(|s| s.name == "๐Ÿ“‚ Scoped Files")
1927            .expect("Scoped Files section emitted");
1928        assert!(
1929            scoped.content.contains("from_finding.rs"),
1930            "impact findings should drive the Scoped Files section:\n{}",
1931            scoped.content
1932        );
1933        assert!(
1934            !scoped.content.contains("from_files_list.rs"),
1935            "files_from should be superseded by impact_findings"
1936        );
1937    }
1938
1939    #[test]
1940    fn lang_for_path_maps_common_extensions() {
1941        let cases = [
1942            ("a.rs", "rust"),
1943            ("b.toml", "toml"),
1944            ("c.yaml", "yaml"),
1945            ("d.yml", "yaml"),
1946            ("e.json", "json"),
1947            ("f.md", "markdown"),
1948            ("g.unknown", ""),
1949            ("noext", ""),
1950        ];
1951        for (file, expected) in cases {
1952            assert_eq!(lang_for_path(Path::new(file)), expected, "for {file}");
1953        }
1954    }
1955}