Skip to main content

cargo_context_core/
pack.rs

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    /// Repo-relative paths whose full contents should be embedded in a
189    /// dedicated "πŸ“‚ Scoped Files" section. Designed for the
190    /// `cargo impact --context | cargo context --files-from -` workflow:
191    /// the upstream tool tells us which files matter, and we include them
192    /// verbatim (subject to scrubbing and budget).
193    ///
194    /// These paths also join the diff-changed set when the related-tests
195    /// collector runs, so a test that references a scoped file by stem
196    /// gets surfaced even when the scoped file isn't in `git diff`.
197    pub fn files_from(mut self, paths: Vec<std::path::PathBuf>) -> Self {
198        self.files_from = paths;
199        self
200    }
201
202    /// Assemble the pack.
203    ///
204    /// Flow:
205    /// 1. Collect candidate sections per preset (collectors that fail for
206    ///    expected reasons return `None` β€” a missing git repo or Cargo.toml
207    ///    never aborts the build).
208    /// 2. Run the scrubber over each section's content. Scrubbing may shrink
209    ///    or grow content, so we re-count tokens *after* it.
210    /// 3. Reconcile with the token budget using the configured strategy.
211    ///    Dropped section names are surfaced on `Pack.dropped`.
212    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        // Build the scrubber once, up front. The diff renderer consults it
219        // for path-level `redact_whole` rules; the content scrubber pass
220        // (below) reuses the same instance.
221        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        // Collect diagnostics as structured data so the diff renderer can
239        // prioritize files with primary error spans.
240        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        // Collect the diff once; share its paths with the tests collector.
261        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        // Scoped files from `--files-from` (e.g. `cargo impact --context`).
297        // Slotted at the diff priority: these are explicit "here is what
298        // matters" signals and deserve to survive budget pressure on par
299        // with the actual diff.
300        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            // Union of diff-changed and files-from for related-tests linkage.
311            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            // Honor `report.log_file` if set in scrub.yaml.
343            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    // Auto mode: only expand when the diff touches a file with proc-macro
412    // attributes. For the initial pass, treat any diff with .rs files as
413    // meeting the threshold; a smarter heuristic can come later.
414    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
459/// Read each repo-relative path under `root`, scrub it, and emit a fenced
460/// block with a sensible language hint. Missing paths and directories are
461/// silently skipped so that an upstream tool emitting a stale path list
462/// doesn't break pack generation.
463fn 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
629/// Render a diff with files containing primary error spans ordered first.
630/// Under budget pressure, earlier sections survive truncation, so this
631/// keeps the most signal-dense files when the budget forces a cut.
632///
633/// The `scrubber` argument consults `redact_whole` globs: files that match
634/// render with their hunks elided and a `[REDACTED FILE: ...]` marker in
635/// their place β€” keeping the fact-of-change visible without leaking the
636/// contents (e.g. for `.env` or `.pem` files that showed up in the diff).
637fn 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        // Files with errors first (0), then by alphabetical path.
648        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
715/// Compiler diagnostics and git diff often use different path anchors
716/// (relative to the crate vs. relative to the workspace). Matching by
717/// suffix is a pragmatic bridge.
718fn 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()) // force empty collection
782            .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        // A clean workspace with no diff and no errors produces an empty pack β€”
791        // that is a valid result, not an error.
792        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        // The broken file should render before the unrelated ones.
843        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        // No warning marker when no errors are present.
882        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        // .env's hunk body is elided; lib.rs's isn't.
932        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}