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 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, §ion_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}