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::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    /// Repo-relative paths whose full contents should be embedded in a
194    /// dedicated "πŸ“‚ Scoped Files" section. Designed for the
195    /// `cargo impact --context | cargo context --files-from -` workflow:
196    /// the upstream tool tells us which files matter, and we include them
197    /// verbatim (subject to scrubbing and budget).
198    ///
199    /// These paths also join the diff-changed set when the related-tests
200    /// collector runs, so a test that references a scoped file by stem
201    /// gets surfaced even when the scoped file isn't in `git diff`.
202    pub fn files_from(mut self, paths: Vec<std::path::PathBuf>) -> Self {
203        self.files_from = paths;
204        self
205    }
206
207    /// Findings pulled from a `cargo-impact --format=json` envelope.
208    ///
209    /// Differs from [`Self::files_from`] in that each finding carries
210    /// metadata (id, kind, confidence, severity, tier, evidence,
211    /// suggested action) that can drive richer rendering:
212    ///
213    /// - In the default aggregated mode, the list is rendered as a single
214    ///   "πŸ“‚ Scoped Files" section with files ordered by the caller-supplied
215    ///   order (typically confidence-descending) and per-file headers that
216    ///   surface confidence/severity/tier.
217    /// - When [`Self::impact_per_finding`] is set, each finding becomes its
218    ///   own "πŸ“‚ Impact: …" section β€” useful when an agent wants to iterate
219    ///   through findings one at a time.
220    ///
221    /// Finding paths join the diff-changed set for related-tests linkage,
222    /// mirroring `files_from`.
223    pub fn impact_findings(mut self, findings: Vec<Finding>) -> Self {
224        self.impact_findings = findings;
225        self
226    }
227
228    /// Emit one pack section per finding instead of one aggregated
229    /// section. No-op when `impact_findings` is empty.
230    pub fn impact_per_finding(mut self, on: bool) -> Self {
231        self.impact_per_finding = on;
232        self
233    }
234
235    /// Assemble the pack.
236    ///
237    /// Flow:
238    /// 1. Collect candidate sections per preset (collectors that fail for
239    ///    expected reasons return `None` β€” a missing git repo or Cargo.toml
240    ///    never aborts the build).
241    /// 2. Run the scrubber over each section's content. Scrubbing may shrink
242    ///    or grow content, so we re-count tokens *after* it.
243    /// 3. Reconcile with the token budget using the configured strategy.
244    ///    Dropped section names are surfaced on `Pack.dropped`.
245    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        // Build the scrubber once, up front. The diff renderer consults it
252        // for path-level `redact_whole` rules; the content scrubber pass
253        // (below) reuses the same instance.
254        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        // Collect diagnostics as structured data so the diff renderer can
272        // prioritize files with primary error spans.
273        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        // Collect the diff once; share its paths with the tests collector.
294        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        // Scoped files from `--files-from` or `--impact-scope`. Slotted at
330        // the diff priority: explicit "here is what matters" signals
331        // deserve to survive budget pressure on par with the actual diff.
332        //
333        // impact_findings takes precedence when both are provided β€”
334        // findings carry richer metadata (confidence, kind) that drives
335        // header/ordering logic.
336        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            // Union of diff-changed, files-from, and impact-finding paths
362            // for related-tests linkage.
363            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            // Honor `report.log_file` if set in scrub.yaml.
396            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    // Auto mode: only expand when the diff touches a file with proc-macro
465    // attributes. For the initial pass, treat any diff with .rs files as
466    // meeting the threshold; a smarter heuristic can come later.
467    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
512/// Read each repo-relative path under `root`, scrub it, and emit a fenced
513/// block with a sensible language hint. Missing paths and directories are
514/// silently skipped so that an upstream tool emitting a stale path list
515/// doesn't break pack generation.
516fn 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
566/// Aggregated impact-scope rendering: one Scoped Files section with
567/// per-file headers showing the finding's confidence/severity/tier and
568/// kind-aware language hints. Findings are rendered in the order passed
569/// in (the CLI sorts by confidence desc before building), deduped by
570/// path so a file referenced by many findings only renders once β€” with
571/// the other findings summarized in its header.
572fn 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        // Gather every finding that names this path β€” the first drove
607        // inclusion, the rest get summarized on the same header.
608        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
637/// Per-finding rendering: each finding becomes a standalone section. The
638/// caller slots these at `P_DIFF` just like the aggregated form, so
639/// low-confidence findings still benefit from budget pressure β€” the
640/// allocate step drops the tail when the pack overflows.
641fn 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
685/// Build a per-file header for the aggregated Scoped Files section. When
686/// several findings name the same path, list each finding's
687/// id+descriptor so the reader can still correlate file content with
688/// analyzer hits.
689fn 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
826/// Render a diff with files containing primary error spans ordered first.
827/// Under budget pressure, earlier sections survive truncation, so this
828/// keeps the most signal-dense files when the budget forces a cut.
829///
830/// The `scrubber` argument consults `redact_whole` globs: files that match
831/// render with their hunks elided and a `[REDACTED FILE: ...]` marker in
832/// their place β€” keeping the fact-of-change visible without leaking the
833/// contents (e.g. for `.env` or `.pem` files that showed up in the diff).
834fn 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        // Files with errors first (0), then by alphabetical path.
845        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
912/// Compiler diagnostics and git diff often use different path anchors
913/// (relative to the crate vs. relative to the workspace). Matching by
914/// suffix is a pragmatic bridge.
915fn 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()) // force empty collection
979            .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        // A clean workspace with no diff and no errors produces an empty pack β€”
988        // that is a valid result, not an error.
989        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        // The broken file should render before the unrelated ones.
1040        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        // No warning marker when no errors are present.
1079        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        // .env's hunk body is elided; lib.rs's isn't.
1129        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        // cold.rs intentionally missing to exercise the skipped counter.
1213
1214        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        // Higher-confidence file comes first in the caller-ordered list.
1250        assert!(
1251            out.find("hot.rs").unwrap() < out.find("warm.rs").unwrap(),
1252            "hot.rs should render before warm.rs:\n{out}"
1253        );
1254        // Per-file header surfaces id + descriptor.
1255        assert!(out.contains("f-hot: trait_impl, high/likely, conf=0.95"));
1256        // Missing finding is counted, not fatal.
1257        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        // Single rendered file block.
1295        assert_eq!(out.matches("### `shared.rs`").count(), 1);
1296        // Both finding ids land in the shared header.
1297        assert!(out.contains("f1"));
1298        assert!(out.contains("f2"));
1299        // Header reports 1 file even though 2 findings fed it.
1300        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        // No id would fall back to finding-N, but fb has an id.
1342        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}