use futures_util::StreamExt;
use crate::config::TuiConfig;
use crate::headless::common::{ensure_engine, resolve_project_path_buf};
use crate::headless::format::colors::{
bar_empty, bar_filled, bold, bold_red, check_mark, cyan, diamond, dim, green, red, score_color,
yellow,
};
use crate::headless::format::labels::check_label;
use crate::headless::format::layers::SEP_WIDTH;
use crate::headless::format::{plural, project_name, separator};
pub const EXIT_NO_FIX_AVAILABLE: i32 = 0;
pub const EXIT_FIX_FAILED: i32 = 2;
pub async fn run_headless_fix(
dry_run: bool,
json: bool,
path: Option<&str>,
config: &TuiConfig,
use_ai: bool,
) -> i32 {
let client = match super::common::ensure_engine(config).await {
Ok(c) => c,
Err(code) => return code,
};
let scan_path = super::common::resolve_project_path(path);
if use_ai && !super::common::check_llm_key(&scan_path) {
super::common::print_llm_key_error();
return 1;
}
let preview = client.get_json("/fix/preview").await;
let cached_fixes: Option<&Vec<serde_json::Value>> = preview
.as_ref()
.ok()
.and_then(|v| v.get("fixes"))
.and_then(|v| v.as_array())
.filter(|arr| !arr.is_empty());
let (fixable, current_score) = if let Some(fixes) = cached_fixes {
let check_ids: Vec<String> = fixes
.iter()
.filter_map(|f| f.get("checkId").and_then(|v| v.as_str()).map(String::from))
.collect();
let score = match client.get_json("/status").await.ok() {
Some(v) => match v.get("lastScan") {
Some(ls) => match ls.get("score") {
Some(sv) => sv.as_f64().unwrap_or(0.0),
None => 0.0,
},
None => 0.0,
},
None => 0.0,
};
(check_ids, score)
} else {
match client.scan(&scan_path).await {
Ok(result) => {
let ids: Vec<String> = result
.findings
.iter()
.filter(|f| f.fix.is_some())
.map(|f| f.check_id.clone())
.collect();
(ids, result.score.total_score)
}
Err(e) => {
eprintln!("Scan failed: {e}");
return 1;
}
}
};
if fixable.is_empty() {
if json {
println!(
"{{\"dryRun\": {dry_run}, \"changes\": [], \"message\": \"No fixable findings\"}}"
);
} else {
println!("No fixable findings. Score: {current_score:.0}/100");
}
return 0;
}
if dry_run {
if let Ok(dr_result) = client.fix_dry_run(current_score).await {
if json {
println!(
"{}",
serde_json::to_string_pretty(&dr_result).unwrap_or_default()
);
} else {
print!(
"{}",
format_dry_run_report(&dr_result, current_score, &scan_path)
);
}
} else {
let impact = (fixable.len() as f64 * 3.0).min(60.0) as i32;
let predicted = (current_score + f64::from(impact)).min(99.0);
if json {
println!(
"{{\"dryRun\": true, \"fixable\": {}, \"currentScore\": {current_score:.0}, \"predictedScore\": {predicted:.0}}}",
fixable.len()
);
} else {
println!("Dry-Run Fix Analysis (offline estimate)");
println!("Fixable: {} findings", fixable.len());
println!("Predicted: {current_score:.0} -> {predicted:.0} (+{impact})");
}
}
} else {
let body = serde_json::json!({ "useAi": use_ai, "projectPath": scan_path });
let model_label = if use_ai && !json {
let model_info = client.get_json("/llm/info").await.ok();
model_info.as_ref().and_then(|info| {
let task = info.get("document-generation")?;
let model = task.get("modelId").and_then(|v| v.as_str())?;
let provider = task.get("provider").and_then(|v| v.as_str())?;
let source = task
.get("source")
.and_then(|v| v.as_str())
.unwrap_or("default");
let source_label = if source == "env" {
let env_var = task.get("envVar").and_then(|v| v.as_str()).unwrap_or("env");
format!(" ({env_var})")
} else {
eprintln!(
" {}",
dim("Override: set COMPLIOR_MODEL_DOCUMENT_GENERATION in .complior/.env")
);
String::new()
};
Some(format!("{model} via {provider}{source_label}"))
})
} else {
None
};
if use_ai && !json {
let stream_result =
run_fix_stream(&client, &body, &scan_path, model_label.as_deref()).await;
return stream_result;
}
let fix_result = if use_ai {
client.post_json_long("/fix/apply-all", &body).await
} else {
client.post_json("/fix/apply-all", &body).await
};
match fix_result {
Ok(resp) => {
if json {
println!(
"{}",
serde_json::to_string_pretty(&resp).unwrap_or_default()
);
} else {
print!("{}", format_fix_report(&resp, &scan_path));
}
}
Err(e) => {
eprintln!("Fix apply failed: {e}");
return 1;
}
}
}
0
}
pub async fn run_fix_single(
check_id: &str,
json: bool,
path: Option<&str>,
config: &TuiConfig,
use_ai: bool,
) -> i32 {
let client = match super::common::ensure_engine(config).await {
Ok(c) => c,
Err(code) => return code,
};
let _scan_path = super::common::resolve_project_path(path);
let body = serde_json::json!({ "checkId": check_id, "useAi": use_ai });
match client.post_json("/fix/apply", &body).await {
Ok(resp) => {
if json {
println!(
"{}",
serde_json::to_string_pretty(&resp).unwrap_or_default()
);
} else {
let applied = resp
.get("applied")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
if applied {
println!(" {} Fix applied for {}", green("✓"), bold(check_id));
if let Some(plan) = resp.get("plan")
&& let Some(actions) = plan.get("actions").and_then(|v| v.as_array())
{
for a in actions {
let p = a.get("path").and_then(|v| v.as_str()).unwrap_or("?");
println!(" → {p}");
}
}
} else {
let err = resp
.get("error")
.and_then(|v| v.as_str())
.or_else(|| resp.get("message").and_then(|v| v.as_str()))
.unwrap_or("Unknown error");
if err == "NO_FIX" {
let msg = resp
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("No auto-fix available");
let rec = resp.get("recommendation").and_then(|v| v.as_str());
eprintln!(" {} {}: {}", yellow("!"), bold(check_id), msg);
if let Some(r) = rec {
eprintln!(" {r}");
}
eprintln!(
" Tip: use {} for LLM-enriched documents",
bold("complior fix --ai")
);
} else {
eprintln!(" Fix failed for {check_id}: {err}");
}
return 1;
}
}
0
}
Err(e) => {
eprintln!("Fix failed: {e}");
1
}
}
}
struct FixEntry {
check_id: String,
fix_type: String,
article: String,
description: String,
applied: bool,
files: Vec<String>,
manual_fields: Vec<String>,
error: Option<String>,
is_scaffold: bool,
}
fn extract_entries(resp: &serde_json::Value) -> Vec<FixEntry> {
let results = match resp.get("results").and_then(|v| v.as_array()) {
Some(r) => r,
None => return Vec::new(),
};
results
.iter()
.map(|r| {
let plan = r.get("plan").unwrap_or(r);
let check_id = plan
.get("checkId")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let fix_type = plan
.get("fixType")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let article = plan
.get("article")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let description = plan
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let applied = r
.get("applied")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let error = r.get("error").and_then(|v| v.as_str()).map(String::from);
let files: Vec<String> = plan
.get("actions")
.and_then(|v| v.as_array())
.map(|actions| {
actions
.iter()
.filter_map(|a| a.get("path").and_then(|v| v.as_str()).map(String::from))
.collect()
})
.unwrap_or_default();
let manual_fields: Vec<String> = plan
.get("manualFields")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let is_scaffold =
plan.get("actions")
.and_then(|v| v.as_array())
.is_none_or(|actions| {
actions
.iter()
.all(|a| a.get("type").and_then(|v| v.as_str()) == Some("create"))
});
FixEntry {
check_id,
fix_type,
article,
description,
applied,
files,
manual_fields,
error,
is_scaffold,
}
})
.collect()
}
fn pad_article(article: &str) -> String {
format!("{article:<9}")
}
fn scaffold_badge() -> String {
yellow("[SCAFFOLD]")
}
fn scaffold_hint(check_id: &str, fix_type: &str) -> &'static str {
match fix_type {
"ai_enrichment" => "Review AI-enriched sections and approve with your compliance team",
"template_generation" => "Fill placeholders and review with your compliance team",
"metadata_generation" => "Populate [TO BE SET] fields with actual system metadata",
"dependency_fix" => "Follow the upgrade plan and run dependency audit",
"config_fix" => match check_id {
id if id.contains("bias") => "Populate test data paths and adjust fairness thresholds",
id if id.contains("ci") => "Adjust workflow triggers and scan thresholds for your CI",
id if id.contains("nhi") => "Move actual secrets to vault or environment variables",
_ => "Review generated config and customize for your project",
},
"code_injection" => "Import this module into your codebase and wire into your pipeline",
_ => "Review and customize for your project",
}
}
fn format_fix_report(resp: &serde_json::Value, scan_path: &str) -> String {
let mut o = String::with_capacity(8192);
let summary = resp.get("summary");
let score_before = summary
.and_then(|s| s.get("scoreBefore"))
.and_then(serde_json::Value::as_f64)
.unwrap_or(0.0);
let score_after = summary
.and_then(|s| s.get("scoreAfter"))
.and_then(serde_json::Value::as_f64)
.unwrap_or(0.0);
let applied_count = summary
.and_then(|s| s.get("applied"))
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
let failed_count = summary
.and_then(|s| s.get("failed"))
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
let entries = extract_entries(resp);
render_fix_header(&mut o, scan_path, applied_count, failed_count, false);
render_score_line(&mut o, score_before, score_after, applied_count);
let (docs, code, config_deps) = group_entries(&entries);
if !docs.is_empty() {
render_doc_section(&mut o, &docs);
}
if !code.is_empty() {
render_code_section(&mut o, &code);
}
if !config_deps.is_empty() {
render_config_dep_section(&mut o, &config_deps);
}
let failures: Vec<&FixEntry> = entries.iter().filter(|e| !e.applied).collect();
if !failures.is_empty() {
render_failures(&mut o, &failures);
}
render_unfixed_findings(&mut o, resp);
let has_todos = docs.iter().any(|e| !e.manual_fields.is_empty());
let has_scaffold = entries.iter().any(|e| e.applied && e.is_scaffold);
render_next_steps(&mut o, has_todos, has_scaffold);
o
}
fn render_fix_header(o: &mut String, scan_path: &str, applied: u64, failed: u64, is_preview: bool) {
let mode = if is_preview {
"Fix Preview"
} else {
"Fix Report"
};
let subtitle = if is_preview {
"Dry Run · No Files Modified"
} else {
"EU AI Act Auto-Remediation"
};
o.push('\n');
o.push_str(&format!(
" {}\n",
bold(&format!("◆ Complior {mode} · {subtitle}"))
));
o.push_str(&format!(" {}\n", separator()));
o.push_str(&format!(
" {}{}\n",
dim(&format!("{:<10}", "Project")),
project_name(scan_path)
));
if is_preview {
o.push_str(&format!(
" {}{}\n",
dim(&format!("{:<10}", "Fixes")),
format!("{applied} planned")
));
} else {
let fix_summary = if failed > 0 {
format!(
"{applied} applied · {}",
bold_red(&format!("{failed} failed"))
)
} else {
format!("{applied} applied · 0 failed")
};
o.push_str(&format!(
" {}{}\n",
dim(&format!("{:<10}", "Fixes")),
fix_summary
));
}
o.push_str(&format!(" {}\n", separator()));
}
fn render_score_line(o: &mut String, before: f64, after: f64, applied: u64) {
render_score_line_inner(o, before, after, applied, false);
}
fn render_score_line_estimated(o: &mut String, before: f64, after: f64, applied: u64) {
render_score_line_inner(o, before, after, applied, true);
}
fn render_score_line_inner(o: &mut String, before: f64, after: f64, applied: u64, estimated: bool) {
let label = if estimated {
"SCORE (estimated)"
} else {
"SCORE"
};
let score_text = if estimated {
format!("{before:.0} → ~{after:.0}")
} else {
format!("{before:.0} → {after:.0}")
};
let pad = SEP_WIDTH.saturating_sub(label.len() + score_text.len());
o.push_str(&format!(
" {}{}{}\n",
bold(label),
" ".repeat(pad),
score_color(after, &score_text)
));
if (before - after).abs() < 0.5 && applied > 0 {
o.push_str(&format!(
" {}\n",
dim("(category improvements below weighted rounding threshold)")
));
}
o.push_str(&format!(" {}\n\n", separator()));
}
fn group_entries(entries: &[FixEntry]) -> (Vec<&FixEntry>, Vec<&FixEntry>, Vec<&FixEntry>) {
let mut docs = Vec::new();
let mut code = Vec::new();
let mut config_deps = Vec::new();
for e in entries {
if !e.applied {
continue;
}
match e.fix_type.as_str() {
"template_generation" | "metadata_generation" | "ai_enrichment" => docs.push(e),
"code_injection" => code.push(e),
"config_fix" | "dependency_fix" => config_deps.push(e),
_ => code.push(e),
}
}
(docs, code, config_deps)
}
fn render_doc_section(o: &mut String, entries: &[&FixEntry]) {
o.push_str(&format!(
" {} ({} fix{})\n\n",
bold("DOCUMENTS CREATED"),
entries.len(),
plural_es(entries.len()),
));
for e in entries {
let icon = green("✓");
let article = if e.article.is_empty() {
String::new()
} else {
format!("{} ", pad_article(&e.article))
};
let label = check_label(&e.check_id);
let badge = if e.is_scaffold {
format!(" {}", scaffold_badge())
} else {
String::new()
};
o.push_str(&format!(
" {} {}{}{}\n",
icon,
bold(&article),
label,
badge
));
for file in &e.files {
o.push_str(&format!(" → {}\n", cyan(file)));
}
if !e.manual_fields.is_empty() {
let shown: Vec<&str> = e
.manual_fields
.iter()
.take(3)
.map(std::string::String::as_str)
.collect();
let mut todo_text = shown.join(", ");
let remaining = e.manual_fields.len().saturating_sub(3);
if remaining > 0 {
todo_text.push_str(&format!(" (+ {remaining} more)"));
}
o.push_str(&format!(
" {}\n",
yellow(&format!("TODO: {todo_text}"))
));
}
if e.is_scaffold {
let hint = scaffold_hint(&e.check_id, &e.fix_type);
o.push_str(&format!(" {} {}\n", dim("↳"), dim(hint)));
}
o.push('\n');
}
}
fn normalize_code_desc(desc: &str) -> String {
let base = desc.strip_prefix("Inline fix: ").unwrap_or(desc);
let core = if let Some(pos) = base.rfind(" at ") {
&base[..pos]
} else if let Some(pos) = base.rfind(" from ") {
&base[..pos]
} else {
base
};
let mut s = core.to_string();
if let Some(first) = s.get_mut(0..1) {
first.make_ascii_uppercase();
}
s
}
fn code_subgroup(check_id: &str) -> &'static str {
if check_id.contains("bare-llm") || check_id.contains("bare-call") {
return "SDK Wrapper";
}
if check_id.contains("security-risk") || check_id.contains("unsafe-deser") {
return "Security Fixes";
}
if check_id.contains("error-handling") {
return "Error Handling";
}
if check_id.contains("nhi") || check_id.contains("detect-secrets") {
return "Secret Externalization";
}
if check_id.contains("banned") {
return "Prohibited Dependencies";
}
if check_id.contains("bandit") {
return "Python Security";
}
"Other Code Fixes"
}
fn render_code_section(o: &mut String, entries: &[&FixEntry]) {
o.push_str(&format!(
" {} ({} fix{})\n\n",
bold("CODE FIXES — INLINE"),
entries.len(),
plural_es(entries.len()),
));
let group_order = [
"SDK Wrapper",
"Security Fixes",
"Error Handling",
"Secret Externalization",
"Prohibited Dependencies",
"Python Security",
"Other Code Fixes",
];
for group_name in &group_order {
let group: Vec<&&FixEntry> = entries
.iter()
.filter(|e| code_subgroup(&e.check_id) == *group_name)
.collect();
if group.is_empty() {
continue;
}
let count = group.len();
let all_scaffold = group.iter().all(|e| e.is_scaffold);
let group_badge = if all_scaffold {
format!(" {}", scaffold_badge())
} else {
String::new()
};
o.push_str(&format!(
" {} ({count} fix{}){}\n",
bold(group_name),
plural_es(count),
group_badge,
));
let mut merged: Vec<(&str, String, Vec<&str>, bool)> = Vec::new();
for e in &group {
let norm = normalize_code_desc(&e.description);
if let Some(existing) = merged.iter_mut().find(|(_, d, _, _)| *d == norm) {
for f in &e.files {
existing.2.push(f.as_str());
}
existing.3 = existing.3 && e.is_scaffold;
} else {
let files: Vec<&str> = e.files.iter().map(std::string::String::as_str).collect();
merged.push((&e.article, norm, files, e.is_scaffold));
}
}
for (article, description, files, is_scaffold) in &merged {
let icon = green("✓");
let art_display = if article.is_empty() {
String::new()
} else {
format!("{} ", pad_article(article))
};
let badge = if *is_scaffold && !all_scaffold {
format!(" {}", scaffold_badge())
} else {
String::new()
};
o.push_str(&format!(
" {} {}{}{}\n",
icon,
bold(&art_display),
description,
badge
));
let mut unique_files: Vec<&str> = Vec::new();
for f in files {
if !unique_files.contains(f) {
unique_files.push(f);
}
}
let shown = unique_files.len().min(2);
let file_list: Vec<&str> = unique_files.iter().take(shown).copied().collect();
let extra = unique_files.len().saturating_sub(shown);
let mut file_text = file_list.join(", ");
if extra > 0 {
file_text.push_str(&format!(" (+ {extra} more)"));
}
o.push_str(&format!(" {}\n", dim(&file_text)));
if *is_scaffold && !all_scaffold {
o.push_str(&format!(
" {} {}\n",
dim("↳"),
dim("Integrate into your request pipeline and customize for your project")
));
}
}
if all_scaffold {
o.push_str(&format!(
" {} {}\n",
dim("↳"),
dim("Integrate into your request pipeline and customize for your project")
));
}
o.push('\n');
}
}
fn render_config_dep_section(o: &mut String, entries: &[&FixEntry]) {
o.push_str(&format!(
" {} ({} fix{})\n\n",
bold("CONFIG & DEPENDENCIES"),
entries.len(),
plural_es(entries.len()),
));
for e in entries {
let icon = green("✓");
let article = if e.article.is_empty() {
String::new()
} else {
format!("{} ", pad_article(&e.article))
};
let label = check_label(&e.check_id);
let file_text = if e.files.is_empty() {
String::new()
} else {
format!(" → {}", e.files[0])
};
let badge = if e.is_scaffold {
format!(" {}", scaffold_badge())
} else {
String::new()
};
o.push_str(&format!(
" {} {}{}{}{}\n",
icon,
bold(&article),
label,
dim(&file_text),
badge
));
if e.is_scaffold {
let hint = scaffold_hint(&e.check_id, &e.fix_type);
o.push_str(&format!(" {} {}\n", dim("↳"), dim(hint)));
}
}
o.push('\n');
}
fn render_failures(o: &mut String, failures: &[&FixEntry]) {
o.push_str(&format!(
" {} ({} fix{})\n\n",
bold_red("FAILED"),
failures.len(),
plural_es(failures.len()),
));
for e in failures {
let icon = red("✖");
let label = check_label(&e.check_id);
o.push_str(&format!(" {icon} {label}\n"));
if let Some(ref err) = e.error {
o.push_str(&format!(" {}\n", dim(err)));
}
}
o.push('\n');
}
fn render_unfixed_findings(o: &mut String, resp: &serde_json::Value) {
let unfixed = match resp.get("unfixedFindings").and_then(|v| v.as_array()) {
Some(arr) if !arr.is_empty() => arr,
_ => return,
};
o.push_str(&format!(" {}\n", separator()));
o.push_str(&format!(
" {} ({} finding{})\n",
bold("MANUAL ACTION NEEDED"),
unfixed.len(),
plural(unfixed.len()),
));
o.push_str(&format!(" {}\n\n", separator()));
for item in unfixed {
let check_id = item.get("checkId").and_then(|v| v.as_str()).unwrap_or("?");
let severity = item
.get("severity")
.and_then(|v| v.as_str())
.unwrap_or("medium");
let message = item.get("message").and_then(|v| v.as_str()).unwrap_or("");
let fix_hint = item.get("fix").and_then(|v| v.as_str());
let sev_colored = match severity {
"high" => red(severity),
"medium" => yellow(severity),
_ => dim(severity),
};
o.push_str(&format!(
" {} [{}] {}\n",
yellow("▸"),
sev_colored,
check_label(check_id)
));
if !message.is_empty() {
o.push_str(&format!(" {}\n", dim(message)));
}
if let Some(hint) = fix_hint {
o.push_str(&format!(" {}: {}\n", bold("Fix"), hint));
}
}
o.push('\n');
}
fn render_next_steps(o: &mut String, has_todos: bool, has_scaffold: bool) {
o.push_str(&format!(" {}\n", separator()));
o.push_str(&format!(" {}\n", bold("NEXT STEPS")));
o.push_str(&format!(" {}\n", separator()));
if has_todos {
o.push_str(&format!(
" {:<26}{}\n",
"Fill TODO fields",
dim("Review generated documents and complete manual sections")
));
}
o.push_str(&format!(
" {:<26}{}\n",
"Review code changes",
dim("Verify inline fixes don't break functionality")
));
o.push_str(&format!(" {:<26}{}\n", "Re-scan", dim("complior scan")));
o.push_str(&format!(" {}\n", separator()));
if has_scaffold {
o.push_str(&format!(
" {} → {}\n",
bold("UPGRADE [SCAFFOLD]"),
bold("PRODUCTION")
));
o.push_str(&format!(" {}\n", separator()));
o.push_str(&format!(
" {:<26}{}\n",
"LLM-enhanced docs",
dim("complior fix --ai")
));
o.push_str(&format!(
" {:<26}{}\n",
"Coding agent via MCP",
dim("Connect your coding agent to Complior MCP server")
));
o.push_str(&format!(
" {:<26}{}\n",
"",
dim("See: complior mcp --help")
));
o.push_str(&format!(" {}\n", separator()));
}
o.push('\n');
}
fn format_dry_run_report(resp: &serde_json::Value, current_score: f64, scan_path: &str) -> String {
let mut o = String::with_capacity(4096);
let changes = resp.get("changes").and_then(|v| v.as_array());
let predicted = resp
.get("predictedScore")
.and_then(serde_json::Value::as_f64)
.unwrap_or(current_score);
let change_count = changes.map_or(0, std::vec::Vec::len) as u64;
render_fix_header(&mut o, scan_path, change_count, 0, true);
render_score_line_estimated(&mut o, current_score, predicted, change_count);
if let Some(changes) = changes {
let creates: Vec<&serde_json::Value> = changes
.iter()
.filter(|c| c.get("action").and_then(|v| v.as_str()) == Some("CREATE"))
.collect();
let modifies: Vec<&serde_json::Value> = changes
.iter()
.filter(|c| c.get("action").and_then(|v| v.as_str()) != Some("CREATE"))
.collect();
if !creates.is_empty() {
o.push_str(&format!(
" {} ({} file{})\n\n",
bold("FILES TO CREATE"),
creates.len(),
plural(creates.len()),
));
for c in &creates {
let path = c.get("path").and_then(|v| v.as_str()).unwrap_or("?");
o.push_str(&format!(" {} {}\n", yellow("[PREVIEW]"), path));
}
o.push('\n');
}
if !modifies.is_empty() {
o.push_str(&format!(
" {} ({} file{})\n\n",
bold("FILES TO MODIFY"),
modifies.len(),
plural(modifies.len()),
));
for c in &modifies {
let path = c.get("path").and_then(|v| v.as_str()).unwrap_or("?");
let action = c.get("action").and_then(|v| v.as_str()).unwrap_or("MODIFY");
o.push_str(&format!(
" {} {:<40} [{}]\n",
yellow("[PREVIEW]"),
path,
action
));
}
o.push('\n');
}
}
o.push_str(&format!(" {}\n", separator()));
o.push_str(&format!(
" Run {} to apply these changes\n",
bold("complior fix")
));
o.push_str(&format!(" {}\n\n", separator()));
o
}
const fn plural_es(n: usize) -> &'static str {
if n == 1 { "" } else { "es" }
}
fn erase_prev_line() {
eprint!("\x1b[1A\x1b[2K");
}
fn render_progress_bar(completed: u64, total: u64) -> String {
let bar_width = 20usize;
let filled = (completed * bar_width as u64)
.checked_div(total)
.unwrap_or(0) as usize;
let empty = bar_width.saturating_sub(filled);
format!(
"[{}{}] {}/{}",
bar_filled().repeat(filled),
bar_empty().repeat(empty),
completed,
total,
)
}
async fn run_fix_stream(
client: &crate::engine_client::EngineClient,
body: &serde_json::Value,
_scan_path: &str,
model_label: Option<&str>,
) -> i32 {
let mode_str = model_label.unwrap_or("LLM");
eprintln!();
eprintln!(
" {} {}",
bold(&format!("{} Complior Fix", diamond())),
bold(&format!("· AI-enriched · {mode_str}")),
);
eprintln!(" {}", separator());
eprintln!();
let resp = match client.post_stream_long("/fix/apply-all/stream", body).await {
Ok(r) => r,
Err(e) => {
eprintln!("Fix streaming failed: {e}");
return 1;
}
};
let mut stream = resp.bytes_stream();
let mut buffer = String::new();
let mut current_event = String::new();
let mut total: u64 = 0;
let mut completed: u64 = 0;
let mut applied: u64 = 0;
let mut failed: u64 = 0;
let mut current_check: Option<String> = None;
let mut done_data: Option<serde_json::Value> = None;
let mut progress_line_shown = false;
let mut fix_lines: Vec<(String, String, String, bool)> = Vec::new();
while let Some(chunk) = stream.next().await {
let chunk = match chunk {
Ok(c) => c,
Err(e) => {
eprintln!("\nStream error: {e}");
return 1;
}
};
buffer.push_str(&String::from_utf8_lossy(&chunk));
while let Some(newline_pos) = buffer.find('\n') {
let line = buffer[..newline_pos].trim_end_matches('\r').to_string();
buffer = buffer[newline_pos + 1..].to_string();
if line.is_empty() {
continue;
}
if let Some(event) = line.strip_prefix("event:") {
current_event = event.trim().to_string();
continue;
}
if let Some(data) = line.strip_prefix("data:") {
let data = data.trim();
match current_event.as_str() {
"heartbeat" => {
continue;
}
"fix:start" => {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(data) {
total = parsed
.get("total")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
eprintln!(" {} fixes to apply\n", total);
eprintln!(" {}", render_progress_bar(0, total));
progress_line_shown = true;
}
}
"fix:progress" => {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(data) {
let check_id = parsed
.get("checkId")
.and_then(|v| v.as_str())
.unwrap_or("?");
let path = parsed.get("path").and_then(|v| v.as_str()).unwrap_or("?");
let action = parsed
.get("action")
.and_then(|v| v.as_str())
.unwrap_or("MODIFY");
current_check = Some(check_id.to_string());
if progress_line_shown {
erase_prev_line();
}
let label = check_label(check_id);
let short_path = shorten_path(path);
eprintln!(
" {} {:<16} {:<36} [{}] {}",
dim("◓"),
label,
short_path,
action,
dim("generating..."),
);
progress_line_shown = true;
}
}
"fix:applied" => {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(data) {
let check_id = parsed
.get("checkId")
.and_then(|v| v.as_str())
.unwrap_or("?");
let path = parsed.get("path").and_then(|v| v.as_str()).unwrap_or("?");
completed += 1;
applied += 1;
current_check = None;
if progress_line_shown {
erase_prev_line();
}
let label = check_label(check_id);
let short_path = shorten_path(path);
let action_str = if path.contains("docs/") || path.ends_with(".md") {
"CREATE"
} else {
"MODIFY"
};
eprintln!(
" {} {:<16} {:<36} [{}]",
green(check_mark()),
label,
short_path,
action_str,
);
fix_lines.push((
check_id.to_string(),
path.to_string(),
action_str.to_string(),
true,
));
eprintln!(" {}", render_progress_bar(completed, total));
progress_line_shown = true;
}
}
"fix:failed" => {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(data) {
let check_id = parsed
.get("checkId")
.and_then(|v| v.as_str())
.unwrap_or("?");
let error = parsed
.get("error")
.and_then(|v| v.as_str())
.unwrap_or("unknown error");
completed += 1;
failed += 1;
current_check = None;
if progress_line_shown {
erase_prev_line();
}
let label = check_label(check_id);
eprintln!(" {} {:<16} {}", red("✖"), label, dim(error));
fix_lines.push((
check_id.to_string(),
String::new(),
String::new(),
false,
));
eprintln!(" {}", render_progress_bar(completed, total));
progress_line_shown = true;
}
}
"fix:done" => {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(data) {
if progress_line_shown {
erase_prev_line();
}
done_data = Some(parsed);
}
}
"fix:error" => {
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(data) {
let error = parsed
.get("error")
.and_then(|v| v.as_str())
.unwrap_or("unknown error");
eprintln!("\n Fix error: {error}");
return 1;
}
}
_ => {}
}
}
}
}
if let Some(done) = done_data {
let summary = done.get("summary");
let score_before = summary
.and_then(|s| s.get("scoreBefore"))
.and_then(serde_json::Value::as_f64)
.unwrap_or(0.0);
let score_after = summary
.and_then(|s| s.get("scoreAfter"))
.and_then(serde_json::Value::as_f64)
.unwrap_or(0.0);
eprintln!();
eprintln!(" {}", separator());
let score_text = format!("{score_before:.0} → {score_after:.0}");
let pad = SEP_WIDTH.saturating_sub("SCORE".len() + score_text.len());
eprintln!(
" {}{}{}",
bold("SCORE"),
" ".repeat(pad),
score_color(score_after, &score_text)
);
eprintln!(" {}", separator());
eprintln!(
" {} applied · {} failed",
green(&applied.to_string()),
if failed > 0 {
bold_red(&failed.to_string())
} else {
dim("0")
},
);
eprintln!(" {}", separator());
if let Some(unfixed) = done.get("unfixedFindings").and_then(|v| v.as_array()) {
if !unfixed.is_empty() {
eprintln!();
eprintln!(
" {} ({} finding{})",
bold("MANUAL ACTION NEEDED"),
unfixed.len(),
plural(unfixed.len())
);
for item in unfixed {
let check_id = item.get("checkId").and_then(|v| v.as_str()).unwrap_or("?");
let message = item.get("message").and_then(|v| v.as_str()).unwrap_or("");
let label = check_label(check_id);
eprintln!(" {} {}", yellow("▸"), label);
if !message.is_empty() {
eprintln!(" {}", dim(message));
}
}
}
}
eprintln!();
} else if let Some(check) = current_check {
eprintln!("\nStream ended unexpectedly while processing: {check}");
return 1;
}
0
}
fn shorten_path(path: &str) -> &str {
if path.len() <= 40 {
return path;
}
let start = path.len().saturating_sub(40);
path[start..]
.find('/')
.map_or(path, |pos| &path[start + pos + 1..])
}
const VALID_DOC_TYPES: &[&str] = &[
"ai-literacy",
"art5-screening",
"technical-documentation",
"incident-report",
"declaration-of-conformity",
"monitoring-policy",
"fria",
"worker-notification",
"risk-management",
"data-governance",
"qms",
"instructions-for-use",
"gpai-transparency",
"gpai-systemic-risk",
];
pub async fn run_doc_generate_fix(
doc_type: &str,
agent: Option<&str>,
json: bool,
path: Option<&str>,
config: &TuiConfig,
) -> i32 {
if doc_type == "all" {
if json {
println!(
"{{\"action\": \"generate-all\", \"docTypes\": {}}}",
serde_json::to_string(VALID_DOC_TYPES).unwrap_or_default()
);
} else {
println!("Generating all compliance documents...\n");
}
let mut failures = 0;
for dtype in VALID_DOC_TYPES {
let code = run_doc_generate_single(dtype, agent, json, path, config).await;
if code != 0 {
failures += 1;
eprintln!(" [FAIL] {dtype}");
} else if !json {
println!(" [OK] {dtype}");
}
}
if !json {
println!(
"\nDone: {} types generated, {} failures.",
VALID_DOC_TYPES.len() - failures,
failures
);
}
i32::from(failures > 0)
} else {
run_doc_generate_single(doc_type, agent, json, path, config).await
}
}
async fn run_doc_generate_single(
doc_type: &str,
agent: Option<&str>,
json: bool,
path: Option<&str>,
config: &TuiConfig,
) -> i32 {
if !VALID_DOC_TYPES.contains(&doc_type) {
eprintln!("Error: Invalid document type: {doc_type}");
eprintln!("Valid types: {}", VALID_DOC_TYPES.join(", "));
return 1;
}
let project_path = resolve_project_path_buf(path);
let agent_name = agent.unwrap_or("default");
let client = match ensure_engine(config).await {
Ok(c) => c,
Err(code) => return code,
};
if !json {
println!("Generating '{doc_type}' document for passport '{agent_name}'...");
}
let body = serde_json::json!({
"path": project_path.to_string_lossy(),
"name": agent_name,
"docType": doc_type,
});
match client.post_json("/fix/doc/generate", &body).await {
Ok(result) => {
if let Some(err) = result.get("error").and_then(|v| v.as_str()) {
let msg = result
.get("message")
.and_then(|v| v.as_str())
.unwrap_or(err);
eprintln!("Error: {msg}");
return 1;
}
if json {
println!(
"{}",
serde_json::to_string_pretty(&result).unwrap_or_default()
);
return 0;
}
let saved_path = result
.get("savedPath")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let prefilled = result
.get("prefilledFields")
.and_then(|v| v.as_array())
.map_or(0, std::vec::Vec::len);
let manual = result
.get("manualFields")
.and_then(|v| v.as_array())
.map_or(0, std::vec::Vec::len);
println!("\nDocument generated:");
println!(" Type: {doc_type}");
println!(" Passport: {agent_name}");
println!(" Saved to: {saved_path}");
println!(" Prefilled: {prefilled} field(s)");
println!(" Manual: {manual} field(s) remaining");
if let Some(fields) = result.get("manualFields").and_then(|v| v.as_array())
&& !fields.is_empty()
{
println!("\n Fields to complete manually:");
for field in fields {
if let Some(f) = field.as_str() {
println!(" - {f}");
}
}
}
0
}
Err(e) => {
eprintln!("Error: Failed to generate document: {e}");
1
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pad_article_short() {
assert_eq!(pad_article("Art. 4"), "Art. 4 ");
assert_eq!(pad_article("Art. 14"), "Art. 14 ");
}
#[test]
fn test_pad_article_long() {
let padded = pad_article("Art. 50(1)");
assert_eq!(padded, "Art. 50(1)");
let padded2 = pad_article("Art. 5(1)(f)");
assert_eq!(padded2, "Art. 5(1)(f)");
}
#[test]
fn test_normalize_code_desc_strips_at_suffix() {
assert_eq!(
normalize_code_desc("Inline fix: wrap bare LLM call at src/chat/anthropic.ts:3"),
"Wrap bare LLM call"
);
}
#[test]
fn test_normalize_code_desc_strips_from_suffix() {
assert_eq!(
normalize_code_desc("Inline fix: remove banned dependency from package.json"),
"Remove banned dependency"
);
}
#[test]
fn test_normalize_code_desc_no_prefix() {
assert_eq!(
normalize_code_desc("fix security risk at src/foo.ts:10"),
"Fix security risk"
);
}
#[test]
fn test_normalize_code_desc_plain() {
assert_eq!(
normalize_code_desc("Add error handling"),
"Add error handling"
);
}
#[test]
fn test_scaffold_hint_by_fix_type() {
assert_eq!(
scaffold_hint("l1-fria", "template_generation"),
"Fill placeholders and review with your compliance team"
);
assert_eq!(
scaffold_hint("l3-bias", "config_fix"),
"Populate test data paths and adjust fairness thresholds"
);
assert_eq!(
scaffold_hint("l3-ci", "config_fix"),
"Adjust workflow triggers and scan thresholds for your CI"
);
assert_eq!(
scaffold_hint("l3-nhi", "config_fix"),
"Move actual secrets to vault or environment variables"
);
assert_eq!(
scaffold_hint("l3-other", "config_fix"),
"Review generated config and customize for your project"
);
assert_eq!(
scaffold_hint("x", "code_injection"),
"Import this module into your codebase and wire into your pipeline"
);
assert_eq!(
scaffold_hint("x", "unknown_type"),
"Review and customize for your project"
);
assert_eq!(
scaffold_hint("l2-fria", "ai_enrichment"),
"Review AI-enriched sections and approve with your compliance team"
);
}
#[test]
fn test_extract_entries_scaffold_detection() {
let resp = serde_json::json!({
"results": [
{
"applied": true,
"plan": {
"checkId": "l4-bare-llm",
"fixType": "code_injection",
"article": "Art. 50(1)",
"description": "Inline fix: wrap bare LLM call at src/foo.ts:3",
"actions": [{ "type": "splice", "path": "src/foo.ts" }]
}
},
{
"applied": true,
"plan": {
"checkId": "l1-fria",
"fixType": "template_generation",
"article": "Art. 27",
"description": "FRIA Template",
"actions": [{ "type": "create", "path": "docs/fria.md" }]
}
},
{
"applied": true,
"plan": {
"checkId": "l4-hitl",
"fixType": "code_injection",
"article": "Art. 14",
"description": "Human Approval Gate",
"actions": [{ "type": "create", "path": "src/middleware/hitl.ts" }]
}
}
]
});
let entries = extract_entries(&resp);
assert_eq!(entries.len(), 3);
assert!(
!entries[0].is_scaffold,
"splice action should not be scaffold"
);
assert!(
entries[1].is_scaffold,
"create-only action should be scaffold"
);
assert!(
entries[2].is_scaffold,
"create-only code_injection should be scaffold"
);
}
#[test]
fn test_render_unfixed_findings_present() {
unsafe {
std::env::set_var("NO_COLOR", "1");
}
let resp = serde_json::json!({
"results": [],
"summary": { "total": 0, "applied": 0, "failed": 0, "scoreBefore": 91, "scoreAfter": 91 },
"unfixedFindings": [
{
"checkId": "l4-logging",
"message": "Art. 12: No structured logging detected",
"severity": "medium",
"fix": "Add structured logging to your application"
},
{
"checkId": "l4-record-keeping",
"message": "Art. 12: No record-keeping policy found",
"severity": "high",
"fix": "Create a record-keeping policy document"
}
]
});
let mut o = String::new();
render_unfixed_findings(&mut o, &resp);
assert!(o.contains("MANUAL ACTION NEEDED"), "should contain header");
assert!(o.contains("2 findings"), "should show count");
assert!(
o.contains("Logging"),
"should contain check label for l4-logging"
);
assert!(
o.contains("Record Keeping"),
"should contain check label for l4-record-keeping"
);
assert!(o.contains("Fix"), "should show fix hints");
}
#[test]
fn test_render_unfixed_findings_empty() {
let resp = serde_json::json!({
"results": [],
"summary": { "total": 0, "applied": 0, "failed": 0, "scoreBefore": 91, "scoreAfter": 91 },
"unfixedFindings": []
});
let mut o = String::new();
render_unfixed_findings(&mut o, &resp);
assert!(
o.is_empty(),
"should produce no output for empty unfixed findings"
);
}
#[test]
fn valid_doc_types_excludes_iso_soa() {
assert!(
!VALID_DOC_TYPES.contains(&"iso42001-soa"),
"VALID_DOC_TYPES must NOT include iso42001-soa — ISO 42001 removed in V1-M22"
);
}
#[test]
fn valid_doc_types_excludes_iso_rr() {
assert!(
!VALID_DOC_TYPES.contains(&"iso42001-risk-register"),
"VALID_DOC_TYPES must NOT include iso42001-risk-register — ISO 42001 removed in V1-M22"
);
}
#[test]
fn valid_doc_types_excludes_iso_ap() {
assert!(
!VALID_DOC_TYPES.contains(&"iso42001-ai-policy"),
"VALID_DOC_TYPES must NOT include iso42001-ai-policy — ISO 42001 removed in V1-M22"
);
}
#[test]
fn valid_doc_types_count_after_iso_removal() {
assert_eq!(
VALID_DOC_TYPES.len(),
14,
"VALID_DOC_TYPES should have 14 entries (EU AI Act only), got {}",
VALID_DOC_TYPES.len()
);
}
}