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::scrub::Scrubber;
10use crate::tokenize::Tokenizer;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "lowercase")]
14pub enum Preset {
15 Fix,
16 Feature,
17 Custom,
18}
19
20#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "lowercase")]
22pub enum Format {
23 #[default]
24 Markdown,
25 Xml,
26 Json,
27 Plain,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct Section {
32 pub name: String,
33 pub content: String,
34 pub token_estimate: usize,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct Pack {
39 pub schema: String,
40 pub project: String,
41 pub sections: Vec<Section>,
42 pub tokens_used: usize,
43 pub tokens_budget: usize,
44 pub tokenizer: String,
45 pub dropped: Vec<String>,
46 #[serde(default)]
47 pub scrub: crate::scrub::ScrubReport,
48}
49
50impl Pack {
51 pub fn render(&self, format: Format) -> Result<String> {
52 match format {
53 Format::Markdown => Ok(self.render_markdown()),
54 Format::Xml => Ok(self.render_xml()),
55 Format::Json => self.render_json(),
56 Format::Plain => Ok(self.render_plain()),
57 }
58 }
59
60 pub fn render_markdown(&self) -> String {
61 let mut out = String::new();
62 out.push_str(&format!("# PROJECT CONTEXT PACK: {}\n", self.project));
63 let dropped = if self.dropped.is_empty() {
64 String::new()
65 } else {
66 format!(" | dropped: {}", self.dropped.join(", "))
67 };
68 out.push_str(&format!(
69 "<!-- schema: {} | tokens: {}/{} | tokenizer: {}{} -->\n\n",
70 self.schema, self.tokens_used, self.tokens_budget, self.tokenizer, dropped
71 ));
72 for s in &self.sections {
73 out.push_str(&format!("## {}\n\n{}\n\n", s.name, s.content));
74 }
75 out
76 }
77
78 pub fn render_xml(&self) -> String {
79 let mut out = String::new();
80 out.push_str(&format!(
81 "<pack schema=\"{}\" project=\"{}\" tokens=\"{}/{}\" tokenizer=\"{}\">\n",
82 self.schema, self.project, self.tokens_used, self.tokens_budget, self.tokenizer
83 ));
84 for s in &self.sections {
85 out.push_str(&format!(
86 " <section name=\"{}\" tokens=\"{}\">\n{}\n </section>\n",
87 s.name, s.token_estimate, s.content
88 ));
89 }
90 out.push_str("</pack>\n");
91 out
92 }
93
94 pub fn render_plain(&self) -> String {
95 self.sections
96 .iter()
97 .map(|s| s.content.as_str())
98 .collect::<Vec<_>>()
99 .join("\n\n")
100 }
101
102 pub fn render_json(&self) -> Result<String> {
103 Ok(serde_json::to_string_pretty(self)?)
104 }
105}
106
107#[derive(Debug, Clone)]
108pub struct PackBuilder {
109 preset: Preset,
110 budget: Budget,
111 tokenizer: Tokenizer,
112 scrub: bool,
113 expand_mode: ExpandMode,
114 include_paths: Vec<String>,
115 exclude_paths: Vec<String>,
116 project_root: Option<std::path::PathBuf>,
117 stdin_prompt: Option<String>,
118 files_from: Vec<std::path::PathBuf>,
119}
120
121impl Default for PackBuilder {
122 fn default() -> Self {
123 Self {
124 preset: Preset::Custom,
125 budget: Budget::default(),
126 tokenizer: Tokenizer::Llama3,
127 scrub: true,
128 expand_mode: ExpandMode::default(),
129 include_paths: Vec::new(),
130 exclude_paths: Vec::new(),
131 project_root: None,
132 stdin_prompt: None,
133 files_from: Vec::new(),
134 }
135 }
136}
137
138impl PackBuilder {
139 pub fn new() -> Self {
140 Self::default()
141 }
142
143 pub fn preset(mut self, p: Preset) -> Self {
144 self.preset = p;
145 self
146 }
147 pub fn budget(mut self, b: Budget) -> Self {
148 self.budget = b;
149 self
150 }
151 pub fn max_tokens(mut self, n: usize) -> Self {
152 self.budget.max_tokens = n;
153 self
154 }
155 pub fn reserve_tokens(mut self, n: usize) -> Self {
156 self.budget.reserve_tokens = n;
157 self
158 }
159 pub fn tokenizer(mut self, t: Tokenizer) -> Self {
160 self.tokenizer = t;
161 self
162 }
163 pub fn scrub(mut self, on: bool) -> Self {
164 self.scrub = on;
165 self
166 }
167 pub fn expand_mode(mut self, m: ExpandMode) -> Self {
168 self.expand_mode = m;
169 self
170 }
171 pub fn include_path(mut self, path: impl Into<String>) -> Self {
172 self.include_paths.push(path.into());
173 self
174 }
175 pub fn exclude_path(mut self, path: impl Into<String>) -> Self {
176 self.exclude_paths.push(path.into());
177 self
178 }
179 pub fn project_root(mut self, p: impl Into<std::path::PathBuf>) -> Self {
180 self.project_root = Some(p.into());
181 self
182 }
183 pub fn stdin_prompt(mut self, prompt: impl Into<String>) -> Self {
184 self.stdin_prompt = Some(prompt.into());
185 self
186 }
187
188 pub fn files_from(mut self, paths: Vec<std::path::PathBuf>) -> Self {
198 self.files_from = paths;
199 self
200 }
201
202 pub fn build(self) -> Result<Pack> {
213 let root = self
214 .project_root
215 .clone()
216 .unwrap_or_else(|| std::path::PathBuf::from("."));
217
218 let scrubber = if self.scrub {
222 Scrubber::with_workspace(&root)?
223 } else {
224 Scrubber::empty()
225 };
226
227 let mut candidates: Vec<(Priority, Section)> = Vec::new();
228
229 if let Some(prompt) = &self.stdin_prompt {
230 candidates.push((
231 P_EXEMPT,
232 mk_section("π User Prompt", prompt, &self.tokenizer),
233 ));
234 }
235
236 let wants = SectionWants::for_preset(self.preset);
237
238 let diagnostics = if wants.errors {
241 collect::last_error(&root).ok()
242 } else {
243 None
244 };
245 let error_files: Vec<std::path::PathBuf> = diagnostics
246 .as_ref()
247 .map(|d| d.referenced_files())
248 .unwrap_or_default();
249
250 if let Some(d) = diagnostics.as_ref()
251 && !d.is_empty()
252 {
253 let content = render_diagnostics(d);
254 candidates.push((
255 P_ERROR,
256 mk_section("π¨ Current State (Errors)", &content, &self.tokenizer),
257 ));
258 }
259
260 let diff = if wants.diff || wants.tests {
262 collect::git_diff(&root, None).ok()
263 } else {
264 None
265 };
266
267 if wants.diff
268 && let Some(d) = diff.as_ref()
269 && !d.is_empty()
270 {
271 candidates.push((
272 P_DIFF,
273 mk_section(
274 "β‘ Intent (Git Diff)",
275 &render_diff_ordered(d, &error_files, &scrubber),
276 &self.tokenizer,
277 ),
278 ));
279 }
280 if wants.map
281 && let Some(content) = try_collect_map(&root)
282 {
283 candidates.push((
284 P_MAP,
285 mk_section("πΊοΈ Project Map", &content, &self.tokenizer),
286 ));
287 }
288 if wants.entry
289 && let Some(content) = try_collect_entry(&root)
290 {
291 candidates.push((
292 P_ENTRY,
293 mk_section("π§ Entry Points", &content, &self.tokenizer),
294 ));
295 }
296 if !self.files_from.is_empty()
301 && let Some(content) = try_collect_scoped(&root, &self.files_from, &scrubber)
302 {
303 candidates.push((
304 P_DIFF,
305 mk_section("π Scoped Files", &content, &self.tokenizer),
306 ));
307 }
308
309 if wants.tests {
310 let mut changed: Vec<std::path::PathBuf> = diff
312 .as_ref()
313 .map(|d| d.files.iter().map(|f| f.path.clone()).collect())
314 .unwrap_or_default();
315 changed.extend(self.files_from.iter().cloned());
316 if !changed.is_empty()
317 && let Some(content) = try_collect_tests(&root, &changed)
318 {
319 candidates.push((
320 P_TESTS,
321 mk_section("π― Related Tests", &content, &self.tokenizer),
322 ));
323 }
324 }
325 if self.expand_mode != ExpandMode::Off
326 && let Some(content) = try_collect_expansion(&root, self.expand_mode, diff.as_ref())
327 {
328 candidates.push((
329 P_ENTRY,
330 mk_section("π Expanded Macros", &content, &self.tokenizer),
331 ));
332 }
333
334 let mut scrub_report = crate::scrub::ScrubReport::default();
335 if self.scrub {
336 for (_, s) in candidates.iter_mut() {
337 let (scrubbed, report) = scrubber.scrub_with_report(&s.content);
338 s.content = scrubbed;
339 s.token_estimate = self.tokenizer.count(&s.content);
340 scrub_report.redactions.extend(report.redactions);
341 }
342 scrubber.log_redactions(&scrub_report)?;
344 }
345
346 let alloc = budget::allocate(candidates, &self.budget, &self.tokenizer);
347
348 Ok(Pack {
349 schema: "cargo-context/v1".into(),
350 project: project_name(self.project_root.as_deref()),
351 sections: alloc.kept,
352 tokens_used: alloc.tokens_used,
353 tokens_budget: alloc.tokens_budget,
354 tokenizer: self.tokenizer.label().into(),
355 dropped: alloc.dropped,
356 scrub: scrub_report,
357 })
358 }
359}
360
361#[derive(Debug, Clone, Copy)]
362struct SectionWants {
363 map: bool,
364 errors: bool,
365 diff: bool,
366 entry: bool,
367 tests: bool,
368}
369
370impl SectionWants {
371 fn for_preset(p: Preset) -> Self {
372 match p {
373 Preset::Fix => Self {
374 map: false,
375 errors: true,
376 diff: true,
377 entry: false,
378 tests: true,
379 },
380 Preset::Feature => Self {
381 map: true,
382 errors: false,
383 diff: true,
384 entry: true,
385 tests: true,
386 },
387 Preset::Custom => Self {
388 map: true,
389 errors: false,
390 diff: true,
391 entry: true,
392 tests: true,
393 },
394 }
395 }
396}
397
398fn try_collect_map(root: &Path) -> Option<String> {
399 collect::cargo_metadata(root).ok().map(render_map)
400}
401
402fn try_collect_expansion(root: &Path, mode: ExpandMode, diff: Option<&Diff>) -> Option<String> {
403 if matches!(mode, ExpandMode::Off) {
404 return None;
405 }
406 if !expand::expand_available() {
407 return None;
408 }
409 let meta = collect::cargo_metadata(root).ok()?;
410
411 if matches!(mode, ExpandMode::Auto) {
415 let has_rust = diff
416 .map(|d| {
417 d.files
418 .iter()
419 .any(|f| f.path.extension().and_then(|e| e.to_str()) == Some("rs"))
420 })
421 .unwrap_or(false);
422 if !has_rust {
423 return None;
424 }
425 }
426
427 let mut out = String::new();
428 let mut expanded_any = false;
429 for member in &meta.members {
430 let dir = match member.manifest_path.parent() {
431 Some(d) => d,
432 None => continue,
433 };
434 let lib = dir.join("src/lib.rs");
435 let main = dir.join("src/main.rs");
436 let target = if lib.exists() {
437 lib
438 } else if main.exists() {
439 main
440 } else {
441 continue;
442 };
443 match expand::expand_file(&meta.workspace_root, &member.name, &target) {
444 Ok(Some(text)) => {
445 out.push_str(&format!(
446 "### `{}` β {} (expanded)\n```rust\n{}\n```\n\n",
447 target.display(),
448 member.name,
449 text.trim_end()
450 ));
451 expanded_any = true;
452 }
453 Ok(None) | Err(_) => continue,
454 }
455 }
456 if expanded_any { Some(out) } else { None }
457}
458
459fn try_collect_scoped(
464 root: &Path,
465 paths: &[std::path::PathBuf],
466 scrubber: &Scrubber,
467) -> Option<String> {
468 let mut body = String::new();
469 let mut included = 0_usize;
470 let mut skipped = 0_usize;
471
472 for rel in paths {
473 let abs = if rel.is_absolute() {
474 rel.clone()
475 } else {
476 root.join(rel)
477 };
478 if !abs.is_file() {
479 skipped += 1;
480 continue;
481 }
482 let raw = match std::fs::read_to_string(&abs) {
483 Ok(s) => s,
484 Err(_) => {
485 skipped += 1;
486 continue;
487 }
488 };
489 let (content, _report) = scrubber.scrub_file(rel, &raw);
490 let lang = lang_for_path(rel);
491 body.push_str(&format!(
492 "### `{}`\n```{lang}\n{}\n```\n\n",
493 rel.display(),
494 content.trim_end()
495 ));
496 included += 1;
497 }
498
499 if included == 0 {
500 return None;
501 }
502
503 let mut header = format!("{included} file(s) included via --files-from");
504 if skipped > 0 {
505 header.push_str(&format!(
506 " ({skipped} listed path(s) skipped: missing, not a regular file, or unreadable)"
507 ));
508 }
509 header.push_str(".\n\n");
510 Some(format!("{header}{body}"))
511}
512
513fn lang_for_path(path: &Path) -> &'static str {
514 match path.extension().and_then(|e| e.to_str()) {
515 Some("rs") => "rust",
516 Some("toml") => "toml",
517 Some("yaml" | "yml") => "yaml",
518 Some("json") => "json",
519 Some("md") => "markdown",
520 Some("sh" | "bash") => "bash",
521 Some("py") => "python",
522 Some("ts") => "typescript",
523 Some("js") => "javascript",
524 _ => "",
525 }
526}
527
528fn try_collect_tests(root: &Path, changed: &[std::path::PathBuf]) -> Option<String> {
529 let rt = collect::related_tests(root, changed).ok()?;
530 if rt.is_empty() {
531 None
532 } else {
533 Some(render_tests(&rt))
534 }
535}
536
537fn try_collect_entry(root: &Path) -> Option<String> {
538 let ep = collect::entry_points(root).ok()?;
539 if ep.is_empty() {
540 None
541 } else {
542 Some(render_entry(&ep))
543 }
544}
545
546fn render_tests(rt: &RelatedTests) -> String {
547 let mut out = String::new();
548 out.push_str(&format!("{} related test file(s).\n\n", rt.files.len()));
549 for f in &rt.files {
550 let kind = match f.kind {
551 collect::TestKind::Integration => "integration",
552 collect::TestKind::UnitInline => "unit (inline)",
553 };
554 let reason = if f.matched_stems.is_empty() {
555 String::new()
556 } else {
557 format!(" β matched: `{}`", f.matched_stems.join("`, `"))
558 };
559 out.push_str(&format!(
560 "### `{}` β {} / {} ({} tests){}\n",
561 f.path.display(),
562 f.crate_name,
563 kind,
564 f.functions.len(),
565 reason,
566 ));
567 for fun in &f.functions {
568 out.push_str(&format!("- `{}`\n", fun.signature.trim()));
569 }
570 out.push('\n');
571 }
572 out
573}
574
575fn render_entry(ep: &EntryPoints) -> String {
576 let mut out = String::new();
577 out.push_str(&format!("{} entry file(s).\n\n", ep.files.len()));
578 for f in &ep.files {
579 let kind = match f.kind {
580 collect::EntryKind::Main => "main",
581 collect::EntryKind::Lib => "lib",
582 };
583 let tag = if f.parse_failed { " (unparsed)" } else { "" };
584 out.push_str(&format!(
585 "### `{}` β {} / {} ({} lines){}\n",
586 f.path.display(),
587 f.crate_name,
588 kind,
589 f.raw_line_count,
590 tag,
591 ));
592 out.push_str("```rust\n");
593 out.push_str(&f.rendered);
594 if !f.rendered.ends_with('\n') {
595 out.push('\n');
596 }
597 out.push_str("```\n\n");
598 }
599 out
600}
601
602fn render_map(m: WorkspaceMap) -> String {
603 let mut out = String::new();
604 if let Some(root) = &m.root_package {
605 out.push_str(&format!("- Root package: `{root}`\n"));
606 }
607 let members = m.member_names();
608 if !members.is_empty() {
609 out.push_str(&format!("- Workspace members ({}): ", members.len()));
610 out.push_str(&members.join(", "));
611 out.push('\n');
612 }
613 let deps = m.external_dep_names();
614 if !deps.is_empty() {
615 let preview: Vec<&str> = deps.iter().take(12).copied().collect();
616 out.push_str(&format!(
617 "- Key dependencies: {}{}\n",
618 preview.join(", "),
619 if deps.len() > preview.len() {
620 format!(" (+{} more)", deps.len() - preview.len())
621 } else {
622 String::new()
623 }
624 ));
625 }
626 out
627}
628
629fn render_diff_ordered(
638 d: &Diff,
639 error_files: &[std::path::PathBuf],
640 scrubber: &Scrubber,
641) -> String {
642 let error_set: std::collections::HashSet<&Path> =
643 error_files.iter().map(|p| p.as_path()).collect();
644
645 let mut files: Vec<&collect::FileDiff> = d.files.iter().collect();
646 files.sort_by_key(|f| {
647 let has_error = error_set.contains(f.path.as_path())
649 || error_files.iter().any(|e| path_matches_suffix(&f.path, e));
650 (!has_error, f.path.to_string_lossy().into_owned())
651 });
652
653 let errored_count = files
654 .iter()
655 .filter(|f| {
656 error_set.contains(f.path.as_path())
657 || error_files.iter().any(|e| path_matches_suffix(&f.path, e))
658 })
659 .count();
660 let path_redacted_count = files
661 .iter()
662 .filter(|f| scrubber.is_path_redacted(&f.path))
663 .count();
664
665 let mut out = String::new();
666 let mut header = format!("{} file(s) changed", d.files.len());
667 if errored_count > 0 {
668 header.push_str(&format!(
669 "; {errored_count} touched by compiler errors (shown first)"
670 ));
671 }
672 if path_redacted_count > 0 {
673 header.push_str(&format!("; {path_redacted_count} redacted by path rules"));
674 }
675 out.push_str(&format!("{header}.\n\n"));
676
677 for f in files {
678 let status = format!("{:?}", f.status).to_lowercase();
679 let error_marker = if error_set.contains(f.path.as_path())
680 || error_files.iter().any(|e| path_matches_suffix(&f.path, e))
681 {
682 " β "
683 } else {
684 ""
685 };
686 let redacted = scrubber.is_path_redacted(&f.path);
687 let redact_marker = if redacted { " π" } else { "" };
688
689 out.push_str(&format!(
690 "### `{}` β {status}{error_marker}{redact_marker}\n",
691 f.path.display()
692 ));
693 if let Some(old) = &f.old_path {
694 out.push_str(&format!("- Renamed from `{}`\n", old.display()));
695 }
696 if redacted {
697 out.push_str(&format!(
698 "[REDACTED FILE: {} β {} hunk(s) elided by scrub.yaml path rules]\n",
699 f.path.display(),
700 f.hunks.len()
701 ));
702 } else {
703 for h in &f.hunks {
704 out.push_str(&format!(
705 "```diff\n@@ -{},{} +{},{} @@\n{}```\n",
706 h.old_start, h.old_lines, h.new_start, h.new_lines, h.body
707 ));
708 }
709 }
710 out.push('\n');
711 }
712 out
713}
714
715fn path_matches_suffix(haystack: &Path, needle: &Path) -> bool {
719 let h = haystack.to_string_lossy();
720 let n = needle.to_string_lossy();
721 h.ends_with(n.as_ref()) || n.ends_with(h.as_ref())
722}
723
724fn render_diagnostics(d: &Diagnostics) -> String {
725 let mut out = String::new();
726 let err_count = d
727 .diagnostics
728 .iter()
729 .filter(|x| x.level == crate::collect::DiagLevel::Error)
730 .count();
731 out.push_str(&format!(
732 "Build {}; {} diagnostic(s), {} error(s).\n\n",
733 if d.success { "succeeded" } else { "failed" },
734 d.diagnostics.len(),
735 err_count,
736 ));
737 for diag in &d.diagnostics {
738 let code = diag.code.as_deref().unwrap_or("");
739 out.push_str(&format!(
740 "- **{:?}** {}: {}\n",
741 diag.level, code, diag.message
742 ));
743 if let Some(file) = diag.primary_file()
744 && let Some(span) = diag.spans.iter().find(|s| s.is_primary)
745 {
746 out.push_str(&format!(
747 " at `{}:{}:{}`\n",
748 file.display(),
749 span.line_start,
750 span.col_start
751 ));
752 }
753 }
754 out
755}
756
757fn mk_section(name: &str, content: &str, tokenizer: &Tokenizer) -> Section {
758 Section {
759 name: name.into(),
760 content: content.into(),
761 token_estimate: tokenizer.count(content),
762 }
763}
764
765fn project_name(root: Option<&std::path::Path>) -> String {
766 root.and_then(|p| p.file_name())
767 .map(|n| n.to_string_lossy().into_owned())
768 .unwrap_or_else(|| "unknown".to_string())
769}
770
771#[cfg(test)]
772mod tests {
773 use super::*;
774
775 #[test]
776 fn builder_includes_prompt_section() {
777 let pack = PackBuilder::new()
778 .preset(Preset::Fix)
779 .max_tokens(4000)
780 .stdin_prompt("why does this fail?")
781 .project_root(std::env::temp_dir()) .build()
783 .expect("build pack");
784 assert_eq!(pack.schema, "cargo-context/v1");
785 assert!(pack.sections.iter().any(|s| s.name.contains("Prompt")));
786 }
787
788 #[test]
789 fn builder_empty_workspace_is_valid() {
790 let pack = PackBuilder::new()
793 .preset(Preset::Fix)
794 .project_root(std::env::temp_dir())
795 .build()
796 .expect("build pack");
797 assert_eq!(pack.schema, "cargo-context/v1");
798 }
799
800 #[test]
801 fn json_roundtrip() {
802 let pack = PackBuilder::new()
803 .project_root(std::env::temp_dir())
804 .build()
805 .unwrap();
806 let s = pack.render_json().unwrap();
807 let _: Pack = serde_json::from_str(&s).unwrap();
808 }
809
810 #[test]
811 fn render_diff_puts_error_files_first() {
812 use crate::collect::{Diff, FileDiff, FileStatus};
813 let d = Diff {
814 range: None,
815 files: vec![
816 FileDiff {
817 path: std::path::PathBuf::from("src/unrelated.rs"),
818 old_path: None,
819 status: FileStatus::Modified,
820 hunks: Vec::new(),
821 binary: false,
822 },
823 FileDiff {
824 path: std::path::PathBuf::from("src/broken.rs"),
825 old_path: None,
826 status: FileStatus::Modified,
827 hunks: Vec::new(),
828 binary: false,
829 },
830 FileDiff {
831 path: std::path::PathBuf::from("src/also_clean.rs"),
832 old_path: None,
833 status: FileStatus::Modified,
834 hunks: Vec::new(),
835 binary: false,
836 },
837 ],
838 };
839 let errors = vec![std::path::PathBuf::from("src/broken.rs")];
840 let scrubber = Scrubber::empty();
841 let rendered = render_diff_ordered(&d, &errors, &scrubber);
842 let broken_pos = rendered.find("broken.rs").unwrap();
844 let unrelated_pos = rendered.find("unrelated.rs").unwrap();
845 let clean_pos = rendered.find("also_clean.rs").unwrap();
846 assert!(
847 broken_pos < unrelated_pos && broken_pos < clean_pos,
848 "error-touched file should render first; got:\n{rendered}"
849 );
850 assert!(
851 rendered.contains('β '),
852 "expected warning marker on errored file"
853 );
854 }
855
856 #[test]
857 fn render_diff_no_errors_falls_back_to_alpha_order() {
858 use crate::collect::{Diff, FileDiff, FileStatus};
859 let d = Diff {
860 range: None,
861 files: vec![
862 FileDiff {
863 path: std::path::PathBuf::from("b.rs"),
864 old_path: None,
865 status: FileStatus::Modified,
866 hunks: Vec::new(),
867 binary: false,
868 },
869 FileDiff {
870 path: std::path::PathBuf::from("a.rs"),
871 old_path: None,
872 status: FileStatus::Modified,
873 hunks: Vec::new(),
874 binary: false,
875 },
876 ],
877 };
878 let scrubber = Scrubber::empty();
879 let rendered = render_diff_ordered(&d, &[], &scrubber);
880 assert!(rendered.find("a.rs").unwrap() < rendered.find("b.rs").unwrap());
881 assert!(!rendered.contains('β '));
883 }
884
885 #[test]
886 fn render_diff_path_rules_redact_matching_files() {
887 use crate::collect::{Diff, FileDiff, FileStatus};
888 use crate::scrub::ScrubConfig;
889
890 let d = Diff {
891 range: None,
892 files: vec![
893 FileDiff {
894 path: std::path::PathBuf::from("src/lib.rs"),
895 old_path: None,
896 status: FileStatus::Modified,
897 hunks: vec![crate::collect::DiffHunk {
898 old_start: 1,
899 old_lines: 1,
900 new_start: 1,
901 new_lines: 1,
902 body: "-old\n+new\n".into(),
903 }],
904 binary: false,
905 },
906 FileDiff {
907 path: std::path::PathBuf::from(".env"),
908 old_path: None,
909 status: FileStatus::Modified,
910 hunks: vec![crate::collect::DiffHunk {
911 old_start: 1,
912 old_lines: 1,
913 new_start: 1,
914 new_lines: 1,
915 body: "-SECRET=old\n+SECRET=new\n".into(),
916 }],
917 binary: false,
918 },
919 ],
920 };
921 let config = ScrubConfig {
922 paths: crate::scrub::paths::PathRulesRaw {
923 redact_whole: vec!["**/.env".into()],
924 exclude: vec![],
925 },
926 ..Default::default()
927 };
928 let scrubber = Scrubber::from_config(&config).unwrap();
929 let rendered = render_diff_ordered(&d, &[], &scrubber);
930
931 assert!(
933 !rendered.contains("SECRET=new"),
934 "redacted hunk content leaked into diff render: {rendered}"
935 );
936 assert!(rendered.contains("[REDACTED FILE: .env"));
937 assert!(
938 rendered.contains("+new"),
939 "non-redacted file should still render normally"
940 );
941 assert!(
942 rendered.contains('π'),
943 "redacted file should carry lock marker"
944 );
945 assert!(
946 rendered.contains("1 redacted by path rules"),
947 "header should report path-redacted count; got:\n{rendered}"
948 );
949 }
950
951 #[test]
952 fn try_collect_scoped_includes_real_files_and_skips_missing() {
953 let tmp = tempfile::tempdir().unwrap();
954 let real = tmp.path().join("real.rs");
955 std::fs::write(&real, "fn answer() -> u8 { 42 }\n").unwrap();
956 let scrubber = Scrubber::empty();
957
958 let paths = vec![
959 std::path::PathBuf::from("real.rs"),
960 std::path::PathBuf::from("does_not_exist.rs"),
961 ];
962 let out = try_collect_scoped(tmp.path(), &paths, &scrubber)
963 .expect("at least one real file β Some");
964
965 assert!(out.contains("real.rs"));
966 assert!(out.contains("fn answer"));
967 assert!(out.contains("1 file(s) included via --files-from"));
968 assert!(
969 out.contains("1 listed path(s) skipped"),
970 "missing path should bump the skipped counter; got:\n{out}"
971 );
972 }
973
974 #[test]
975 fn try_collect_scoped_returns_none_when_all_missing() {
976 let tmp = tempfile::tempdir().unwrap();
977 let scrubber = Scrubber::empty();
978 let paths = vec![
979 std::path::PathBuf::from("nope1.rs"),
980 std::path::PathBuf::from("nope2.rs"),
981 ];
982 assert!(try_collect_scoped(tmp.path(), &paths, &scrubber).is_none());
983 }
984
985 #[test]
986 fn try_collect_scoped_applies_path_redaction() {
987 use crate::scrub::ScrubConfig;
988 let tmp = tempfile::tempdir().unwrap();
989 let env_file = tmp.path().join(".env");
990 std::fs::write(&env_file, "DB_PASSWORD=hunter2\n").unwrap();
991
992 let config = ScrubConfig {
993 paths: crate::scrub::paths::PathRulesRaw {
994 redact_whole: vec!["**/.env".into()],
995 exclude: vec![],
996 },
997 ..Default::default()
998 };
999 let scrubber = Scrubber::from_config(&config).unwrap();
1000
1001 let paths = vec![std::path::PathBuf::from(".env")];
1002 let out = try_collect_scoped(tmp.path(), &paths, &scrubber).unwrap();
1003 assert!(
1004 !out.contains("hunter2"),
1005 "redacted file content leaked: {out}"
1006 );
1007 assert!(out.contains("[REDACTED FILE:"));
1008 }
1009
1010 #[test]
1011 fn lang_for_path_maps_common_extensions() {
1012 let cases = [
1013 ("a.rs", "rust"),
1014 ("b.toml", "toml"),
1015 ("c.yaml", "yaml"),
1016 ("d.yml", "yaml"),
1017 ("e.json", "json"),
1018 ("f.md", "markdown"),
1019 ("g.unknown", ""),
1020 ("noext", ""),
1021 ];
1022 for (file, expected) in cases {
1023 assert_eq!(lang_for_path(Path::new(file)), expected, "for {file}");
1024 }
1025 }
1026}