use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use globset::{Glob, GlobSet, GlobSetBuilder};
use serde::{Deserialize, Serialize};
use crate::budget::{
self, Budget, BudgetDecision, BudgetStrategy, P_DIFF, P_ENTRY, P_ERROR, P_EXEMPT, P_MAP,
P_TESTS, Priority,
};
use crate::collect::{self, Diff};
use crate::error::{Error, Result};
use crate::expand::{self, ExpandMode};
use crate::impact::Finding;
use crate::options::PackOptions;
use crate::scrub::{ScrubReport, Scrubber};
use crate::tokenize::Tokenizer;
pub mod impact;
pub mod render;
pub(crate) use render::lang_for_path;
use render::{
mk_section, project_name, render_diagnostics, render_diff_ordered, render_entry, render_map,
render_tests,
};
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Preset {
Fix,
Feature,
#[default]
Custom,
}
impl Preset {
fn label(self) -> &'static str {
match self {
Self::Fix => "fix",
Self::Feature => "feature",
Self::Custom => "custom",
}
}
}
impl BudgetStrategy {
fn label(self) -> &'static str {
match self {
Self::Priority => "priority",
Self::Proportional => "proportional",
Self::Truncate => "truncate",
}
}
}
impl DiffSource {
fn label(&self) -> &str {
match self {
Self::WorkingTree => "working-tree",
Self::Range(range) => range.as_str(),
}
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Format {
#[default]
Markdown,
Xml,
Json,
Plain,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Section {
pub name: String,
pub content: String,
pub token_estimate: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Pack {
pub schema: String,
pub project: String,
pub sections: Vec<Section>,
pub tokens_used: usize,
pub tokens_budget: usize,
pub tokenizer: String,
pub dropped: Vec<String>,
#[serde(default)]
pub manifest: PackManifest,
#[serde(default)]
pub scrub: crate::scrub::ScrubReport,
}
impl Pack {
pub fn render(&self, format: Format) -> Result<String> {
match format {
Format::Markdown => Ok(self.render_markdown()),
Format::Xml => Ok(self.render_xml()),
Format::Json => self.render_json(),
Format::Plain => Ok(self.render_plain()),
}
}
pub fn render_markdown(&self) -> String {
let mut out = String::new();
out.push_str(&format!("# PROJECT CONTEXT PACK: {}\n", self.project));
let dropped = if self.dropped.is_empty() {
String::new()
} else {
format!(" | dropped: {}", self.dropped.join(", "))
};
out.push_str(&format!(
"<!-- schema: {} | tokens: {}/{} | tokenizer: {}{} -->\n\n",
self.schema, self.tokens_used, self.tokens_budget, self.tokenizer, dropped
));
out.push_str("## 🧾 Context Manifest\n\n");
out.push_str(&self.manifest.render_markdown());
out.push_str("\n\n");
for s in &self.sections {
out.push_str(&format!("## {}\n\n{}\n\n", s.name, s.content));
}
out
}
pub fn render_xml(&self) -> String {
let mut out = String::new();
out.push_str(&format!(
"<pack schema=\"{}\" project=\"{}\" tokens=\"{}/{}\" tokenizer=\"{}\">\n",
self.schema, self.project, self.tokens_used, self.tokens_budget, self.tokenizer
));
out.push_str(" <manifest>\n");
out.push_str(&self.manifest.render_plain_indented(" "));
out.push_str(" </manifest>\n");
for s in &self.sections {
out.push_str(&format!(
" <section name=\"{}\" tokens=\"{}\">\n{}\n </section>\n",
s.name, s.token_estimate, s.content
));
}
out.push_str("</pack>\n");
out
}
pub fn render_plain(&self) -> String {
let mut parts = vec![format!(
"Context Manifest\n{}",
self.manifest.render_markdown()
)];
parts.extend(
self.sections
.iter()
.map(|s| s.content.as_str())
.map(str::to_string),
);
parts.join("\n\n")
}
pub fn render_json(&self) -> Result<String> {
Ok(serde_json::to_string_pretty(self)?)
}
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct PackManifest {
pub preset: Preset,
pub diff_source: DiffSource,
pub collectors: Vec<String>,
pub path_filters: PathFilterManifest,
pub files: Vec<FileProvenance>,
pub budget: BudgetManifest,
pub scrub: ScrubManifest,
}
impl PackManifest {
fn render_markdown(&self) -> String {
let mut out = String::new();
out.push_str(&format!("- Preset: `{}`\n", self.preset.label()));
out.push_str(&format!("- Diff source: `{}`\n", self.diff_source.label()));
if !self.collectors.is_empty() {
out.push_str(&format!(
"- Collectors: `{}`\n",
self.collectors.join("`, `")
));
}
out.push_str(&format!(
"- Budget: `{}` strategy, {}/{} tokens",
self.budget.strategy.label(),
self.budget.tokens_used,
self.budget.tokens_budget
));
if self.budget.manifest_tokens > 0 {
out.push_str(&format!(" (manifest: {})", self.budget.manifest_tokens));
}
let changed = self
.budget
.decisions
.iter()
.filter(|d| !matches!(d.outcome, budget::BudgetOutcome::Kept))
.count();
if changed > 0 {
out.push_str(&format!(", {changed} section decision(s)"));
}
out.push('\n');
out.push_str(&format!(
"- Scrub: {} redaction(s){}\n",
self.scrub.redactions,
if self.scrub.enabled {
""
} else {
" (disabled)"
}
));
if !self.path_filters.include.is_empty() {
out.push_str(&format!(
"- Include filters: `{}`\n",
self.path_filters.include.join("`, `")
));
}
if !self.path_filters.exclude.is_empty() {
out.push_str(&format!(
"- Exclude filters: `{}`\n",
self.path_filters.exclude.join("`, `")
));
}
let included = self
.files
.iter()
.filter(|f| matches!(f.status, FileProvenanceStatus::Included))
.count();
let suppressed = self
.files
.iter()
.filter(|f| matches!(f.status, FileProvenanceStatus::Suppressed))
.count();
if included > 0 || suppressed > 0 {
out.push_str(&format!(
"- Files: {included} included, {suppressed} suppressed\n"
));
}
out
}
fn render_plain_indented(&self, indent: &str) -> String {
self.render_markdown()
.lines()
.map(|line| format!("{indent}{line}\n"))
.collect()
}
fn scrub_strings(&mut self, scrubber: &Scrubber) -> ScrubReport {
let mut report = ScrubReport::default();
if let DiffSource::Range(range) = &mut self.diff_source {
scrub_string(range, scrubber, &mut report);
}
scrub_strings(&mut self.collectors, scrubber, &mut report);
scrub_strings(&mut self.path_filters.include, scrubber, &mut report);
scrub_strings(&mut self.path_filters.exclude, scrubber, &mut report);
scrub_strings(
&mut self.path_filters.unmatched_include,
scrubber,
&mut report,
);
for file in &mut self.files {
scrub_string(&mut file.path, scrubber, &mut report);
scrub_strings(&mut file.sources, scrubber, &mut report);
if let Some(reason) = &mut file.reason {
scrub_string(reason, scrubber, &mut report);
}
}
for decision in &mut self.budget.decisions {
scrub_string(&mut decision.section, scrubber, &mut report);
if let Some(reason) = &mut decision.reason {
scrub_string(reason, scrubber, &mut report);
}
}
scrub_string(&mut self.scrub.summary, scrubber, &mut report);
report
}
}
fn scrub_strings(values: &mut [String], scrubber: &Scrubber, report: &mut ScrubReport) {
for value in values {
scrub_string(value, scrubber, report);
}
}
fn scrub_string(value: &mut String, scrubber: &Scrubber, report: &mut ScrubReport) {
let (scrubbed, string_report) = scrubber.scrub_with_report(value);
*value = scrubbed;
report.redactions.extend(string_report.redactions);
}
fn manifest_token_estimate(manifest: &PackManifest, tokenizer: &Tokenizer) -> usize {
let rendered_tokens = tokenizer.count(&manifest.render_markdown());
let json_tokens = serde_json::to_string(manifest)
.map(|json| tokenizer.count(&json))
.unwrap_or(rendered_tokens);
rendered_tokens.max(json_tokens)
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct PathFilterManifest {
pub include: Vec<String>,
pub exclude: Vec<String>,
pub unmatched_include: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileProvenance {
pub path: String,
pub sources: Vec<String>,
pub status: FileProvenanceStatus,
pub reason: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FileProvenanceStatus {
Included,
Suppressed,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct BudgetManifest {
pub max_tokens: usize,
pub reserve_tokens: usize,
pub tokens_budget: usize,
pub tokens_used: usize,
#[serde(default)]
pub manifest_tokens: usize,
pub strategy: BudgetStrategy,
pub decisions: Vec<BudgetDecision>,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct ScrubManifest {
pub enabled: bool,
pub redactions: usize,
pub summary: String,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", content = "range", rename_all = "snake_case")]
pub enum DiffSource {
#[default]
WorkingTree,
Range(String),
}
#[derive(Debug, Clone)]
pub struct PackBuilder {
preset: Preset,
budget: Budget,
tokenizer: Tokenizer,
scrub: bool,
expand_mode: ExpandMode,
include_paths: Vec<String>,
exclude_paths: Vec<String>,
diff_range: Option<String>,
project_root: Option<std::path::PathBuf>,
stdin_prompt: Option<String>,
files_from: Vec<std::path::PathBuf>,
impact_findings: Vec<Finding>,
impact_per_finding: bool,
}
impl Default for PackBuilder {
fn default() -> Self {
Self {
preset: Preset::Custom,
budget: Budget::default(),
tokenizer: Tokenizer::Llama3,
scrub: true,
expand_mode: ExpandMode::default(),
include_paths: Vec::new(),
exclude_paths: Vec::new(),
diff_range: None,
project_root: None,
stdin_prompt: None,
files_from: Vec::new(),
impact_findings: Vec::new(),
impact_per_finding: false,
}
}
}
impl PackBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn from_options(options: PackOptions) -> Self {
Self::new().options(options)
}
pub fn options(mut self, options: PackOptions) -> Self {
self.preset = options.preset;
self.budget = options.budget;
self.tokenizer = options.tokenizer;
self.scrub = options.scrub;
self.expand_mode = options.expand_mode;
self.include_paths = options.include_paths;
self.exclude_paths = options.exclude_paths;
self.diff_range = options.diff_range;
self.project_root = options.project_root;
self.stdin_prompt = options.stdin_prompt;
self.files_from = options.files_from;
self.impact_findings = options.impact_findings;
self.impact_per_finding = options.impact_per_finding;
self
}
pub fn preset(mut self, p: Preset) -> Self {
self.preset = p;
self
}
pub fn budget(mut self, b: Budget) -> Self {
self.budget = b;
self
}
pub fn max_tokens(mut self, n: usize) -> Self {
self.budget.max_tokens = n;
self
}
pub fn reserve_tokens(mut self, n: usize) -> Self {
self.budget.reserve_tokens = n;
self
}
pub fn tokenizer(mut self, t: Tokenizer) -> Self {
self.tokenizer = t;
self
}
pub fn scrub(mut self, on: bool) -> Self {
self.scrub = on;
self
}
pub fn expand_mode(mut self, m: ExpandMode) -> Self {
self.expand_mode = m;
self
}
pub fn include_path(mut self, path: impl Into<String>) -> Self {
self.include_paths.push(path.into());
self
}
pub fn exclude_path(mut self, path: impl Into<String>) -> Self {
self.exclude_paths.push(path.into());
self
}
pub fn diff_range(mut self, range: impl Into<String>) -> Self {
self.diff_range = Some(range.into());
self
}
pub fn project_root(mut self, p: impl Into<std::path::PathBuf>) -> Self {
self.project_root = Some(p.into());
self
}
pub fn stdin_prompt(mut self, prompt: impl Into<String>) -> Self {
self.stdin_prompt = Some(prompt.into());
self
}
pub fn files_from(mut self, paths: Vec<std::path::PathBuf>) -> Self {
self.files_from = paths;
self
}
pub fn impact_findings(mut self, findings: Vec<Finding>) -> Self {
self.impact_findings = findings;
self
}
pub fn impact_per_finding(mut self, on: bool) -> Self {
self.impact_per_finding = on;
self
}
pub fn build(self) -> Result<Pack> {
let root = self
.project_root
.clone()
.unwrap_or_else(|| std::path::PathBuf::from("."));
let scrubber = if self.scrub {
Scrubber::with_workspace(&root)?
} else {
Scrubber::empty()
};
let mut scrub_report = ScrubReport::default();
let mut candidates: Vec<(Priority, Section)> = Vec::new();
if let Some(prompt) = &self.stdin_prompt {
candidates.push((
P_EXEMPT,
mk_section("📝 User Prompt", prompt, &self.tokenizer),
));
}
let wants = SectionWants::for_preset(self.preset);
let path_filters = UserPathFilters::new(&self.include_paths, &self.exclude_paths)?;
let mut filter_report = ScopeFilterReport::new(&path_filters);
let mut provenance = ProvenanceBuilder::new(&root);
let diagnostics = if wants.errors {
collect::last_error(&root).ok()
} else {
None
};
let error_files: Vec<std::path::PathBuf> = diagnostics
.as_ref()
.map(|d| d.referenced_files())
.unwrap_or_default();
for path in &error_files {
provenance.record(path, "compiler_error", &path_filters);
}
let visible_error_files: Vec<std::path::PathBuf> = error_files
.iter()
.filter(|p| path_filters.allows(p))
.cloned()
.collect();
if let Some(d) = diagnostics.as_ref()
&& !d.is_empty()
{
let content = render_diagnostics(d);
candidates.push((
P_ERROR,
mk_section("🚨 Current State (Errors)", &content, &self.tokenizer),
));
}
let diff = if wants.diff || wants.tests {
collect::git_diff(&root, self.diff_range.as_deref())
.ok()
.map(|d| {
for f in &d.files {
provenance.record(&f.path, "diff", &path_filters);
}
filter_report.diff_files_excluded += d
.files
.iter()
.filter(|f| !path_filters.allows(&f.path))
.count();
path_filters.filter_diff(d)
})
} else {
None
};
if wants.diff
&& let Some(d) = diff.as_ref()
&& !d.is_empty()
{
candidates.push((
P_DIFF,
mk_section(
"⚡ Intent (Git Diff)",
&render_diff_ordered(d, &visible_error_files, &scrubber),
&self.tokenizer,
),
));
}
if wants.map
&& let Some(content) = try_collect_map(&root)
{
candidates.push((
P_MAP,
mk_section("🗺️ Project Map", &content, &self.tokenizer),
));
}
if wants.entry
&& let Some((content, paths)) = try_collect_entry(&root, &path_filters)
{
for path in paths {
provenance.record_included(&path, "entry_point");
}
candidates.push((
P_ENTRY,
mk_section("🧭 Entry Points", &content, &self.tokenizer),
));
}
filter_report.scoped_files_excluded += self
.files_from
.iter()
.filter(|p| !path_filters.allows(p))
.count();
for path in &self.files_from {
provenance.record(path, "files_from", &path_filters);
}
let files_from = path_filters.filter_paths(self.files_from);
filter_report.impact_findings_excluded += self
.impact_findings
.iter()
.filter(|f| !path_filters.allows(&f.primary_path))
.count();
for finding in &self.impact_findings {
let source = finding
.id
.as_ref()
.map(|id| format!("impact_scope:{id}"))
.unwrap_or_else(|| "impact_scope".to_string());
provenance.record(&finding.primary_path, &source, &path_filters);
}
let impact_findings = path_filters.filter_findings(self.impact_findings);
let force_include = path_filters.force_include_paths(&root);
filter_report.include_patterns_unmatched = force_include.unmatched_patterns;
filter_report.include_paths_excluded = force_include.excluded_matches;
let force_include_paths = force_include.paths;
for path in &force_include_paths {
provenance.record_included(path, "include_path");
}
if !impact_findings.is_empty() {
if self.impact_per_finding {
for (idx, f) in impact_findings.iter().enumerate() {
if let Some((name, collected)) =
impact::try_collect_per_finding(&root, f, idx, &scrubber)
{
scrub_report.redactions.extend(collected.report.redactions);
candidates.push((
P_DIFF,
mk_section(&name, &collected.content, &self.tokenizer),
));
}
}
} else if let Some(collected) =
impact::try_collect_scoped_findings(&root, &impact_findings, &scrubber)
{
scrub_report.redactions.extend(collected.report.redactions);
candidates.push((
P_DIFF,
mk_section("📂 Scoped Files", &collected.content, &self.tokenizer),
));
}
} else if !files_from.is_empty()
&& let Some(collected) = impact::try_collect_scoped(&root, &files_from, &scrubber)
{
scrub_report.redactions.extend(collected.report.redactions);
candidates.push((
P_DIFF,
mk_section("📂 Scoped Files", &collected.content, &self.tokenizer),
));
}
if !force_include_paths.is_empty()
&& let Some(collected) =
impact::try_collect_scoped(&root, &force_include_paths, &scrubber)
{
scrub_report.redactions.extend(collected.report.redactions);
candidates.push((
P_DIFF,
mk_section("📌 Included Paths", &collected.content, &self.tokenizer),
));
}
if wants.tests {
let mut changed: Vec<std::path::PathBuf> = diff
.as_ref()
.map(|d| d.files.iter().map(|f| f.path.clone()).collect())
.unwrap_or_default();
changed.extend(files_from.iter().cloned());
changed.extend(impact_findings.iter().map(|f| f.primary_path.clone()));
changed.extend(force_include_paths.iter().cloned());
if !changed.is_empty()
&& let Some((content, paths)) = try_collect_tests(&root, &changed)
{
for path in paths {
provenance.record(&path, "related_test", &path_filters);
}
candidates.push((
P_TESTS,
mk_section("🎯 Related Tests", &content, &self.tokenizer),
));
}
}
if self.expand_mode != ExpandMode::Off
&& let Some((content, paths)) =
try_collect_expansion(&root, self.expand_mode, diff.as_ref(), &path_filters)
{
for path in paths {
provenance.record_included(&path, "macro_expansion");
}
candidates.push((
P_ENTRY,
mk_section("🔍 Expanded Macros", &content, &self.tokenizer),
));
}
if let Some(content) = filter_report.render() {
candidates.push((
P_MAP,
mk_section("🔎 Scope Filters", &content, &self.tokenizer),
));
}
if self.scrub {
for (_, s) in candidates.iter_mut() {
let (scrubbed_name, name_report) = scrubber.scrub_with_report(&s.name);
s.name = scrubbed_name;
scrub_report.redactions.extend(name_report.redactions);
let (scrubbed_content, content_report) = scrubber.scrub_with_report(&s.content);
s.content = scrubbed_content;
s.token_estimate = self.tokenizer.count(&s.content);
scrub_report.redactions.extend(content_report.redactions);
}
}
let total_budget = self.budget.effective();
let diff_source = self
.diff_range
.clone()
.map(DiffSource::Range)
.unwrap_or(DiffSource::WorkingTree);
let collectors = wants.labels();
let path_filter_manifest = PathFilterManifest {
include: path_filters.include_paths.clone(),
exclude: path_filters.exclude_paths.clone(),
unmatched_include: filter_report.include_patterns_unmatched.clone(),
};
let files = provenance.finish();
let preliminary_manifest = PackManifest {
preset: self.preset,
diff_source: diff_source.clone(),
collectors: collectors.clone(),
path_filters: path_filter_manifest.clone(),
files: files.clone(),
budget: BudgetManifest {
max_tokens: self.budget.max_tokens,
reserve_tokens: self.budget.reserve_tokens,
tokens_budget: total_budget,
tokens_used: 0,
manifest_tokens: 0,
strategy: self.budget.strategy,
decisions: Vec::new(),
},
scrub: ScrubManifest {
enabled: self.scrub,
redactions: scrub_report.redactions.len(),
summary: scrub_report.summary(),
},
};
let manifest_reserve = manifest_token_estimate(&preliminary_manifest, &self.tokenizer);
let section_budget = Budget {
max_tokens: total_budget.saturating_sub(manifest_reserve),
reserve_tokens: 0,
strategy: self.budget.strategy,
};
let alloc = budget::allocate(candidates, §ion_budget, &self.tokenizer);
let mut project = project_name(self.project_root.as_deref());
if self.scrub {
let (scrubbed_project, project_report) = scrubber.scrub_with_report(&project);
project = scrubbed_project;
scrub_report.redactions.extend(project_report.redactions);
}
let mut manifest = PackManifest {
preset: self.preset,
diff_source,
collectors,
path_filters: path_filter_manifest,
files,
budget: BudgetManifest {
max_tokens: self.budget.max_tokens,
reserve_tokens: self.budget.reserve_tokens,
tokens_budget: total_budget,
tokens_used: alloc.tokens_used,
manifest_tokens: manifest_reserve,
strategy: self.budget.strategy,
decisions: alloc.decisions.clone(),
},
scrub: ScrubManifest {
enabled: self.scrub,
redactions: scrub_report.redactions.len(),
summary: scrub_report.summary(),
},
};
if self.scrub {
let manifest_report = manifest.scrub_strings(&scrubber);
scrub_report.redactions.extend(manifest_report.redactions);
}
let manifest_tokens = manifest_token_estimate(&manifest, &self.tokenizer);
let tokens_used = alloc.tokens_used + manifest_tokens;
manifest.budget.tokens_used = tokens_used;
manifest.budget.manifest_tokens = manifest_tokens;
manifest.scrub.redactions = scrub_report.redactions.len();
manifest.scrub.summary = scrub_report.summary();
if self.scrub {
scrubber.log_redactions(&scrub_report)?;
}
Ok(Pack {
schema: "cargo-context/v1".into(),
project,
sections: alloc.kept,
tokens_used,
tokens_budget: total_budget,
tokenizer: self.tokenizer.label().into(),
dropped: alloc.dropped,
manifest,
scrub: scrub_report,
})
}
}
#[derive(Debug, Clone, Copy)]
struct SectionWants {
map: bool,
errors: bool,
diff: bool,
entry: bool,
tests: bool,
}
impl SectionWants {
fn for_preset(p: Preset) -> Self {
match p {
Preset::Fix => Self {
map: false,
errors: true,
diff: true,
entry: false,
tests: true,
},
Preset::Feature => Self {
map: true,
errors: false,
diff: true,
entry: true,
tests: true,
},
Preset::Custom => Self {
map: true,
errors: false,
diff: true,
entry: true,
tests: true,
},
}
}
fn labels(self) -> Vec<String> {
let mut out = Vec::new();
if self.errors {
out.push("errors".into());
}
if self.diff {
out.push("diff".into());
}
if self.map {
out.push("map".into());
}
if self.entry {
out.push("entry".into());
}
if self.tests {
out.push("tests".into());
}
out
}
}
fn try_collect_map(root: &Path) -> Option<String> {
collect::cargo_metadata(root).ok().map(render_map)
}
fn try_collect_expansion(
root: &Path,
mode: ExpandMode,
diff: Option<&Diff>,
path_filters: &UserPathFilters,
) -> Option<(String, Vec<PathBuf>)> {
if matches!(mode, ExpandMode::Off) {
return None;
}
if !expand::expand_available() {
return None;
}
let meta = collect::cargo_metadata(root).ok()?;
if matches!(mode, ExpandMode::Auto) {
let has_rust = diff
.map(|d| {
d.files
.iter()
.any(|f| f.path.extension().and_then(|e| e.to_str()) == Some("rs"))
})
.unwrap_or(false);
if !has_rust {
return None;
}
}
let mut out = String::new();
let mut expanded_any = false;
let mut paths = Vec::new();
for member in &meta.members {
let dir = match member.manifest_path.parent() {
Some(d) => d,
None => continue,
};
let lib = dir.join("src/lib.rs");
let main = dir.join("src/main.rs");
let target = if lib.exists() {
lib
} else if main.exists() {
main
} else {
continue;
};
if !path_filters.allows(&target) {
continue;
}
match expand::expand_file(&meta.workspace_root, &member.name, &target) {
Ok(Some(text)) => {
paths.push(target.clone());
out.push_str(&format!(
"### `{}` — {} (expanded)\n```rust\n{}\n```\n\n",
target.display(),
member.name,
text.trim_end()
));
expanded_any = true;
}
Ok(None) | Err(_) => continue,
}
}
if expanded_any {
Some((out, paths))
} else {
None
}
}
fn try_collect_tests(
root: &Path,
changed: &[std::path::PathBuf],
) -> Option<(String, Vec<PathBuf>)> {
let rt = collect::related_tests(root, changed).ok()?;
if rt.is_empty() {
None
} else {
let paths = rt.files.iter().map(|f| f.path.clone()).collect();
Some((render_tests(&rt), paths))
}
}
fn try_collect_entry(
root: &Path,
path_filters: &UserPathFilters,
) -> Option<(String, Vec<PathBuf>)> {
let mut ep = collect::entry_points(root).ok()?;
ep.files.retain(|f| path_filters.allows(&f.path));
if ep.is_empty() {
None
} else {
let paths = ep.files.iter().map(|f| f.path.clone()).collect();
Some((render_entry(&ep), paths))
}
}
#[derive(Debug)]
struct ProvenanceBuilder {
root: PathBuf,
files: BTreeMap<String, FileTrace>,
}
#[derive(Debug, Default)]
struct FileTrace {
sources: BTreeSet<String>,
status: Option<FileProvenanceStatus>,
reason: Option<String>,
}
impl ProvenanceBuilder {
fn new(root: &Path) -> Self {
Self {
root: root.to_path_buf(),
files: BTreeMap::new(),
}
}
fn record(&mut self, path: &Path, source: &str, filters: &UserPathFilters) {
let path_key = self.path_key(path);
let allowed = filters.allows(path);
let trace = self.files.entry(path_key).or_default();
trace.sources.insert(source.to_string());
if allowed {
trace.status = Some(FileProvenanceStatus::Included);
} else {
trace.status = Some(FileProvenanceStatus::Suppressed);
trace.reason = Some("--exclude-path".into());
}
}
fn record_included(&mut self, path: &Path, source: &str) {
let path_key = self.path_key(path);
let trace = self.files.entry(path_key).or_default();
trace.sources.insert(source.to_string());
trace.status = Some(FileProvenanceStatus::Included);
}
fn path_key(&self, path: &Path) -> String {
path.strip_prefix(&self.root)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/")
}
fn finish(self) -> Vec<FileProvenance> {
self.files
.into_iter()
.map(|(path, trace)| FileProvenance {
path,
sources: trace.sources.into_iter().collect(),
status: trace.status.unwrap_or(FileProvenanceStatus::Included),
reason: trace.reason,
})
.collect()
}
}
#[derive(Debug, Default)]
struct UserPathFilters {
include_paths: Vec<String>,
exclude_paths: Vec<String>,
exclude: Option<GlobSet>,
}
impl UserPathFilters {
fn new(include_paths: &[String], exclude_paths: &[String]) -> Result<Self> {
Ok(Self {
include_paths: include_paths.to_vec(),
exclude_paths: exclude_paths.to_vec(),
exclude: build_globset(exclude_paths)?,
})
}
fn allows(&self, path: &Path) -> bool {
!self.matches_exclude(path)
}
fn matches_exclude(&self, path: &Path) -> bool {
self.exclude
.as_ref()
.map(|gs| gs.is_match(path))
.unwrap_or(false)
}
fn filter_paths(&self, paths: Vec<PathBuf>) -> Vec<PathBuf> {
paths.into_iter().filter(|p| self.allows(p)).collect()
}
fn filter_findings(&self, findings: Vec<Finding>) -> Vec<Finding> {
findings
.into_iter()
.filter(|f| self.allows(&f.primary_path))
.collect()
}
fn filter_diff(&self, mut diff: Diff) -> Diff {
diff.files.retain(|f| self.allows(&f.path));
diff
}
fn force_include_paths(&self, root: &Path) -> ForceIncludeResult {
let mut out = Vec::new();
let mut seen = std::collections::HashSet::new();
let mut unmatched = Vec::new();
let mut excluded_matches = 0_usize;
for pattern in &self.include_paths {
if is_glob_pattern(pattern) {
let Ok(glob) = Glob::new(pattern) else {
continue;
};
let mut builder = GlobSetBuilder::new();
builder.add(glob);
let Ok(set) = builder.build() else {
continue;
};
let before = out.len();
excluded_matches +=
self.collect_matching_files(root, root, &set, &mut seen, &mut out);
if out.len() == before {
unmatched.push(pattern.clone());
}
} else {
let path = PathBuf::from(pattern);
if self.allows(&path) {
if seen.insert(path.clone()) {
out.push(path);
}
} else {
excluded_matches += 1;
}
}
}
ForceIncludeResult {
paths: out,
unmatched_patterns: unmatched,
excluded_matches,
}
}
fn collect_matching_files(
&self,
root: &Path,
dir: &Path,
glob: &GlobSet,
seen: &mut std::collections::HashSet<PathBuf>,
out: &mut Vec<PathBuf>,
) -> usize {
let Ok(entries) = std::fs::read_dir(dir) else {
return 0;
};
let mut excluded = 0_usize;
for entry in entries.flatten() {
let path = entry.path();
let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
continue;
};
if name == ".git" || name == "target" {
continue;
}
if path.is_dir() {
excluded += self.collect_matching_files(root, &path, glob, seen, out);
continue;
}
if !path.is_file() {
continue;
}
let rel = path.strip_prefix(root).unwrap_or(&path).to_path_buf();
if glob.is_match(&rel) {
if self.allows(&rel) {
if seen.insert(rel.clone()) {
out.push(rel);
}
} else {
excluded += 1;
}
}
}
excluded
}
}
#[derive(Debug, Default)]
struct ForceIncludeResult {
paths: Vec<PathBuf>,
unmatched_patterns: Vec<String>,
excluded_matches: usize,
}
#[derive(Debug, Default)]
struct ScopeFilterReport {
include_patterns: Vec<String>,
exclude_patterns: Vec<String>,
diff_files_excluded: usize,
scoped_files_excluded: usize,
impact_findings_excluded: usize,
include_paths_excluded: usize,
include_patterns_unmatched: Vec<String>,
}
impl ScopeFilterReport {
fn new(filters: &UserPathFilters) -> Self {
Self {
include_patterns: filters.include_paths.clone(),
exclude_patterns: filters.exclude_paths.clone(),
..Self::default()
}
}
fn render(&self) -> Option<String> {
if self.include_patterns.is_empty() && self.exclude_patterns.is_empty() {
return None;
}
let mut out = String::new();
if !self.include_patterns.is_empty() {
out.push_str(&format!(
"- Include patterns: `{}`\n",
self.include_patterns.join("`, `")
));
}
if !self.exclude_patterns.is_empty() {
out.push_str(&format!(
"- Exclude patterns: `{}`\n",
self.exclude_patterns.join("`, `")
));
}
let total_excluded = self.diff_files_excluded
+ self.scoped_files_excluded
+ self.impact_findings_excluded
+ self.include_paths_excluded;
if total_excluded > 0 {
out.push_str(&format!(
"- Excluded by `--exclude-path`: {total_excluded} candidate(s)"
));
let details = [
("diff", self.diff_files_excluded),
("scoped", self.scoped_files_excluded),
("impact", self.impact_findings_excluded),
("include", self.include_paths_excluded),
]
.into_iter()
.filter(|(_, count)| *count > 0)
.map(|(label, count)| format!("{label}:{count}"))
.collect::<Vec<_>>();
if !details.is_empty() {
out.push_str(&format!(" ({})", details.join(", ")));
}
out.push('\n');
}
if !self.include_patterns_unmatched.is_empty() {
out.push_str(&format!(
"- Include patterns with no included files: `{}`\n",
self.include_patterns_unmatched.join("`, `")
));
}
Some(out)
}
}
fn is_glob_pattern(pattern: &str) -> bool {
pattern.contains('*') || pattern.contains('?') || pattern.contains('[') || pattern.contains('{')
}
fn build_globset(patterns: &[String]) -> Result<Option<GlobSet>> {
if patterns.is_empty() {
return Ok(None);
}
let mut builder = GlobSetBuilder::new();
for pattern in patterns {
let glob = Glob::new(pattern)
.map_err(|e| Error::Glob(format!("invalid glob `{pattern}`: {e}")))?;
builder.add(glob);
}
builder
.build()
.map(Some)
.map_err(|e| Error::Glob(format!("globset: {e}")))
}
#[cfg(test)]
mod tests {
use super::impact::*;
use super::render::lang_for_path;
use super::*;
#[test]
fn builder_includes_prompt_section() {
let pack = PackBuilder::new()
.preset(Preset::Fix)
.max_tokens(4000)
.stdin_prompt("why does this fail?")
.project_root(std::env::temp_dir())
.build()
.expect("build pack");
assert_eq!(pack.schema, "cargo-context/v1");
assert!(pack.sections.iter().any(|s| s.name.contains("Prompt")));
}
#[test]
fn builder_empty_workspace_is_valid() {
let pack = PackBuilder::new()
.preset(Preset::Fix)
.project_root(std::env::temp_dir())
.build()
.expect("build pack");
assert_eq!(pack.schema, "cargo-context/v1");
}
#[test]
fn user_path_filters_exclude_diff_files() {
let filters = UserPathFilters::new(&[], &["**/secret.rs".to_string()]).unwrap();
let diff = Diff {
range: None,
files: vec![
crate::collect::FileDiff {
path: std::path::PathBuf::from("src/lib.rs"),
old_path: None,
status: crate::collect::FileStatus::Modified,
hunks: Vec::new(),
binary: false,
},
crate::collect::FileDiff {
path: std::path::PathBuf::from("src/secret.rs"),
old_path: None,
status: crate::collect::FileStatus::Modified,
hunks: Vec::new(),
binary: false,
},
],
};
let filtered = filters.filter_diff(diff);
assert_eq!(filtered.files.len(), 1);
assert_eq!(
filtered.files[0].path,
std::path::PathBuf::from("src/lib.rs")
);
}
#[test]
fn include_paths_force_scope_unless_excluded() {
let filters = UserPathFilters::new(
&["src/lib.rs".to_string(), "src/secret.rs".to_string()],
&["**/secret.rs".to_string()],
)
.unwrap();
assert_eq!(
filters.force_include_paths(Path::new(".")).paths,
vec![std::path::PathBuf::from("src/lib.rs")]
);
}
#[test]
fn include_paths_expand_globs_from_root() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join("src")).unwrap();
std::fs::write(tmp.path().join("src/lib.rs"), "").unwrap();
std::fs::write(tmp.path().join("src/secret.rs"), "").unwrap();
let filters =
UserPathFilters::new(&["src/*.rs".to_string()], &["**/secret.rs".to_string()]).unwrap();
assert_eq!(
filters.force_include_paths(tmp.path()).paths,
vec![std::path::PathBuf::from("src/lib.rs")]
);
}
#[test]
fn scope_filter_report_surfaces_excluded_candidates() {
let filters =
UserPathFilters::new(&["src/*.rs".to_string()], &["**/secret.rs".to_string()]).unwrap();
let mut report = ScopeFilterReport::new(&filters);
report.diff_files_excluded = 1;
report.include_paths_excluded = 1;
report.include_patterns_unmatched = vec!["missing/*.rs".to_string()];
let rendered = report.render().unwrap();
assert!(rendered.contains("Exclude patterns"));
assert!(rendered.contains("Excluded by `--exclude-path`: 2 candidate(s)"));
assert!(rendered.contains("diff:1"));
assert!(rendered.contains("include:1"));
assert!(rendered.contains("missing/*.rs"));
}
#[test]
fn json_roundtrip() {
let pack = PackBuilder::new()
.project_root(std::env::temp_dir())
.build()
.unwrap();
let s = pack.render_json().unwrap();
let _: Pack = serde_json::from_str(&s).unwrap();
}
#[test]
fn manifest_records_diff_range_filters_and_budget_decisions() {
let tmp = tempfile::tempdir().unwrap();
let pack = PackBuilder::new()
.preset(Preset::Fix)
.diff_range("HEAD~1..HEAD")
.include_path("src/lib.rs")
.exclude_path("src/secret.rs")
.budget(Budget {
max_tokens: 10,
reserve_tokens: 0,
strategy: BudgetStrategy::Priority,
})
.project_root(tmp.path())
.build()
.unwrap();
assert_eq!(pack.manifest.preset, Preset::Fix);
assert!(matches!(
pack.manifest.diff_source,
DiffSource::Range(ref range) if range == "HEAD~1..HEAD"
));
assert_eq!(pack.manifest.path_filters.include, vec!["src/lib.rs"]);
assert_eq!(pack.manifest.path_filters.exclude, vec!["src/secret.rs"]);
assert_eq!(pack.manifest.budget.strategy, BudgetStrategy::Priority);
assert!(
pack.render_markdown().contains("Context Manifest"),
"markdown should expose the human-readable manifest"
);
}
#[test]
fn manifest_strings_are_scrubbed_before_output() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".cargo-context")).unwrap();
std::fs::write(
tmp.path().join(".cargo-context/scrub.yaml"),
r#"
version: 1
patterns:
- id: secret_path
regex: 'secret-project'
category: path
severity: high
"#,
)
.unwrap();
let pack = PackBuilder::new()
.include_path("src/secret-project.rs")
.diff_range("secret-project..HEAD")
.project_root(tmp.path())
.build()
.unwrap();
let json = pack.render_json().unwrap();
assert!(!json.contains("secret-project"));
assert!(json.contains("<REDACTED:path:"));
assert!(!pack.scrub.is_empty());
assert_eq!(pack.manifest.scrub.redactions, pack.scrub.redactions.len());
}
#[test]
fn scoped_file_path_redactions_are_reported() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".cargo-context")).unwrap();
std::fs::create_dir_all(tmp.path().join("src")).unwrap();
std::fs::write(tmp.path().join("src/secret.rs"), "DB_PASSWORD=hunter2\n").unwrap();
std::fs::write(
tmp.path().join(".cargo-context/scrub.yaml"),
r#"
version: 1
paths:
redact_whole:
- "src/secret.rs"
"#,
)
.unwrap();
let pack = PackBuilder::new()
.files_from(vec![PathBuf::from("src/secret.rs")])
.project_root(tmp.path())
.build()
.unwrap();
let out = pack.render_markdown();
assert!(out.contains("[REDACTED FILE: src/secret.rs]"));
assert!(!out.contains("hunter2"));
assert!(
pack.scrub
.redactions
.iter()
.any(|r| r.rule_id == "path_redact_whole")
);
assert_eq!(pack.manifest.scrub.redactions, pack.scrub.redactions.len());
}
#[test]
fn manifest_tokens_are_counted_in_pack_budget() {
let tmp = tempfile::tempdir().unwrap();
let pack = PackBuilder::new().project_root(tmp.path()).build().unwrap();
assert!(pack.manifest.budget.manifest_tokens > 0);
assert_eq!(pack.tokens_budget, pack.manifest.budget.tokens_budget);
assert_eq!(pack.tokens_used, pack.manifest.budget.tokens_used);
assert!(pack.tokens_used >= pack.manifest.budget.manifest_tokens);
}
#[test]
fn provenance_records_suppressed_and_multi_source_files() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join("src")).unwrap();
std::fs::write(tmp.path().join("src/lib.rs"), "pub fn lib() {}\n").unwrap();
std::fs::write(tmp.path().join("src/secret.rs"), "pub fn secret() {}\n").unwrap();
let findings = vec![Finding {
id: Some("f-lib".into()),
primary_path: PathBuf::from("src/lib.rs"),
kind: None,
confidence: Some(0.9),
severity: None,
tier: None,
evidence: None,
suggested_action: None,
}];
let pack = PackBuilder::new()
.files_from(vec![
PathBuf::from("src/lib.rs"),
PathBuf::from("src/secret.rs"),
])
.impact_findings(findings)
.exclude_path("src/secret.rs")
.project_root(tmp.path())
.build()
.unwrap();
let lib = pack
.manifest
.files
.iter()
.find(|f| f.path == "src/lib.rs")
.expect("lib provenance");
assert_eq!(lib.status, FileProvenanceStatus::Included);
assert!(lib.sources.contains(&"files_from".to_string()));
assert!(lib.sources.contains(&"impact_scope:f-lib".to_string()));
let secret = pack
.manifest
.files
.iter()
.find(|f| f.path == "src/secret.rs")
.expect("secret provenance");
assert_eq!(secret.status, FileProvenanceStatus::Suppressed);
assert_eq!(secret.reason.as_deref(), Some("--exclude-path"));
}
#[test]
fn render_diff_puts_error_files_first() {
use crate::collect::{Diff, FileDiff, FileStatus};
let d = Diff {
range: None,
files: vec![
FileDiff {
path: std::path::PathBuf::from("src/unrelated.rs"),
old_path: None,
status: FileStatus::Modified,
hunks: Vec::new(),
binary: false,
},
FileDiff {
path: std::path::PathBuf::from("src/broken.rs"),
old_path: None,
status: FileStatus::Modified,
hunks: Vec::new(),
binary: false,
},
FileDiff {
path: std::path::PathBuf::from("src/also_clean.rs"),
old_path: None,
status: FileStatus::Modified,
hunks: Vec::new(),
binary: false,
},
],
};
let errors = vec![std::path::PathBuf::from("src/broken.rs")];
let scrubber = Scrubber::empty();
let rendered = render_diff_ordered(&d, &errors, &scrubber);
let broken_pos = rendered.find("broken.rs").unwrap();
let unrelated_pos = rendered.find("unrelated.rs").unwrap();
let clean_pos = rendered.find("also_clean.rs").unwrap();
assert!(
broken_pos < unrelated_pos && broken_pos < clean_pos,
"error-touched file should render first; got:\n{rendered}"
);
assert!(
rendered.contains('⚠'),
"expected warning marker on errored file"
);
}
#[test]
fn render_diff_no_errors_falls_back_to_alpha_order() {
use crate::collect::{Diff, FileDiff, FileStatus};
let d = Diff {
range: None,
files: vec![
FileDiff {
path: std::path::PathBuf::from("b.rs"),
old_path: None,
status: FileStatus::Modified,
hunks: Vec::new(),
binary: false,
},
FileDiff {
path: std::path::PathBuf::from("a.rs"),
old_path: None,
status: FileStatus::Modified,
hunks: Vec::new(),
binary: false,
},
],
};
let scrubber = Scrubber::empty();
let rendered = render_diff_ordered(&d, &[], &scrubber);
assert!(rendered.find("a.rs").unwrap() < rendered.find("b.rs").unwrap());
assert!(!rendered.contains('⚠'));
}
#[test]
fn render_diff_path_rules_redact_matching_files() {
use crate::collect::{Diff, DiffHunk, FileDiff, FileStatus};
use crate::scrub::ScrubConfig;
let d = Diff {
range: None,
files: vec![
FileDiff {
path: std::path::PathBuf::from("src/lib.rs"),
old_path: None,
status: FileStatus::Modified,
hunks: vec![DiffHunk {
old_start: 1,
old_lines: 1,
new_start: 1,
new_lines: 1,
body: "-old\n+new\n".into(),
}],
binary: false,
},
FileDiff {
path: std::path::PathBuf::from(".env"),
old_path: None,
status: FileStatus::Modified,
hunks: vec![DiffHunk {
old_start: 1,
old_lines: 1,
new_start: 1,
new_lines: 1,
body: "-SECRET=old\n+SECRET=new\n".into(),
}],
binary: false,
},
],
};
let config = ScrubConfig {
paths: crate::scrub::paths::PathRulesRaw {
redact_whole: vec!["**/.env".into()],
exclude: vec![],
},
..Default::default()
};
let scrubber = Scrubber::from_config(&config).unwrap();
let rendered = render_diff_ordered(&d, &[], &scrubber);
assert!(
!rendered.contains("SECRET=new"),
"redacted hunk content leaked into diff render: {rendered}"
);
assert!(rendered.contains("[REDACTED FILE: .env"));
assert!(
rendered.contains("+new"),
"non-redacted file should still render normally"
);
assert!(
rendered.contains('🔒'),
"redacted file should carry lock marker"
);
assert!(
rendered.contains("1 redacted by path rules"),
"header should report path-redacted count; got:\n{rendered}"
);
}
#[test]
fn try_collect_scoped_includes_real_files_and_skips_missing() {
let tmp = tempfile::tempdir().unwrap();
let real = tmp.path().join("real.rs");
std::fs::write(&real, "fn answer() -> u8 { 42 }\n").unwrap();
let scrubber = Scrubber::empty();
let paths = vec![
std::path::PathBuf::from("real.rs"),
std::path::PathBuf::from("does_not_exist.rs"),
];
let out = try_collect_scoped(tmp.path(), &paths, &scrubber)
.expect("at least one real file → Some");
assert!(out.contains("real.rs"));
assert!(out.contains("fn answer"));
assert!(out.contains("1 file(s) included via --files-from"));
assert!(
out.contains("1 listed path(s) skipped"),
"missing path should bump the skipped counter; got:\n{out}"
);
}
#[test]
fn try_collect_scoped_returns_none_when_all_missing() {
let tmp = tempfile::tempdir().unwrap();
let scrubber = Scrubber::empty();
let paths = vec![
std::path::PathBuf::from("nope1.rs"),
std::path::PathBuf::from("nope2.rs"),
];
assert!(try_collect_scoped(tmp.path(), &paths, &scrubber).is_none());
}
#[test]
fn try_collect_scoped_applies_path_redaction() {
use crate::scrub::ScrubConfig;
let tmp = tempfile::tempdir().unwrap();
let env_file = tmp.path().join(".env");
std::fs::write(&env_file, "DB_PASSWORD=hunter2\n").unwrap();
let config = ScrubConfig {
paths: crate::scrub::paths::PathRulesRaw {
redact_whole: vec!["**/.env".into()],
exclude: vec![],
},
..Default::default()
};
let scrubber = Scrubber::from_config(&config).unwrap();
let paths = vec![std::path::PathBuf::from(".env")];
let out = try_collect_scoped(tmp.path(), &paths, &scrubber).unwrap();
assert!(
!out.contains("hunter2"),
"redacted file content leaked: {out}"
);
assert!(out.contains("[REDACTED FILE:"));
}
#[test]
fn impact_aggregated_sorts_by_caller_order_and_reports_skipped() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("hot.rs"), "fn hot() {}\n").unwrap();
std::fs::write(tmp.path().join("warm.rs"), "fn warm() {}\n").unwrap();
let scrubber = Scrubber::empty();
let findings = vec![
Finding {
id: Some("f-hot".into()),
primary_path: std::path::PathBuf::from("hot.rs"),
kind: Some("trait_impl".into()),
confidence: Some(0.95),
severity: Some("high".into()),
tier: Some("likely".into()),
evidence: None,
suggested_action: None,
},
Finding {
id: Some("f-warm".into()),
primary_path: std::path::PathBuf::from("warm.rs"),
kind: None,
confidence: Some(0.50),
severity: None,
tier: None,
evidence: None,
suggested_action: None,
},
Finding {
id: Some("f-cold".into()),
primary_path: std::path::PathBuf::from("cold.rs"),
kind: None,
confidence: Some(0.10),
severity: None,
tier: None,
evidence: None,
suggested_action: None,
},
];
let out = try_collect_scoped_findings(tmp.path(), &findings, &scrubber)
.expect("at least one finding resolves");
assert!(
out.find("hot.rs").unwrap() < out.find("warm.rs").unwrap(),
"hot.rs should render before warm.rs:\n{out}"
);
assert!(out.contains("f-hot: trait_impl, high/likely, conf=0.95"));
assert!(
out.contains("1 listed path(s) skipped"),
"expected skipped counter in header: {out}"
);
assert!(out.contains("2 file(s) included via --impact-scope"));
}
#[test]
fn impact_aggregated_dedupes_co_located_findings_into_one_block() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("shared.rs"), "fn shared() {}\n").unwrap();
let scrubber = Scrubber::empty();
let findings = vec![
Finding {
id: Some("f1".into()),
primary_path: std::path::PathBuf::from("shared.rs"),
kind: Some("trait_impl".into()),
confidence: Some(0.9),
severity: None,
tier: None,
evidence: None,
suggested_action: None,
},
Finding {
id: Some("f2".into()),
primary_path: std::path::PathBuf::from("shared.rs"),
kind: Some("dyn_dispatch".into()),
confidence: Some(0.6),
severity: None,
tier: None,
evidence: None,
suggested_action: None,
},
];
let out = try_collect_scoped_findings(tmp.path(), &findings, &scrubber).unwrap();
assert_eq!(out.matches("### `shared.rs`").count(), 1);
assert!(out.contains("f1"));
assert!(out.contains("f2"));
assert!(out.contains("1 file(s) included via --impact-scope"));
}
#[test]
fn impact_per_finding_emits_one_section_each_with_metadata() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("a.rs"), "fn a() {}\n").unwrap();
std::fs::write(tmp.path().join("b.rs"), "fn b() {}\n").unwrap();
let scrubber = Scrubber::empty();
let fa = Finding {
id: Some("f-aaa".into()),
primary_path: std::path::PathBuf::from("a.rs"),
kind: Some("trait_impl".into()),
confidence: Some(0.95),
severity: Some("high".into()),
tier: Some("likely".into()),
evidence: Some("Trait change affects downstream callers".into()),
suggested_action: Some("cargo nextest run -E 'test(a)'".into()),
};
let fb = Finding {
id: Some("f-bbb".into()),
primary_path: std::path::PathBuf::from("b.rs"),
kind: None,
confidence: None,
severity: None,
tier: None,
evidence: None,
suggested_action: None,
};
let (name_a, body_a) = try_collect_per_finding(tmp.path(), &fa, 0, &scrubber).unwrap();
assert_eq!(
name_a,
"📂 Impact: f-aaa (trait_impl, high/likely, conf=0.95)"
);
assert!(body_a.contains("**Evidence:** Trait change"));
assert!(body_a.contains("**Suggested action:** `cargo nextest run"));
assert!(body_a.contains("fn a() {}"));
let (name_b, body_b) = try_collect_per_finding(tmp.path(), &fb, 1, &scrubber).unwrap();
assert_eq!(name_b, "📂 Impact: f-bbb");
assert!(!body_b.contains("**Evidence:**"));
assert!(body_b.contains("fn b() {}"));
}
#[test]
fn impact_per_finding_falls_back_to_positional_label_when_id_missing() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("x.rs"), "fn x() {}\n").unwrap();
let scrubber = Scrubber::empty();
let f = Finding {
id: None,
primary_path: std::path::PathBuf::from("x.rs"),
kind: None,
confidence: None,
severity: None,
tier: None,
evidence: None,
suggested_action: None,
};
let (name, _) = try_collect_per_finding(tmp.path(), &f, 3, &scrubber).unwrap();
assert_eq!(name, "📂 Impact: finding-4");
}
#[test]
fn impact_per_finding_skips_missing_file() {
let tmp = tempfile::tempdir().unwrap();
let scrubber = Scrubber::empty();
let f = Finding {
id: Some("f-gone".into()),
primary_path: std::path::PathBuf::from("does_not_exist.rs"),
kind: None,
confidence: None,
severity: None,
tier: None,
evidence: None,
suggested_action: None,
};
assert!(try_collect_per_finding(tmp.path(), &f, 0, &scrubber).is_none());
}
#[test]
fn impact_findings_take_precedence_over_files_from_in_builder() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("from_finding.rs"), "fn ff() {}\n").unwrap();
std::fs::write(tmp.path().join("from_files_list.rs"), "fn fl() {}\n").unwrap();
let pack = PackBuilder::new()
.project_root(tmp.path())
.files_from(vec![std::path::PathBuf::from("from_files_list.rs")])
.impact_findings(vec![Finding {
id: Some("f-only".into()),
primary_path: std::path::PathBuf::from("from_finding.rs"),
kind: None,
confidence: Some(0.9),
severity: None,
tier: None,
evidence: None,
suggested_action: None,
}])
.build()
.unwrap();
let scoped = pack
.sections
.iter()
.find(|s| s.name == "📂 Scoped Files")
.expect("Scoped Files section emitted");
assert!(
scoped.content.contains("from_finding.rs"),
"impact findings should drive the Scoped Files section:\n{}",
scoped.content
);
assert!(
!scoped.content.contains("from_files_list.rs"),
"files_from should be superseded by impact_findings"
);
}
#[test]
fn lang_for_path_maps_common_extensions() {
let cases = [
("a.rs", "rust"),
("b.toml", "toml"),
("c.yaml", "yaml"),
("d.yml", "yaml"),
("e.json", "json"),
("f.md", "markdown"),
("g.unknown", ""),
("noext", ""),
];
for (file, expected) in cases {
assert_eq!(lang_for_path(Path::new(file)), expected, "for {file}");
}
}
}