mod budget;
mod findings;
mod guardrails;
mod header;
mod hotfiles;
mod json;
mod recommendations;
mod repo_facts;
pub use budget::SectionBreakdown;
pub use json::{AI_CONTEXT_ARTIFACT_VERSION, AI_CONTEXT_JSON_SCHEMA_VERSION, render_json};
use crate::facts::RepoFactsSummary;
use crate::findings::types::Finding;
use crate::output::ai_context::budget::BreakdownSection;
use crate::scan::types::ScanSummary;
use std::path::Path;
use std::str::FromStr;
pub const DEFAULT_TOKEN_BUDGET: usize = 4096;
pub struct AiContextRenderOptions {
pub focus: Option<AiFocusCategory>,
pub budget_tokens: usize,
pub no_header: bool,
pub no_task: bool,
}
impl Default for AiContextRenderOptions {
fn default() -> Self {
Self {
focus: None,
budget_tokens: DEFAULT_TOKEN_BUDGET,
no_header: false,
no_task: false,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AiFocusCategory {
Security,
Architecture,
Quality,
Framework,
All,
}
impl FromStr for AiFocusCategory {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"security" => Ok(Self::Security),
"arch" | "architecture" => Ok(Self::Architecture),
"quality" => Ok(Self::Quality),
"framework" => Ok(Self::Framework),
"all" => Ok(Self::All),
_ => Err(()),
}
}
}
impl AiFocusCategory {
pub(crate) fn matches(&self, category: &crate::findings::types::FindingCategory) -> bool {
use crate::findings::types::FindingCategory;
match self {
AiFocusCategory::All => true,
AiFocusCategory::Security => matches!(category, FindingCategory::Security),
AiFocusCategory::Architecture => matches!(category, FindingCategory::Architecture),
AiFocusCategory::Quality => matches!(
category,
FindingCategory::CodeQuality | FindingCategory::Testing
),
AiFocusCategory::Framework => matches!(category, FindingCategory::Framework),
}
}
fn includes_architecture_context(&self) -> bool {
matches!(self, AiFocusCategory::Architecture | AiFocusCategory::All)
}
}
pub fn render(summary: &ScanSummary, opts: &AiContextRenderOptions) -> String {
let (content, _) = render_internal(summary, None, opts);
content
}
pub fn render_with_breakdown(
summary: &ScanSummary,
opts: &AiContextRenderOptions,
) -> (String, SectionBreakdown) {
render_internal(summary, None, opts)
}
pub fn render_with_facts_summary_and_breakdown(
summary: &ScanSummary,
facts_summary: Option<&RepoFactsSummary>,
opts: &AiContextRenderOptions,
) -> (String, SectionBreakdown) {
render_internal(summary, facts_summary, opts)
}
fn render_internal(
summary: &ScanSummary,
facts_summary: Option<&RepoFactsSummary>,
opts: &AiContextRenderOptions,
) -> (String, SectionBreakdown) {
let budget_chars = opts.budget_tokens * 4;
let findings: Vec<&Finding> = summary
.artifacts
.findings
.iter()
.filter(|f| {
opts.focus
.as_ref()
.is_none_or(|focus| focus.matches(&f.category))
})
.collect();
let mut out = String::new();
let mut sections: Vec<BreakdownSection> = Vec::new();
if !opts.no_header && !opts.no_task {
let pre = out.len();
header::render_task_instruction(&mut out, &findings, summary);
guardrails::render_working_rules(&mut out);
let added = out.len() - pre;
if added > 0 {
sections.push(BreakdownSection {
label: "Task instruction".into(),
tokens: added / 4,
});
}
}
if !opts.no_header {
let pre = out.len();
header::render_header(&mut out, summary, &findings);
sections.push(BreakdownSection {
label: "Header".into(),
tokens: (out.len() - pre) / 4,
});
}
if let Some(facts_summary) = facts_summary {
let pre = out.len();
repo_facts::render_summary(&mut out, facts_summary);
sections.push(BreakdownSection {
label: "Repository Facts".into(),
tokens: (out.len() - pre) / 4,
});
}
let compact = opts.no_header;
let infos = findings::render_findings_by_category(&mut out, &findings, budget_chars, compact);
for info in &infos {
if info.chars_added > 0 {
sections.push(BreakdownSection {
label: info.label.clone(),
tokens: info.chars_added / 4,
});
}
}
if opts
.focus
.as_ref()
.is_none_or(AiFocusCategory::includes_architecture_context)
{
let pre = out.len();
hotfiles::render_hot_files(&mut out, summary);
let added = out.len() - pre;
if added > 0 {
sections.push(BreakdownSection {
label: "Hot Files".into(),
tokens: added / 4,
});
}
}
let pre = out.len();
crate::output::ai_plan::render_plan_section(
&mut out,
summary,
opts.focus.as_ref(),
budget_chars,
);
let plan_added = out.len() - pre;
if plan_added > 0 {
sections.push(BreakdownSection {
label: "Remediation Plan".into(),
tokens: plan_added / 4,
});
}
if !opts.no_header && !opts.no_task {
let pre = out.len();
crate::output::ai_plan::render_verification(&mut out);
let added = out.len() - pre;
if added > 0 {
sections.push(BreakdownSection {
label: "Verify".into(),
tokens: added / 4,
});
}
}
let pre = out.len();
let content_len = out.len();
recommendations::render_footer(
&mut out,
content_len,
opts.budget_tokens,
summary.scan_duration_us,
);
sections.push(BreakdownSection {
label: "Footer".into(),
tokens: (out.len() - pre) / 4,
});
let total_tokens = out.len() / 4;
let shown_findings: usize = infos.iter().map(|i| i.shown).sum();
let hidden_findings = findings.len().saturating_sub(shown_findings);
let breakdown = SectionBreakdown {
sections,
total_tokens,
budget_tokens: opts.budget_tokens,
hidden_findings,
};
(out, breakdown)
}
pub(crate) fn project_name(summary: &ScanSummary) -> String {
path_name(&summary.root_path)
.or_else(|| {
(summary.root_path == Path::new("."))
.then(|| std::env::current_dir().ok())
.flatten()
.and_then(|path| path_name(&path))
})
.unwrap_or_else(|| "project".to_string())
}
fn path_name(path: &Path) -> Option<String> {
path.file_name()
.and_then(|name| name.to_str())
.filter(|name| !name.is_empty() && *name != ".")
.map(str::to_string)
}
#[cfg(test)]
mod tests;