use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::HashMap;
use super::output_store;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
Info,
Low,
Medium,
High,
Critical,
}
impl Severity {
pub fn from_str(s: &str) -> Self {
match s.to_lowercase().as_str() {
"critical" | "error" => Severity::Critical,
"high" | "warning" => Severity::High,
"medium" => Severity::Medium,
"low" | "hint" => Severity::Low,
_ => Severity::Info,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeduplicatedPattern {
pub code: String,
pub count: usize,
pub severity: Severity,
pub message: String,
pub affected_files: Vec<String>,
pub example: Option<Value>,
pub fix_template: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompressedOutput {
pub tool: String,
pub status: String,
pub summary: SeveritySummary,
pub critical_issues: Vec<Value>,
pub high_issues: Vec<Value>,
pub patterns: Vec<DeduplicatedPattern>,
pub full_data_ref: String,
pub retrieval_hint: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SeveritySummary {
pub total: usize,
pub critical: usize,
pub high: usize,
pub medium: usize,
pub low: usize,
pub info: usize,
}
#[derive(Debug, Clone)]
pub struct CompressionConfig {
pub max_high_full: usize,
pub max_files_per_pattern: usize,
pub target_size_bytes: usize,
}
impl Default for CompressionConfig {
fn default() -> Self {
Self {
max_high_full: 10,
max_files_per_pattern: 5,
target_size_bytes: 15_000,
}
}
}
pub fn compress_tool_output(output: &Value, tool_name: &str, config: &CompressionConfig) -> String {
let raw_str = serde_json::to_string(output).unwrap_or_default();
if raw_str.len() <= config.target_size_bytes {
return raw_str;
}
let ref_id = output_store::store_output(output, tool_name);
let issues = extract_issues(output);
if issues.is_empty() {
let contains = format!("{} analysis data (no issues)", tool_name);
output_store::register_session_ref(
&ref_id,
tool_name,
&contains,
"0 issues",
raw_str.len(),
);
let mut result = serde_json::to_string_pretty(&json!({
"tool": tool_name,
"status": "NO_ISSUES",
"summary": { "total": 0 },
"full_data_ref": ref_id,
"retrieval_hint": format!("Use retrieve_output('{}') for full analysis data", ref_id)
}))
.unwrap_or(raw_str.clone());
result.push_str(&output_store::format_session_refs_for_agent());
return result;
}
let (critical, high, medium, low, info) = classify_by_severity(&issues);
let summary = SeveritySummary {
total: issues.len(),
critical: critical.len(),
high: high.len(),
medium: medium.len(),
low: low.len(),
info: info.len(),
};
let critical_issues: Vec<Value> = critical.clone();
let high_issues: Vec<Value> = if high.len() <= config.max_high_full {
high.clone()
} else {
high.iter().take(config.max_high_full).cloned().collect()
};
let mut all_lower: Vec<Value> = Vec::new();
all_lower.extend(medium.clone());
all_lower.extend(low.clone());
all_lower.extend(info.clone());
if high.len() > config.max_high_full {
all_lower.extend(high.iter().skip(config.max_high_full).cloned());
}
let patterns = deduplicate_to_patterns(&all_lower, config);
let status = if summary.critical > 0 {
"CRITICAL_ISSUES_FOUND"
} else if summary.high > 0 {
"HIGH_ISSUES_FOUND"
} else if summary.total > 0 {
"ISSUES_FOUND"
} else {
"CLEAN"
};
let contains = match tool_name {
"kubelint" => "Kubernetes manifest lint issues (security, best practices)",
"k8s_optimize" => "K8s resource optimization recommendations",
"analyze" => "Project analysis (languages, frameworks, dependencies)",
_ => "Tool analysis results",
};
let summary_str = format!(
"{} issues: {} critical, {} high, {} medium",
summary.total, summary.critical, summary.high, summary.medium
);
output_store::register_session_ref(&ref_id, tool_name, contains, &summary_str, raw_str.len());
let compressed = CompressedOutput {
tool: tool_name.to_string(),
status: status.to_string(),
summary,
critical_issues,
high_issues,
patterns,
full_data_ref: ref_id.clone(),
retrieval_hint: format!(
"Use retrieve_output('{}', query) to get full details. Query options: 'severity:critical', 'file:path', 'code:DL3008'",
ref_id
),
};
let mut result = serde_json::to_string_pretty(&compressed).unwrap_or(raw_str);
result.push_str(&output_store::format_session_refs_for_agent());
result
}
fn extract_issues(output: &Value) -> Vec<Value> {
let issue_fields = [
"issues",
"findings",
"violations",
"warnings",
"errors",
"recommendations",
"results",
"diagnostics",
"failures", "vulnerable_dependencies",
];
for field in &issue_fields {
if let Some(arr) = output.get(field).and_then(|v| v.as_array()) {
if field == &"vulnerable_dependencies" && !arr.is_empty() {
let mut flat = Vec::new();
for dep in arr {
let dep_name = dep
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let dep_version = dep.get("version").and_then(|v| v.as_str()).unwrap_or("?");
let language = dep
.get("language")
.cloned()
.unwrap_or(serde_json::Value::Null);
if let Some(vulns) = dep.get("vulnerabilities").and_then(|v| v.as_array()) {
for vuln in vulns {
let mut entry = vuln.clone();
if let Some(obj) = entry.as_object_mut() {
obj.insert("package".to_string(), serde_json::json!(dep_name));
obj.insert(
"package_version".to_string(),
serde_json::json!(dep_version),
);
obj.insert("language".to_string(), language.clone());
}
flat.push(entry);
}
}
}
return flat;
}
return arr.clone();
}
}
if let Some(arr) = output.as_array() {
return arr.clone();
}
if let Some(obj) = output.as_object() {
for (_, v) in obj {
if let Some(arr) = v.as_array()
&& !arr.is_empty()
&& is_issue_like(&arr[0])
{
return arr.clone();
}
}
}
Vec::new()
}
fn is_issue_like(value: &Value) -> bool {
if let Some(obj) = value.as_object() {
obj.contains_key("severity")
|| obj.contains_key("code")
|| obj.contains_key("message")
|| obj.contains_key("rule")
|| obj.contains_key("level")
} else {
false
}
}
fn classify_by_severity(
issues: &[Value],
) -> (Vec<Value>, Vec<Value>, Vec<Value>, Vec<Value>, Vec<Value>) {
let mut critical = Vec::new();
let mut high = Vec::new();
let mut medium = Vec::new();
let mut low = Vec::new();
let mut info = Vec::new();
for issue in issues {
let severity = get_severity(issue);
match severity {
Severity::Critical => critical.push(issue.clone()),
Severity::High => high.push(issue.clone()),
Severity::Medium => medium.push(issue.clone()),
Severity::Low => low.push(issue.clone()),
Severity::Info => info.push(issue.clone()),
}
}
(critical, high, medium, low, info)
}
fn get_severity(issue: &Value) -> Severity {
let severity_fields = ["severity", "level", "priority", "type"];
for field in &severity_fields {
if let Some(s) = issue.get(field).and_then(|v| v.as_str()) {
return Severity::from_str(s);
}
}
if let Some(code) = issue.get("code").and_then(|v| v.as_str()) {
if code.to_lowercase().contains("error") {
return Severity::Critical;
}
if code.to_lowercase().contains("warn") {
return Severity::High;
}
}
Severity::Medium }
fn get_issue_code(issue: &Value) -> String {
let code_fields = ["code", "rule", "rule_id", "type", "check", "id"];
for field in &code_fields {
if let Some(s) = issue.get(field).and_then(|v| v.as_str()) {
return s.to_string();
}
}
if let Some(msg) = issue.get("message").and_then(|v| v.as_str()) {
return format!("msg:{}", &msg[..msg.len().min(30)]);
}
"unknown".to_string()
}
fn get_issue_file(issue: &Value) -> Option<String> {
let file_fields = ["file", "path", "filename", "location", "source"];
for field in &file_fields {
if let Some(s) = issue.get(field).and_then(|v| v.as_str()) {
return Some(s.to_string());
}
if let Some(loc) = issue.get(field).and_then(|v| v.as_object())
&& let Some(f) = loc.get("file").and_then(|v| v.as_str())
{
return Some(f.to_string());
}
}
None
}
fn get_issue_message(issue: &Value) -> String {
let msg_fields = ["message", "msg", "description", "text", "detail"];
for field in &msg_fields {
if let Some(s) = issue.get(field).and_then(|v| v.as_str()) {
return s.to_string();
}
}
"No message".to_string()
}
fn deduplicate_to_patterns(
issues: &[Value],
config: &CompressionConfig,
) -> Vec<DeduplicatedPattern> {
let mut groups: HashMap<String, Vec<&Value>> = HashMap::new();
for issue in issues {
let code = get_issue_code(issue);
groups.entry(code).or_default().push(issue);
}
let mut patterns: Vec<DeduplicatedPattern> = groups
.into_iter()
.map(|(code, group)| {
let first = group[0];
let severity = get_severity(first);
let message = get_issue_message(first);
let mut files: Vec<String> = group.iter().filter_map(|i| get_issue_file(i)).collect();
files.dedup();
let total_files = files.len();
let truncated_files: Vec<String> = if files.len() > config.max_files_per_pattern {
let mut truncated: Vec<String> = files
.iter()
.take(config.max_files_per_pattern)
.cloned()
.collect();
truncated.push(format!(
"...+{} more",
total_files - config.max_files_per_pattern
));
truncated
} else {
files
};
let fix_template = first
.get("fix")
.or_else(|| first.get("suggestion"))
.or_else(|| first.get("recommendation"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
DeduplicatedPattern {
code,
count: group.len(),
severity,
message,
affected_files: truncated_files,
example: if group.len() > 1 {
Some(first.clone())
} else {
None
},
fix_template,
}
})
.collect();
patterns.sort_by(|a, b| {
b.severity
.cmp(&a.severity)
.then_with(|| b.count.cmp(&a.count))
});
patterns
}
pub fn compress_analysis_output(output: &Value, config: &CompressionConfig) -> String {
let raw_str = serde_json::to_string(output).unwrap_or_default();
if raw_str.len() <= config.target_size_bytes {
return raw_str;
}
let ref_id = output_store::store_output(output, "analyze_project");
let mut summary = json!({
"tool": "analyze_project",
"status": "ANALYSIS_COMPLETE",
"full_data_ref": ref_id.clone()
});
let summary_obj = summary.as_object_mut().unwrap();
let is_monorepo = output.get("projects").is_some() || output.get("is_monorepo").is_some();
let is_project_analysis =
output.get("languages").is_some() && output.get("analysis_metadata").is_some();
if is_monorepo {
if let Some(mono) = output.get("is_monorepo").and_then(|v| v.as_bool()) {
summary_obj.insert("is_monorepo".to_string(), json!(mono));
}
if let Some(root) = output.get("root_path").and_then(|v| v.as_str()) {
summary_obj.insert("root_path".to_string(), json!(root));
}
if let Some(projects) = output.get("projects").and_then(|v| v.as_array()) {
summary_obj.insert("project_count".to_string(), json!(projects.len()));
let mut all_languages: Vec<String> = Vec::new();
let mut all_frameworks: Vec<String> = Vec::new();
let mut project_names: Vec<String> = Vec::new();
for project in projects.iter().take(20) {
if let Some(name) = project.get("name").and_then(|v| v.as_str()) {
project_names.push(name.to_string());
}
if let Some(analysis) = project.get("analysis") {
if let Some(langs) = analysis.get("languages").and_then(|v| v.as_array()) {
for lang in langs {
if let Some(name) = lang.get("name").and_then(|v| v.as_str())
&& !all_languages.contains(&name.to_string())
{
all_languages.push(name.to_string());
}
}
}
if let Some(fws) = analysis.get("frameworks").and_then(|v| v.as_array()) {
for fw in fws {
if let Some(name) = fw.get("name").and_then(|v| v.as_str())
&& !all_frameworks.contains(&name.to_string())
{
all_frameworks.push(name.to_string());
}
}
}
}
}
summary_obj.insert("project_names".to_string(), json!(project_names));
summary_obj.insert("languages_detected".to_string(), json!(all_languages));
summary_obj.insert("frameworks_detected".to_string(), json!(all_frameworks));
}
} else if is_project_analysis {
if let Some(root) = output.get("project_root").and_then(|v| v.as_str()) {
summary_obj.insert("project_root".to_string(), json!(root));
}
if let Some(arch) = output.get("architecture_type").and_then(|v| v.as_str()) {
summary_obj.insert("architecture_type".to_string(), json!(arch));
}
if let Some(proj_type) = output.get("project_type").and_then(|v| v.as_str()) {
summary_obj.insert("project_type".to_string(), json!(proj_type));
}
if let Some(langs) = output.get("languages").and_then(|v| v.as_array()) {
let names: Vec<&str> = langs
.iter()
.filter_map(|l| l.get("name").and_then(|n| n.as_str()))
.collect();
summary_obj.insert("languages_detected".to_string(), json!(names));
}
if let Some(techs) = output.get("technologies").and_then(|v| v.as_array()) {
let names: Vec<&str> = techs
.iter()
.filter_map(|t| t.get("name").and_then(|n| n.as_str()))
.collect();
summary_obj.insert("technologies_detected".to_string(), json!(names));
}
if let Some(services) = output.get("services").and_then(|v| v.as_array()) {
summary_obj.insert("services_count".to_string(), json!(services.len()));
let service_names: Vec<&str> = services
.iter()
.filter_map(|s| s.get("name").and_then(|n| n.as_str()))
.collect();
if !service_names.is_empty() {
summary_obj.insert("services_detected".to_string(), json!(service_names));
}
}
}
summary_obj.insert(
"retrieval_instructions".to_string(),
json!({
"message": "Full analysis stored. Use retrieve_output with queries to get specific sections.",
"ref_id": ref_id,
"available_queries": [
"section:summary - Project overview",
"section:languages - All detected languages",
"section:frameworks - All detected frameworks/technologies",
"section:services - All detected services",
"language:<name> - Details for specific language (e.g., language:Rust)",
"framework:<name> - Details for specific framework"
],
"example": format!("retrieve_output('{}', 'section:summary')", ref_id)
}),
);
let project_count = output
.get("projects")
.and_then(|v| v.as_array())
.map(|a| a.len())
.unwrap_or(1);
let summary_str = format!(
"{} project(s), {} bytes stored",
project_count,
raw_str.len()
);
output_store::register_session_ref(
&ref_id,
"analyze_project",
"Full project analysis (use section queries to retrieve specific data)",
&summary_str,
raw_str.len(),
);
serde_json::to_string_pretty(&summary).unwrap_or_else(|_| {
format!(
r#"{{"tool":"analyze_project","status":"STORED","full_data_ref":"{}","message":"Analysis complete. Use retrieve_output('{}', 'section:summary') to view."}}"#,
ref_id, ref_id
)
})
}
pub fn compress_tool_output_cli(
output: &Value,
tool_name: &str,
config: &CompressionConfig,
) -> String {
let raw_str = serde_json::to_string(output).unwrap_or_default();
if raw_str.len() <= config.target_size_bytes {
let ref_id = output_store::store_output(output, tool_name);
let mut obj = match output.clone() {
Value::Object(m) => m,
other => {
let mut m = serde_json::Map::new();
m.insert("data".to_string(), other);
m
}
};
obj.insert("full_data_ref".to_string(), json!(ref_id));
obj.insert(
"retrieval_hint".to_string(),
json!(format!(
"Use `sync-ctl retrieve '{}' --query 'severity:critical'` for details. Paginate with --limit N --offset M. Other queries: 'file:<path>', 'code:<id>'",
ref_id
)),
);
return serde_json::to_string_pretty(&Value::Object(obj)).unwrap_or(raw_str);
}
let ref_id = output_store::store_output(output, tool_name);
if let Some(deps_obj) = output.get("dependencies").and_then(|v| v.as_object()) {
let total = output
.get("total")
.and_then(|v| v.as_u64())
.unwrap_or(deps_obj.len() as u64);
let mut by_source: std::collections::HashMap<String, usize> =
std::collections::HashMap::new();
let mut by_license: std::collections::HashMap<String, usize> =
std::collections::HashMap::new();
let mut dev_count = 0usize;
let mut prod_count = 0usize;
for dep in deps_obj.values() {
let source = dep
.get("source")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
*by_source.entry(source.to_string()).or_default() += 1;
let license = dep
.get("license")
.and_then(|v| v.as_str())
.unwrap_or("Unknown");
*by_license.entry(license.to_string()).or_default() += 1;
if dep.get("is_dev").and_then(|v| v.as_bool()).unwrap_or(false) {
dev_count += 1;
} else {
prod_count += 1;
}
}
return serde_json::to_string_pretty(&json!({
"tool": tool_name,
"total": total,
"production": prod_count,
"development": dev_count,
"by_source": by_source,
"by_license": by_license,
"full_data_ref": ref_id,
"retrieval_hint": format!(
"Use `sync-ctl retrieve '{}' --query 'file:<path>'` for details. Paginate with --limit N --offset M.",
ref_id
)
}))
.unwrap_or(raw_str);
}
let issues = extract_issues(output);
if issues.is_empty() {
return serde_json::to_string_pretty(&json!({
"tool": tool_name,
"status": "NO_ISSUES",
"summary": { "total": 0 },
"full_data_ref": ref_id,
"retrieval_hint": format!(
"Use `sync-ctl retrieve '{}' --query 'severity:critical'` for details. Paginate with --limit N --offset M.",
ref_id
)
}))
.unwrap_or(raw_str);
}
let (critical, high, medium, low, info) = classify_by_severity(&issues);
let summary = SeveritySummary {
total: issues.len(),
critical: critical.len(),
high: high.len(),
medium: medium.len(),
low: low.len(),
info: info.len(),
};
let critical_issues: Vec<Value> = critical.clone();
let high_issues: Vec<Value> = if high.len() <= config.max_high_full {
high.clone()
} else {
high.iter().take(config.max_high_full).cloned().collect()
};
let mut all_lower: Vec<Value> = Vec::new();
all_lower.extend(medium.clone());
all_lower.extend(low.clone());
all_lower.extend(info.clone());
if high.len() > config.max_high_full {
all_lower.extend(high.iter().skip(config.max_high_full).cloned());
}
let patterns = deduplicate_to_patterns(&all_lower, config);
let status = if summary.critical > 0 {
"CRITICAL_ISSUES_FOUND"
} else if summary.high > 0 {
"HIGH_ISSUES_FOUND"
} else if summary.total > 0 {
"ISSUES_FOUND"
} else {
"CLEAN"
};
let compressed = CompressedOutput {
tool: tool_name.to_string(),
status: status.to_string(),
summary,
critical_issues,
high_issues,
patterns,
full_data_ref: ref_id.clone(),
retrieval_hint: format!(
"Use `sync-ctl retrieve '{}' --query 'severity:critical'` for details. Paginate with --limit N --offset M. Other queries: 'file:<path>', 'code:<id>'",
ref_id
),
};
serde_json::to_string_pretty(&compressed).unwrap_or(raw_str)
}
pub fn compress_analysis_output_cli(output: &Value, _config: &CompressionConfig) -> String {
let ref_id = output_store::store_output(output, "analyze_project");
let mut summary = json!({
"tool": "analyze_project",
"status": "ANALYSIS_COMPLETE",
"full_data_ref": ref_id.clone()
});
let summary_obj = summary.as_object_mut().unwrap();
let is_monorepo = output.get("projects").is_some() || output.get("is_monorepo").is_some();
let is_project_analysis =
output.get("languages").is_some() && output.get("analysis_metadata").is_some();
if is_monorepo {
if let Some(mono) = output.get("is_monorepo").and_then(|v| v.as_bool()) {
summary_obj.insert("is_monorepo".to_string(), json!(mono));
}
if let Some(root) = output.get("root_path").and_then(|v| v.as_str()) {
summary_obj.insert("root_path".to_string(), json!(root));
}
if let Some(projects) = output.get("projects").and_then(|v| v.as_array()) {
summary_obj.insert("project_count".to_string(), json!(projects.len()));
let mut all_languages: Vec<String> = Vec::new();
let mut all_frameworks: Vec<String> = Vec::new();
let mut project_names: Vec<String> = Vec::new();
for project in projects.iter().take(20) {
if let Some(name) = project.get("name").and_then(|v| v.as_str()) {
project_names.push(name.to_string());
}
if let Some(analysis) = project.get("analysis") {
if let Some(langs) = analysis.get("languages").and_then(|v| v.as_array()) {
for lang in langs {
if let Some(name) = lang.get("name").and_then(|v| v.as_str())
&& !all_languages.contains(&name.to_string())
{
all_languages.push(name.to_string());
}
}
}
if let Some(fws) = analysis.get("frameworks").and_then(|v| v.as_array()) {
for fw in fws {
if let Some(name) = fw.get("name").and_then(|v| v.as_str())
&& !all_frameworks.contains(&name.to_string())
{
all_frameworks.push(name.to_string());
}
}
}
}
}
summary_obj.insert("project_names".to_string(), json!(project_names));
summary_obj.insert("languages_detected".to_string(), json!(all_languages));
summary_obj.insert("frameworks_detected".to_string(), json!(all_frameworks));
}
} else if is_project_analysis {
if let Some(root) = output.get("project_root").and_then(|v| v.as_str()) {
summary_obj.insert("project_root".to_string(), json!(root));
}
if let Some(arch) = output.get("architecture_type").and_then(|v| v.as_str()) {
summary_obj.insert("architecture_type".to_string(), json!(arch));
}
if let Some(proj_type) = output.get("project_type").and_then(|v| v.as_str()) {
summary_obj.insert("project_type".to_string(), json!(proj_type));
}
if let Some(langs) = output.get("languages").and_then(|v| v.as_array()) {
let names: Vec<&str> = langs
.iter()
.filter_map(|l| l.get("name").and_then(|n| n.as_str()))
.collect();
summary_obj.insert("languages_detected".to_string(), json!(names));
}
if let Some(techs) = output.get("technologies").and_then(|v| v.as_array()) {
let names: Vec<&str> = techs
.iter()
.filter_map(|t| t.get("name").and_then(|n| n.as_str()))
.collect();
summary_obj.insert("technologies_detected".to_string(), json!(names));
}
if let Some(services) = output.get("services").and_then(|v| v.as_array()) {
summary_obj.insert("services_count".to_string(), json!(services.len()));
let service_names: Vec<&str> = services
.iter()
.filter_map(|s| s.get("name").and_then(|n| n.as_str()))
.collect();
if !service_names.is_empty() {
summary_obj.insert("services_detected".to_string(), json!(service_names));
}
}
}
summary_obj.insert(
"retrieval_hint".to_string(),
json!(format!(
"Use `sync-ctl retrieve '{}' --query 'section:summary'` for full details. Other queries: 'section:languages', 'section:frameworks', 'section:services'",
ref_id
)),
);
serde_json::to_string_pretty(&summary).unwrap_or_else(|_| {
format!(
r#"{{"tool":"analyze_project","status":"STORED","full_data_ref":"{}","retrieval_hint":"Use `sync-ctl retrieve '{}' --query 'section:summary'` for full details."}}"#,
ref_id, ref_id
)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_severity_ordering() {
assert!(Severity::Critical > Severity::High);
assert!(Severity::High > Severity::Medium);
assert!(Severity::Medium > Severity::Low);
assert!(Severity::Low > Severity::Info);
}
#[test]
fn test_extract_issues_from_array_field() {
let output = json!({
"issues": [
{ "code": "DL3008", "severity": "warning", "message": "Pin versions" },
{ "code": "DL3009", "severity": "info", "message": "Delete apt lists" }
]
});
let issues = extract_issues(&output);
assert_eq!(issues.len(), 2);
}
#[test]
fn test_deduplication() {
let issues = vec![
json!({ "code": "DL3008", "severity": "warning", "file": "Dockerfile1" }),
json!({ "code": "DL3008", "severity": "warning", "file": "Dockerfile2" }),
json!({ "code": "DL3008", "severity": "warning", "file": "Dockerfile3" }),
json!({ "code": "DL3009", "severity": "info", "file": "Dockerfile1" }),
];
let config = CompressionConfig::default();
let patterns = deduplicate_to_patterns(&issues, &config);
assert_eq!(patterns.len(), 2);
let dl3008 = patterns.iter().find(|p| p.code == "DL3008").unwrap();
assert_eq!(dl3008.count, 3);
assert_eq!(dl3008.affected_files.len(), 3);
}
#[test]
fn test_small_output_not_compressed() {
let small_output = json!({
"issues": [
{ "code": "test", "severity": "low" }
]
});
let config = CompressionConfig {
target_size_bytes: 10000,
..Default::default()
};
let result = compress_tool_output(&small_output, "test", &config);
assert!(!result.contains("full_data_ref"));
}
#[test]
fn test_compress_tool_output_cli_produces_valid_json() {
let output = serde_json::json!({
"findings": (0..100).map(|i| serde_json::json!({
"code": format!("SEC{:03}", i),
"severity": if i < 3 { "critical" } else if i < 15 { "high" } else { "medium" },
"message": format!("Finding {} with enough text to exceed compression threshold when multiplied", i),
"file": format!("src/file_{}.rs", i),
})).collect::<Vec<_>>()
});
let config = CompressionConfig::default();
let result = compress_tool_output_cli(&output, "security", &config);
let parsed: Result<serde_json::Value, _> = serde_json::from_str(&result);
assert!(
parsed.is_ok(),
"CLI output must be valid JSON, got: {}",
&result[..200.min(result.len())]
);
let json = parsed.unwrap();
let hint = json.get("retrieval_hint").and_then(|v| v.as_str()).unwrap();
assert!(
hint.contains("sync-ctl retrieve"),
"Hint should use CLI syntax, got: {}",
hint
);
assert!(
!hint.contains("retrieve_output("),
"Hint should NOT use internal tool call syntax"
);
}
}