1use std::path::Path;
2
3use serde::{Deserialize, Serialize};
4
5use crate::budget::{self, Budget, P_DIFF, P_ENTRY, P_ERROR, P_EXEMPT, P_MAP, P_TESTS, Priority};
6use crate::collect::{self, Diagnostics, Diff, EntryPoints, RelatedTests, WorkspaceMap};
7use crate::error::Result;
8use crate::expand::{self, ExpandMode};
9use crate::impact::Finding;
10use crate::scrub::Scrubber;
11use crate::tokenize::Tokenizer;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "lowercase")]
15pub enum Preset {
16 Fix,
17 Feature,
18 Custom,
19}
20
21#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "lowercase")]
23pub enum Format {
24 #[default]
25 Markdown,
26 Xml,
27 Json,
28 Plain,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct Section {
33 pub name: String,
34 pub content: String,
35 pub token_estimate: usize,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct Pack {
40 pub schema: String,
41 pub project: String,
42 pub sections: Vec<Section>,
43 pub tokens_used: usize,
44 pub tokens_budget: usize,
45 pub tokenizer: String,
46 pub dropped: Vec<String>,
47 #[serde(default)]
48 pub scrub: crate::scrub::ScrubReport,
49}
50
51impl Pack {
52 pub fn render(&self, format: Format) -> Result<String> {
53 match format {
54 Format::Markdown => Ok(self.render_markdown()),
55 Format::Xml => Ok(self.render_xml()),
56 Format::Json => self.render_json(),
57 Format::Plain => Ok(self.render_plain()),
58 }
59 }
60
61 pub fn render_markdown(&self) -> String {
62 let mut out = String::new();
63 out.push_str(&format!("# PROJECT CONTEXT PACK: {}\n", self.project));
64 let dropped = if self.dropped.is_empty() {
65 String::new()
66 } else {
67 format!(" | dropped: {}", self.dropped.join(", "))
68 };
69 out.push_str(&format!(
70 "<!-- schema: {} | tokens: {}/{} | tokenizer: {}{} -->\n\n",
71 self.schema, self.tokens_used, self.tokens_budget, self.tokenizer, dropped
72 ));
73 for s in &self.sections {
74 out.push_str(&format!("## {}\n\n{}\n\n", s.name, s.content));
75 }
76 out
77 }
78
79 pub fn render_xml(&self) -> String {
80 let mut out = String::new();
81 out.push_str(&format!(
82 "<pack schema=\"{}\" project=\"{}\" tokens=\"{}/{}\" tokenizer=\"{}\">\n",
83 self.schema, self.project, self.tokens_used, self.tokens_budget, self.tokenizer
84 ));
85 for s in &self.sections {
86 out.push_str(&format!(
87 " <section name=\"{}\" tokens=\"{}\">\n{}\n </section>\n",
88 s.name, s.token_estimate, s.content
89 ));
90 }
91 out.push_str("</pack>\n");
92 out
93 }
94
95 pub fn render_plain(&self) -> String {
96 self.sections
97 .iter()
98 .map(|s| s.content.as_str())
99 .collect::<Vec<_>>()
100 .join("\n\n")
101 }
102
103 pub fn render_json(&self) -> Result<String> {
104 Ok(serde_json::to_string_pretty(self)?)
105 }
106}
107
108#[derive(Debug, Clone)]
109pub struct PackBuilder {
110 preset: Preset,
111 budget: Budget,
112 tokenizer: Tokenizer,
113 scrub: bool,
114 expand_mode: ExpandMode,
115 include_paths: Vec<String>,
116 exclude_paths: Vec<String>,
117 project_root: Option<std::path::PathBuf>,
118 stdin_prompt: Option<String>,
119 files_from: Vec<std::path::PathBuf>,
120 impact_findings: Vec<Finding>,
121 impact_per_finding: bool,
122}
123
124impl Default for PackBuilder {
125 fn default() -> Self {
126 Self {
127 preset: Preset::Custom,
128 budget: Budget::default(),
129 tokenizer: Tokenizer::Llama3,
130 scrub: true,
131 expand_mode: ExpandMode::default(),
132 include_paths: Vec::new(),
133 exclude_paths: Vec::new(),
134 project_root: None,
135 stdin_prompt: None,
136 files_from: Vec::new(),
137 impact_findings: Vec::new(),
138 impact_per_finding: false,
139 }
140 }
141}
142
143impl PackBuilder {
144 pub fn new() -> Self {
145 Self::default()
146 }
147
148 pub fn preset(mut self, p: Preset) -> Self {
149 self.preset = p;
150 self
151 }
152 pub fn budget(mut self, b: Budget) -> Self {
153 self.budget = b;
154 self
155 }
156 pub fn max_tokens(mut self, n: usize) -> Self {
157 self.budget.max_tokens = n;
158 self
159 }
160 pub fn reserve_tokens(mut self, n: usize) -> Self {
161 self.budget.reserve_tokens = n;
162 self
163 }
164 pub fn tokenizer(mut self, t: Tokenizer) -> Self {
165 self.tokenizer = t;
166 self
167 }
168 pub fn scrub(mut self, on: bool) -> Self {
169 self.scrub = on;
170 self
171 }
172 pub fn expand_mode(mut self, m: ExpandMode) -> Self {
173 self.expand_mode = m;
174 self
175 }
176 pub fn include_path(mut self, path: impl Into<String>) -> Self {
177 self.include_paths.push(path.into());
178 self
179 }
180 pub fn exclude_path(mut self, path: impl Into<String>) -> Self {
181 self.exclude_paths.push(path.into());
182 self
183 }
184 pub fn project_root(mut self, p: impl Into<std::path::PathBuf>) -> Self {
185 self.project_root = Some(p.into());
186 self
187 }
188 pub fn stdin_prompt(mut self, prompt: impl Into<String>) -> Self {
189 self.stdin_prompt = Some(prompt.into());
190 self
191 }
192
193 pub fn files_from(mut self, paths: Vec<std::path::PathBuf>) -> Self {
203 self.files_from = paths;
204 self
205 }
206
207 pub fn impact_findings(mut self, findings: Vec<Finding>) -> Self {
224 self.impact_findings = findings;
225 self
226 }
227
228 pub fn impact_per_finding(mut self, on: bool) -> Self {
231 self.impact_per_finding = on;
232 self
233 }
234
235 pub fn build(self) -> Result<Pack> {
246 let root = self
247 .project_root
248 .clone()
249 .unwrap_or_else(|| std::path::PathBuf::from("."));
250
251 let scrubber = if self.scrub {
255 Scrubber::with_workspace(&root)?
256 } else {
257 Scrubber::empty()
258 };
259
260 let mut candidates: Vec<(Priority, Section)> = Vec::new();
261
262 if let Some(prompt) = &self.stdin_prompt {
263 candidates.push((
264 P_EXEMPT,
265 mk_section("π User Prompt", prompt, &self.tokenizer),
266 ));
267 }
268
269 let wants = SectionWants::for_preset(self.preset);
270
271 let diagnostics = if wants.errors {
274 collect::last_error(&root).ok()
275 } else {
276 None
277 };
278 let error_files: Vec<std::path::PathBuf> = diagnostics
279 .as_ref()
280 .map(|d| d.referenced_files())
281 .unwrap_or_default();
282
283 if let Some(d) = diagnostics.as_ref()
284 && !d.is_empty()
285 {
286 let content = render_diagnostics(d);
287 candidates.push((
288 P_ERROR,
289 mk_section("π¨ Current State (Errors)", &content, &self.tokenizer),
290 ));
291 }
292
293 let diff = if wants.diff || wants.tests {
295 collect::git_diff(&root, None).ok()
296 } else {
297 None
298 };
299
300 if wants.diff
301 && let Some(d) = diff.as_ref()
302 && !d.is_empty()
303 {
304 candidates.push((
305 P_DIFF,
306 mk_section(
307 "β‘ Intent (Git Diff)",
308 &render_diff_ordered(d, &error_files, &scrubber),
309 &self.tokenizer,
310 ),
311 ));
312 }
313 if wants.map
314 && let Some(content) = try_collect_map(&root)
315 {
316 candidates.push((
317 P_MAP,
318 mk_section("πΊοΈ Project Map", &content, &self.tokenizer),
319 ));
320 }
321 if wants.entry
322 && let Some(content) = try_collect_entry(&root)
323 {
324 candidates.push((
325 P_ENTRY,
326 mk_section("π§ Entry Points", &content, &self.tokenizer),
327 ));
328 }
329 if !self.impact_findings.is_empty() {
337 if self.impact_per_finding {
338 for (idx, f) in self.impact_findings.iter().enumerate() {
339 if let Some((name, body)) = try_collect_per_finding(&root, f, idx, &scrubber) {
340 candidates.push((P_DIFF, mk_section(&name, &body, &self.tokenizer)));
341 }
342 }
343 } else if let Some(content) =
344 try_collect_scoped_findings(&root, &self.impact_findings, &scrubber)
345 {
346 candidates.push((
347 P_DIFF,
348 mk_section("π Scoped Files", &content, &self.tokenizer),
349 ));
350 }
351 } else if !self.files_from.is_empty()
352 && let Some(content) = try_collect_scoped(&root, &self.files_from, &scrubber)
353 {
354 candidates.push((
355 P_DIFF,
356 mk_section("π Scoped Files", &content, &self.tokenizer),
357 ));
358 }
359
360 if wants.tests {
361 let mut changed: Vec<std::path::PathBuf> = diff
364 .as_ref()
365 .map(|d| d.files.iter().map(|f| f.path.clone()).collect())
366 .unwrap_or_default();
367 changed.extend(self.files_from.iter().cloned());
368 changed.extend(self.impact_findings.iter().map(|f| f.primary_path.clone()));
369 if !changed.is_empty()
370 && let Some(content) = try_collect_tests(&root, &changed)
371 {
372 candidates.push((
373 P_TESTS,
374 mk_section("π― Related Tests", &content, &self.tokenizer),
375 ));
376 }
377 }
378 if self.expand_mode != ExpandMode::Off
379 && let Some(content) = try_collect_expansion(&root, self.expand_mode, diff.as_ref())
380 {
381 candidates.push((
382 P_ENTRY,
383 mk_section("π Expanded Macros", &content, &self.tokenizer),
384 ));
385 }
386
387 let mut scrub_report = crate::scrub::ScrubReport::default();
388 if self.scrub {
389 for (_, s) in candidates.iter_mut() {
390 let (scrubbed, report) = scrubber.scrub_with_report(&s.content);
391 s.content = scrubbed;
392 s.token_estimate = self.tokenizer.count(&s.content);
393 scrub_report.redactions.extend(report.redactions);
394 }
395 scrubber.log_redactions(&scrub_report)?;
397 }
398
399 let alloc = budget::allocate(candidates, &self.budget, &self.tokenizer);
400
401 Ok(Pack {
402 schema: "cargo-context/v1".into(),
403 project: project_name(self.project_root.as_deref()),
404 sections: alloc.kept,
405 tokens_used: alloc.tokens_used,
406 tokens_budget: alloc.tokens_budget,
407 tokenizer: self.tokenizer.label().into(),
408 dropped: alloc.dropped,
409 scrub: scrub_report,
410 })
411 }
412}
413
414#[derive(Debug, Clone, Copy)]
415struct SectionWants {
416 map: bool,
417 errors: bool,
418 diff: bool,
419 entry: bool,
420 tests: bool,
421}
422
423impl SectionWants {
424 fn for_preset(p: Preset) -> Self {
425 match p {
426 Preset::Fix => Self {
427 map: false,
428 errors: true,
429 diff: true,
430 entry: false,
431 tests: true,
432 },
433 Preset::Feature => Self {
434 map: true,
435 errors: false,
436 diff: true,
437 entry: true,
438 tests: true,
439 },
440 Preset::Custom => Self {
441 map: true,
442 errors: false,
443 diff: true,
444 entry: true,
445 tests: true,
446 },
447 }
448 }
449}
450
451fn try_collect_map(root: &Path) -> Option<String> {
452 collect::cargo_metadata(root).ok().map(render_map)
453}
454
455fn try_collect_expansion(root: &Path, mode: ExpandMode, diff: Option<&Diff>) -> Option<String> {
456 if matches!(mode, ExpandMode::Off) {
457 return None;
458 }
459 if !expand::expand_available() {
460 return None;
461 }
462 let meta = collect::cargo_metadata(root).ok()?;
463
464 if matches!(mode, ExpandMode::Auto) {
468 let has_rust = diff
469 .map(|d| {
470 d.files
471 .iter()
472 .any(|f| f.path.extension().and_then(|e| e.to_str()) == Some("rs"))
473 })
474 .unwrap_or(false);
475 if !has_rust {
476 return None;
477 }
478 }
479
480 let mut out = String::new();
481 let mut expanded_any = false;
482 for member in &meta.members {
483 let dir = match member.manifest_path.parent() {
484 Some(d) => d,
485 None => continue,
486 };
487 let lib = dir.join("src/lib.rs");
488 let main = dir.join("src/main.rs");
489 let target = if lib.exists() {
490 lib
491 } else if main.exists() {
492 main
493 } else {
494 continue;
495 };
496 match expand::expand_file(&meta.workspace_root, &member.name, &target) {
497 Ok(Some(text)) => {
498 out.push_str(&format!(
499 "### `{}` β {} (expanded)\n```rust\n{}\n```\n\n",
500 target.display(),
501 member.name,
502 text.trim_end()
503 ));
504 expanded_any = true;
505 }
506 Ok(None) | Err(_) => continue,
507 }
508 }
509 if expanded_any { Some(out) } else { None }
510}
511
512fn try_collect_scoped(
517 root: &Path,
518 paths: &[std::path::PathBuf],
519 scrubber: &Scrubber,
520) -> Option<String> {
521 let mut body = String::new();
522 let mut included = 0_usize;
523 let mut skipped = 0_usize;
524
525 for rel in paths {
526 let abs = if rel.is_absolute() {
527 rel.clone()
528 } else {
529 root.join(rel)
530 };
531 if !abs.is_file() {
532 skipped += 1;
533 continue;
534 }
535 let raw = match std::fs::read_to_string(&abs) {
536 Ok(s) => s,
537 Err(_) => {
538 skipped += 1;
539 continue;
540 }
541 };
542 let (content, _report) = scrubber.scrub_file(rel, &raw);
543 let lang = lang_for_path(rel);
544 body.push_str(&format!(
545 "### `{}`\n```{lang}\n{}\n```\n\n",
546 rel.display(),
547 content.trim_end()
548 ));
549 included += 1;
550 }
551
552 if included == 0 {
553 return None;
554 }
555
556 let mut header = format!("{included} file(s) included via --files-from");
557 if skipped > 0 {
558 header.push_str(&format!(
559 " ({skipped} listed path(s) skipped: missing, not a regular file, or unreadable)"
560 ));
561 }
562 header.push_str(".\n\n");
563 Some(format!("{header}{body}"))
564}
565
566fn try_collect_scoped_findings(
573 root: &Path,
574 findings: &[Finding],
575 scrubber: &Scrubber,
576) -> Option<String> {
577 let mut body = String::new();
578 let mut included = 0_usize;
579 let mut skipped = 0_usize;
580 let mut emitted: std::collections::HashSet<std::path::PathBuf> =
581 std::collections::HashSet::new();
582
583 for (i, f) in findings.iter().enumerate() {
584 if !emitted.insert(f.primary_path.clone()) {
585 continue;
586 }
587 let abs = if f.primary_path.is_absolute() {
588 f.primary_path.clone()
589 } else {
590 root.join(&f.primary_path)
591 };
592 if !abs.is_file() {
593 skipped += 1;
594 continue;
595 }
596 let raw = match std::fs::read_to_string(&abs) {
597 Ok(s) => s,
598 Err(_) => {
599 skipped += 1;
600 continue;
601 }
602 };
603 let (content, _report) = scrubber.scrub_file(&f.primary_path, &raw);
604 let lang = f.language_hint();
605
606 let co_findings: Vec<&Finding> = findings
609 .iter()
610 .skip(i)
611 .filter(|g| g.primary_path == f.primary_path)
612 .collect();
613 let header = format_file_header(&f.primary_path, &co_findings);
614
615 body.push_str(&format!(
616 "{header}\n```{lang}\n{}\n```\n\n",
617 content.trim_end()
618 ));
619 included += 1;
620 }
621
622 if included == 0 {
623 return None;
624 }
625
626 let mut preamble =
627 format!("{included} file(s) included via --impact-scope (sorted by confidence desc)");
628 if skipped > 0 {
629 preamble.push_str(&format!(
630 "; {skipped} listed path(s) skipped (missing or unreadable)"
631 ));
632 }
633 preamble.push_str(".\n\n");
634 Some(format!("{preamble}{body}"))
635}
636
637fn try_collect_per_finding(
642 root: &Path,
643 f: &Finding,
644 idx: usize,
645 scrubber: &Scrubber,
646) -> Option<(String, String)> {
647 let abs = if f.primary_path.is_absolute() {
648 f.primary_path.clone()
649 } else {
650 root.join(&f.primary_path)
651 };
652 if !abs.is_file() {
653 return None;
654 }
655 let raw = std::fs::read_to_string(&abs).ok()?;
656 let (content, _report) = scrubber.scrub_file(&f.primary_path, &raw);
657 let lang = f.language_hint();
658
659 let label =
660 f.id.clone()
661 .unwrap_or_else(|| format!("finding-{}", idx + 1));
662 let descriptor = f.descriptor();
663 let name = if descriptor.is_empty() {
664 format!("π Impact: {label}")
665 } else {
666 format!("π Impact: {label} ({descriptor})")
667 };
668
669 let mut body = String::new();
670 if let Some(ev) = &f.evidence {
671 body.push_str(&format!("**Evidence:** {ev}\n\n"));
672 }
673 if let Some(act) = &f.suggested_action {
674 body.push_str(&format!("**Suggested action:** `{act}`\n\n"));
675 }
676 body.push_str(&format!(
677 "### `{}`\n```{lang}\n{}\n```\n",
678 f.primary_path.display(),
679 content.trim_end()
680 ));
681
682 Some((name, body))
683}
684
685fn format_file_header(path: &Path, findings: &[&Finding]) -> String {
690 let mut header = format!("### `{}`", path.display());
691 let labels: Vec<String> = findings
692 .iter()
693 .map(|f| {
694 let id = f.id.as_deref().unwrap_or("finding");
695 let d = f.descriptor();
696 if d.is_empty() {
697 id.to_string()
698 } else {
699 format!("{id}: {d}")
700 }
701 })
702 .collect();
703 if !labels.is_empty() {
704 header.push_str(" β ");
705 header.push_str(&labels.join("; "));
706 }
707 header
708}
709
710pub(crate) fn lang_for_path(path: &Path) -> &'static str {
711 match path.extension().and_then(|e| e.to_str()) {
712 Some("rs") => "rust",
713 Some("toml") => "toml",
714 Some("yaml" | "yml") => "yaml",
715 Some("json") => "json",
716 Some("md") => "markdown",
717 Some("sh" | "bash") => "bash",
718 Some("py") => "python",
719 Some("ts") => "typescript",
720 Some("js") => "javascript",
721 _ => "",
722 }
723}
724
725fn try_collect_tests(root: &Path, changed: &[std::path::PathBuf]) -> Option<String> {
726 let rt = collect::related_tests(root, changed).ok()?;
727 if rt.is_empty() {
728 None
729 } else {
730 Some(render_tests(&rt))
731 }
732}
733
734fn try_collect_entry(root: &Path) -> Option<String> {
735 let ep = collect::entry_points(root).ok()?;
736 if ep.is_empty() {
737 None
738 } else {
739 Some(render_entry(&ep))
740 }
741}
742
743fn render_tests(rt: &RelatedTests) -> String {
744 let mut out = String::new();
745 out.push_str(&format!("{} related test file(s).\n\n", rt.files.len()));
746 for f in &rt.files {
747 let kind = match f.kind {
748 collect::TestKind::Integration => "integration",
749 collect::TestKind::UnitInline => "unit (inline)",
750 };
751 let reason = if f.matched_stems.is_empty() {
752 String::new()
753 } else {
754 format!(" β matched: `{}`", f.matched_stems.join("`, `"))
755 };
756 out.push_str(&format!(
757 "### `{}` β {} / {} ({} tests){}\n",
758 f.path.display(),
759 f.crate_name,
760 kind,
761 f.functions.len(),
762 reason,
763 ));
764 for fun in &f.functions {
765 out.push_str(&format!("- `{}`\n", fun.signature.trim()));
766 }
767 out.push('\n');
768 }
769 out
770}
771
772fn render_entry(ep: &EntryPoints) -> String {
773 let mut out = String::new();
774 out.push_str(&format!("{} entry file(s).\n\n", ep.files.len()));
775 for f in &ep.files {
776 let kind = match f.kind {
777 collect::EntryKind::Main => "main",
778 collect::EntryKind::Lib => "lib",
779 };
780 let tag = if f.parse_failed { " (unparsed)" } else { "" };
781 out.push_str(&format!(
782 "### `{}` β {} / {} ({} lines){}\n",
783 f.path.display(),
784 f.crate_name,
785 kind,
786 f.raw_line_count,
787 tag,
788 ));
789 out.push_str("```rust\n");
790 out.push_str(&f.rendered);
791 if !f.rendered.ends_with('\n') {
792 out.push('\n');
793 }
794 out.push_str("```\n\n");
795 }
796 out
797}
798
799fn render_map(m: WorkspaceMap) -> String {
800 let mut out = String::new();
801 if let Some(root) = &m.root_package {
802 out.push_str(&format!("- Root package: `{root}`\n"));
803 }
804 let members = m.member_names();
805 if !members.is_empty() {
806 out.push_str(&format!("- Workspace members ({}): ", members.len()));
807 out.push_str(&members.join(", "));
808 out.push('\n');
809 }
810 let deps = m.external_dep_names();
811 if !deps.is_empty() {
812 let preview: Vec<&str> = deps.iter().take(12).copied().collect();
813 out.push_str(&format!(
814 "- Key dependencies: {}{}\n",
815 preview.join(", "),
816 if deps.len() > preview.len() {
817 format!(" (+{} more)", deps.len() - preview.len())
818 } else {
819 String::new()
820 }
821 ));
822 }
823 out
824}
825
826fn render_diff_ordered(
835 d: &Diff,
836 error_files: &[std::path::PathBuf],
837 scrubber: &Scrubber,
838) -> String {
839 let error_set: std::collections::HashSet<&Path> =
840 error_files.iter().map(|p| p.as_path()).collect();
841
842 let mut files: Vec<&collect::FileDiff> = d.files.iter().collect();
843 files.sort_by_key(|f| {
844 let has_error = error_set.contains(f.path.as_path())
846 || error_files.iter().any(|e| path_matches_suffix(&f.path, e));
847 (!has_error, f.path.to_string_lossy().into_owned())
848 });
849
850 let errored_count = files
851 .iter()
852 .filter(|f| {
853 error_set.contains(f.path.as_path())
854 || error_files.iter().any(|e| path_matches_suffix(&f.path, e))
855 })
856 .count();
857 let path_redacted_count = files
858 .iter()
859 .filter(|f| scrubber.is_path_redacted(&f.path))
860 .count();
861
862 let mut out = String::new();
863 let mut header = format!("{} file(s) changed", d.files.len());
864 if errored_count > 0 {
865 header.push_str(&format!(
866 "; {errored_count} touched by compiler errors (shown first)"
867 ));
868 }
869 if path_redacted_count > 0 {
870 header.push_str(&format!("; {path_redacted_count} redacted by path rules"));
871 }
872 out.push_str(&format!("{header}.\n\n"));
873
874 for f in files {
875 let status = format!("{:?}", f.status).to_lowercase();
876 let error_marker = if error_set.contains(f.path.as_path())
877 || error_files.iter().any(|e| path_matches_suffix(&f.path, e))
878 {
879 " β "
880 } else {
881 ""
882 };
883 let redacted = scrubber.is_path_redacted(&f.path);
884 let redact_marker = if redacted { " π" } else { "" };
885
886 out.push_str(&format!(
887 "### `{}` β {status}{error_marker}{redact_marker}\n",
888 f.path.display()
889 ));
890 if let Some(old) = &f.old_path {
891 out.push_str(&format!("- Renamed from `{}`\n", old.display()));
892 }
893 if redacted {
894 out.push_str(&format!(
895 "[REDACTED FILE: {} β {} hunk(s) elided by scrub.yaml path rules]\n",
896 f.path.display(),
897 f.hunks.len()
898 ));
899 } else {
900 for h in &f.hunks {
901 out.push_str(&format!(
902 "```diff\n@@ -{},{} +{},{} @@\n{}```\n",
903 h.old_start, h.old_lines, h.new_start, h.new_lines, h.body
904 ));
905 }
906 }
907 out.push('\n');
908 }
909 out
910}
911
912fn path_matches_suffix(haystack: &Path, needle: &Path) -> bool {
916 let h = haystack.to_string_lossy();
917 let n = needle.to_string_lossy();
918 h.ends_with(n.as_ref()) || n.ends_with(h.as_ref())
919}
920
921fn render_diagnostics(d: &Diagnostics) -> String {
922 let mut out = String::new();
923 let err_count = d
924 .diagnostics
925 .iter()
926 .filter(|x| x.level == crate::collect::DiagLevel::Error)
927 .count();
928 out.push_str(&format!(
929 "Build {}; {} diagnostic(s), {} error(s).\n\n",
930 if d.success { "succeeded" } else { "failed" },
931 d.diagnostics.len(),
932 err_count,
933 ));
934 for diag in &d.diagnostics {
935 let code = diag.code.as_deref().unwrap_or("");
936 out.push_str(&format!(
937 "- **{:?}** {}: {}\n",
938 diag.level, code, diag.message
939 ));
940 if let Some(file) = diag.primary_file()
941 && let Some(span) = diag.spans.iter().find(|s| s.is_primary)
942 {
943 out.push_str(&format!(
944 " at `{}:{}:{}`\n",
945 file.display(),
946 span.line_start,
947 span.col_start
948 ));
949 }
950 }
951 out
952}
953
954fn mk_section(name: &str, content: &str, tokenizer: &Tokenizer) -> Section {
955 Section {
956 name: name.into(),
957 content: content.into(),
958 token_estimate: tokenizer.count(content),
959 }
960}
961
962fn project_name(root: Option<&std::path::Path>) -> String {
963 root.and_then(|p| p.file_name())
964 .map(|n| n.to_string_lossy().into_owned())
965 .unwrap_or_else(|| "unknown".to_string())
966}
967
968#[cfg(test)]
969mod tests {
970 use super::*;
971
972 #[test]
973 fn builder_includes_prompt_section() {
974 let pack = PackBuilder::new()
975 .preset(Preset::Fix)
976 .max_tokens(4000)
977 .stdin_prompt("why does this fail?")
978 .project_root(std::env::temp_dir()) .build()
980 .expect("build pack");
981 assert_eq!(pack.schema, "cargo-context/v1");
982 assert!(pack.sections.iter().any(|s| s.name.contains("Prompt")));
983 }
984
985 #[test]
986 fn builder_empty_workspace_is_valid() {
987 let pack = PackBuilder::new()
990 .preset(Preset::Fix)
991 .project_root(std::env::temp_dir())
992 .build()
993 .expect("build pack");
994 assert_eq!(pack.schema, "cargo-context/v1");
995 }
996
997 #[test]
998 fn json_roundtrip() {
999 let pack = PackBuilder::new()
1000 .project_root(std::env::temp_dir())
1001 .build()
1002 .unwrap();
1003 let s = pack.render_json().unwrap();
1004 let _: Pack = serde_json::from_str(&s).unwrap();
1005 }
1006
1007 #[test]
1008 fn render_diff_puts_error_files_first() {
1009 use crate::collect::{Diff, FileDiff, FileStatus};
1010 let d = Diff {
1011 range: None,
1012 files: vec![
1013 FileDiff {
1014 path: std::path::PathBuf::from("src/unrelated.rs"),
1015 old_path: None,
1016 status: FileStatus::Modified,
1017 hunks: Vec::new(),
1018 binary: false,
1019 },
1020 FileDiff {
1021 path: std::path::PathBuf::from("src/broken.rs"),
1022 old_path: None,
1023 status: FileStatus::Modified,
1024 hunks: Vec::new(),
1025 binary: false,
1026 },
1027 FileDiff {
1028 path: std::path::PathBuf::from("src/also_clean.rs"),
1029 old_path: None,
1030 status: FileStatus::Modified,
1031 hunks: Vec::new(),
1032 binary: false,
1033 },
1034 ],
1035 };
1036 let errors = vec![std::path::PathBuf::from("src/broken.rs")];
1037 let scrubber = Scrubber::empty();
1038 let rendered = render_diff_ordered(&d, &errors, &scrubber);
1039 let broken_pos = rendered.find("broken.rs").unwrap();
1041 let unrelated_pos = rendered.find("unrelated.rs").unwrap();
1042 let clean_pos = rendered.find("also_clean.rs").unwrap();
1043 assert!(
1044 broken_pos < unrelated_pos && broken_pos < clean_pos,
1045 "error-touched file should render first; got:\n{rendered}"
1046 );
1047 assert!(
1048 rendered.contains('β '),
1049 "expected warning marker on errored file"
1050 );
1051 }
1052
1053 #[test]
1054 fn render_diff_no_errors_falls_back_to_alpha_order() {
1055 use crate::collect::{Diff, FileDiff, FileStatus};
1056 let d = Diff {
1057 range: None,
1058 files: vec![
1059 FileDiff {
1060 path: std::path::PathBuf::from("b.rs"),
1061 old_path: None,
1062 status: FileStatus::Modified,
1063 hunks: Vec::new(),
1064 binary: false,
1065 },
1066 FileDiff {
1067 path: std::path::PathBuf::from("a.rs"),
1068 old_path: None,
1069 status: FileStatus::Modified,
1070 hunks: Vec::new(),
1071 binary: false,
1072 },
1073 ],
1074 };
1075 let scrubber = Scrubber::empty();
1076 let rendered = render_diff_ordered(&d, &[], &scrubber);
1077 assert!(rendered.find("a.rs").unwrap() < rendered.find("b.rs").unwrap());
1078 assert!(!rendered.contains('β '));
1080 }
1081
1082 #[test]
1083 fn render_diff_path_rules_redact_matching_files() {
1084 use crate::collect::{Diff, FileDiff, FileStatus};
1085 use crate::scrub::ScrubConfig;
1086
1087 let d = Diff {
1088 range: None,
1089 files: vec![
1090 FileDiff {
1091 path: std::path::PathBuf::from("src/lib.rs"),
1092 old_path: None,
1093 status: FileStatus::Modified,
1094 hunks: vec![crate::collect::DiffHunk {
1095 old_start: 1,
1096 old_lines: 1,
1097 new_start: 1,
1098 new_lines: 1,
1099 body: "-old\n+new\n".into(),
1100 }],
1101 binary: false,
1102 },
1103 FileDiff {
1104 path: std::path::PathBuf::from(".env"),
1105 old_path: None,
1106 status: FileStatus::Modified,
1107 hunks: vec![crate::collect::DiffHunk {
1108 old_start: 1,
1109 old_lines: 1,
1110 new_start: 1,
1111 new_lines: 1,
1112 body: "-SECRET=old\n+SECRET=new\n".into(),
1113 }],
1114 binary: false,
1115 },
1116 ],
1117 };
1118 let config = ScrubConfig {
1119 paths: crate::scrub::paths::PathRulesRaw {
1120 redact_whole: vec!["**/.env".into()],
1121 exclude: vec![],
1122 },
1123 ..Default::default()
1124 };
1125 let scrubber = Scrubber::from_config(&config).unwrap();
1126 let rendered = render_diff_ordered(&d, &[], &scrubber);
1127
1128 assert!(
1130 !rendered.contains("SECRET=new"),
1131 "redacted hunk content leaked into diff render: {rendered}"
1132 );
1133 assert!(rendered.contains("[REDACTED FILE: .env"));
1134 assert!(
1135 rendered.contains("+new"),
1136 "non-redacted file should still render normally"
1137 );
1138 assert!(
1139 rendered.contains('π'),
1140 "redacted file should carry lock marker"
1141 );
1142 assert!(
1143 rendered.contains("1 redacted by path rules"),
1144 "header should report path-redacted count; got:\n{rendered}"
1145 );
1146 }
1147
1148 #[test]
1149 fn try_collect_scoped_includes_real_files_and_skips_missing() {
1150 let tmp = tempfile::tempdir().unwrap();
1151 let real = tmp.path().join("real.rs");
1152 std::fs::write(&real, "fn answer() -> u8 { 42 }\n").unwrap();
1153 let scrubber = Scrubber::empty();
1154
1155 let paths = vec![
1156 std::path::PathBuf::from("real.rs"),
1157 std::path::PathBuf::from("does_not_exist.rs"),
1158 ];
1159 let out = try_collect_scoped(tmp.path(), &paths, &scrubber)
1160 .expect("at least one real file β Some");
1161
1162 assert!(out.contains("real.rs"));
1163 assert!(out.contains("fn answer"));
1164 assert!(out.contains("1 file(s) included via --files-from"));
1165 assert!(
1166 out.contains("1 listed path(s) skipped"),
1167 "missing path should bump the skipped counter; got:\n{out}"
1168 );
1169 }
1170
1171 #[test]
1172 fn try_collect_scoped_returns_none_when_all_missing() {
1173 let tmp = tempfile::tempdir().unwrap();
1174 let scrubber = Scrubber::empty();
1175 let paths = vec![
1176 std::path::PathBuf::from("nope1.rs"),
1177 std::path::PathBuf::from("nope2.rs"),
1178 ];
1179 assert!(try_collect_scoped(tmp.path(), &paths, &scrubber).is_none());
1180 }
1181
1182 #[test]
1183 fn try_collect_scoped_applies_path_redaction() {
1184 use crate::scrub::ScrubConfig;
1185 let tmp = tempfile::tempdir().unwrap();
1186 let env_file = tmp.path().join(".env");
1187 std::fs::write(&env_file, "DB_PASSWORD=hunter2\n").unwrap();
1188
1189 let config = ScrubConfig {
1190 paths: crate::scrub::paths::PathRulesRaw {
1191 redact_whole: vec!["**/.env".into()],
1192 exclude: vec![],
1193 },
1194 ..Default::default()
1195 };
1196 let scrubber = Scrubber::from_config(&config).unwrap();
1197
1198 let paths = vec![std::path::PathBuf::from(".env")];
1199 let out = try_collect_scoped(tmp.path(), &paths, &scrubber).unwrap();
1200 assert!(
1201 !out.contains("hunter2"),
1202 "redacted file content leaked: {out}"
1203 );
1204 assert!(out.contains("[REDACTED FILE:"));
1205 }
1206
1207 #[test]
1208 fn impact_aggregated_sorts_by_caller_order_and_reports_skipped() {
1209 let tmp = tempfile::tempdir().unwrap();
1210 std::fs::write(tmp.path().join("hot.rs"), "fn hot() {}\n").unwrap();
1211 std::fs::write(tmp.path().join("warm.rs"), "fn warm() {}\n").unwrap();
1212 let scrubber = Scrubber::empty();
1215 let findings = vec![
1216 Finding {
1217 id: Some("f-hot".into()),
1218 primary_path: std::path::PathBuf::from("hot.rs"),
1219 kind: Some("trait_impl".into()),
1220 confidence: Some(0.95),
1221 severity: Some("high".into()),
1222 tier: Some("likely".into()),
1223 evidence: None,
1224 suggested_action: None,
1225 },
1226 Finding {
1227 id: Some("f-warm".into()),
1228 primary_path: std::path::PathBuf::from("warm.rs"),
1229 kind: None,
1230 confidence: Some(0.50),
1231 severity: None,
1232 tier: None,
1233 evidence: None,
1234 suggested_action: None,
1235 },
1236 Finding {
1237 id: Some("f-cold".into()),
1238 primary_path: std::path::PathBuf::from("cold.rs"),
1239 kind: None,
1240 confidence: Some(0.10),
1241 severity: None,
1242 tier: None,
1243 evidence: None,
1244 suggested_action: None,
1245 },
1246 ];
1247 let out = try_collect_scoped_findings(tmp.path(), &findings, &scrubber)
1248 .expect("at least one finding resolves");
1249 assert!(
1251 out.find("hot.rs").unwrap() < out.find("warm.rs").unwrap(),
1252 "hot.rs should render before warm.rs:\n{out}"
1253 );
1254 assert!(out.contains("f-hot: trait_impl, high/likely, conf=0.95"));
1256 assert!(
1258 out.contains("1 listed path(s) skipped"),
1259 "expected skipped counter in header: {out}"
1260 );
1261 assert!(out.contains("2 file(s) included via --impact-scope"));
1262 }
1263
1264 #[test]
1265 fn impact_aggregated_dedupes_co_located_findings_into_one_block() {
1266 let tmp = tempfile::tempdir().unwrap();
1267 std::fs::write(tmp.path().join("shared.rs"), "fn shared() {}\n").unwrap();
1268 let scrubber = Scrubber::empty();
1269
1270 let findings = vec![
1271 Finding {
1272 id: Some("f1".into()),
1273 primary_path: std::path::PathBuf::from("shared.rs"),
1274 kind: Some("trait_impl".into()),
1275 confidence: Some(0.9),
1276 severity: None,
1277 tier: None,
1278 evidence: None,
1279 suggested_action: None,
1280 },
1281 Finding {
1282 id: Some("f2".into()),
1283 primary_path: std::path::PathBuf::from("shared.rs"),
1284 kind: Some("dyn_dispatch".into()),
1285 confidence: Some(0.6),
1286 severity: None,
1287 tier: None,
1288 evidence: None,
1289 suggested_action: None,
1290 },
1291 ];
1292 let out = try_collect_scoped_findings(tmp.path(), &findings, &scrubber).unwrap();
1293
1294 assert_eq!(out.matches("### `shared.rs`").count(), 1);
1296 assert!(out.contains("f1"));
1298 assert!(out.contains("f2"));
1299 assert!(out.contains("1 file(s) included via --impact-scope"));
1301 }
1302
1303 #[test]
1304 fn impact_per_finding_emits_one_section_each_with_metadata() {
1305 let tmp = tempfile::tempdir().unwrap();
1306 std::fs::write(tmp.path().join("a.rs"), "fn a() {}\n").unwrap();
1307 std::fs::write(tmp.path().join("b.rs"), "fn b() {}\n").unwrap();
1308 let scrubber = Scrubber::empty();
1309
1310 let fa = Finding {
1311 id: Some("f-aaa".into()),
1312 primary_path: std::path::PathBuf::from("a.rs"),
1313 kind: Some("trait_impl".into()),
1314 confidence: Some(0.95),
1315 severity: Some("high".into()),
1316 tier: Some("likely".into()),
1317 evidence: Some("Trait change affects downstream callers".into()),
1318 suggested_action: Some("cargo nextest run -E 'test(a)'".into()),
1319 };
1320 let fb = Finding {
1321 id: Some("f-bbb".into()),
1322 primary_path: std::path::PathBuf::from("b.rs"),
1323 kind: None,
1324 confidence: None,
1325 severity: None,
1326 tier: None,
1327 evidence: None,
1328 suggested_action: None,
1329 };
1330
1331 let (name_a, body_a) = try_collect_per_finding(tmp.path(), &fa, 0, &scrubber).unwrap();
1332 assert_eq!(
1333 name_a,
1334 "π Impact: f-aaa (trait_impl, high/likely, conf=0.95)"
1335 );
1336 assert!(body_a.contains("**Evidence:** Trait change"));
1337 assert!(body_a.contains("**Suggested action:** `cargo nextest run"));
1338 assert!(body_a.contains("fn a() {}"));
1339
1340 let (name_b, body_b) = try_collect_per_finding(tmp.path(), &fb, 1, &scrubber).unwrap();
1341 assert_eq!(name_b, "π Impact: f-bbb");
1343 assert!(!body_b.contains("**Evidence:**"));
1344 assert!(body_b.contains("fn b() {}"));
1345 }
1346
1347 #[test]
1348 fn impact_per_finding_falls_back_to_positional_label_when_id_missing() {
1349 let tmp = tempfile::tempdir().unwrap();
1350 std::fs::write(tmp.path().join("x.rs"), "fn x() {}\n").unwrap();
1351 let scrubber = Scrubber::empty();
1352 let f = Finding {
1353 id: None,
1354 primary_path: std::path::PathBuf::from("x.rs"),
1355 kind: None,
1356 confidence: None,
1357 severity: None,
1358 tier: None,
1359 evidence: None,
1360 suggested_action: None,
1361 };
1362 let (name, _) = try_collect_per_finding(tmp.path(), &f, 3, &scrubber).unwrap();
1363 assert_eq!(name, "π Impact: finding-4");
1364 }
1365
1366 #[test]
1367 fn impact_per_finding_skips_missing_file() {
1368 let tmp = tempfile::tempdir().unwrap();
1369 let scrubber = Scrubber::empty();
1370 let f = Finding {
1371 id: Some("f-gone".into()),
1372 primary_path: std::path::PathBuf::from("does_not_exist.rs"),
1373 kind: None,
1374 confidence: None,
1375 severity: None,
1376 tier: None,
1377 evidence: None,
1378 suggested_action: None,
1379 };
1380 assert!(try_collect_per_finding(tmp.path(), &f, 0, &scrubber).is_none());
1381 }
1382
1383 #[test]
1384 fn impact_findings_take_precedence_over_files_from_in_builder() {
1385 let tmp = tempfile::tempdir().unwrap();
1386 std::fs::write(tmp.path().join("from_finding.rs"), "fn ff() {}\n").unwrap();
1387 std::fs::write(tmp.path().join("from_files_list.rs"), "fn fl() {}\n").unwrap();
1388
1389 let pack = PackBuilder::new()
1390 .project_root(tmp.path())
1391 .files_from(vec![std::path::PathBuf::from("from_files_list.rs")])
1392 .impact_findings(vec![Finding {
1393 id: Some("f-only".into()),
1394 primary_path: std::path::PathBuf::from("from_finding.rs"),
1395 kind: None,
1396 confidence: Some(0.9),
1397 severity: None,
1398 tier: None,
1399 evidence: None,
1400 suggested_action: None,
1401 }])
1402 .build()
1403 .unwrap();
1404
1405 let scoped = pack
1406 .sections
1407 .iter()
1408 .find(|s| s.name == "π Scoped Files")
1409 .expect("Scoped Files section emitted");
1410 assert!(
1411 scoped.content.contains("from_finding.rs"),
1412 "impact findings should drive the Scoped Files section:\n{}",
1413 scoped.content
1414 );
1415 assert!(
1416 !scoped.content.contains("from_files_list.rs"),
1417 "files_from should be superseded by impact_findings"
1418 );
1419 }
1420
1421 #[test]
1422 fn lang_for_path_maps_common_extensions() {
1423 let cases = [
1424 ("a.rs", "rust"),
1425 ("b.toml", "toml"),
1426 ("c.yaml", "yaml"),
1427 ("d.yml", "yaml"),
1428 ("e.json", "json"),
1429 ("f.md", "markdown"),
1430 ("g.unknown", ""),
1431 ("noext", ""),
1432 ];
1433 for (file, expected) in cases {
1434 assert_eq!(lang_for_path(Path::new(file)), expected, "for {file}");
1435 }
1436 }
1437}