use std::io::IsTerminal as _;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use crate::config::TuiConfig;
use crate::engine_client::EngineClient;
use crate::types::Severity;
use super::format::colors::{bold, check_mark, dim, green, red, tree_branch, tree_end};
use super::format::{format_human, format_json, format_sarif, print_paged, FormatOptions};
#[allow(clippy::too_many_arguments, clippy::fn_params_excessive_bools)]
pub async fn run_headless_scan(
ci: bool,
json: bool,
sarif: bool,
_no_tui: bool,
threshold: u32,
fail_on: Option<&str>,
deep: bool,
llm: bool,
cloud: bool,
quiet: bool,
agent: Option<&str>,
path: Option<&str>,
config: &TuiConfig,
) -> i32 {
let engine_url = config
.engine_url_override
.clone()
.unwrap_or_else(|| config.engine_url());
let client = EngineClient::from_url(&engine_url);
match client.status().await {
Ok(status) if status.ready => {}
Ok(_) => {
eprintln!("Error: Engine is not ready");
return 1;
}
Err(e) => {
eprintln!("Error: Cannot connect to engine at {engine_url}: {e}");
eprintln!("Start the engine with: cd engine && npm run dev");
return 1;
}
}
let scan_elapsed = std::time::Instant::now();
if cloud {
eprintln!("Error: --cloud (Tier 3) is not yet available. Planned for Month 3-4.");
return 1;
}
let scan_path = super::common::resolve_project_path(path);
if llm && !super::common::check_llm_key(&scan_path) {
super::common::print_llm_key_error();
return 1;
}
if deep {
if !check_uv_available() {
return 1;
}
if !json && !sarif {
show_deep_scan_tools();
}
}
if llm && !json && !sarif {
if let Ok(info) = client.get_json("/llm/info").await {
let model = info.get("classify").and_then(|v| v.get("modelId")).and_then(|v| v.as_str()).unwrap_or("unknown");
let provider = info.get("classify").and_then(|v| v.get("provider")).and_then(|v| v.as_str()).unwrap_or("unknown");
let source = info.get("classify").and_then(|v| v.get("source")).and_then(|v| v.as_str()).unwrap_or("default");
let source_label = if source == "env" {
let env_var = info.get("classify").and_then(|v| v.get("envVar")).and_then(|v| v.as_str()).unwrap_or("env");
format!(" ({})", env_var)
} else {
String::new()
};
eprintln!(" LLM: {} via {}{}", bold(model), provider, dim(&source_label));
if source != "env" {
eprintln!(" {}", dim("Override: set COMPLIOR_MODEL_CLASSIFY in .complior/.env"));
}
}
}
let spinner_active = Arc::new(AtomicBool::new(false));
let spinner_handle = if !json && !sarif && std::io::stderr().is_terminal() {
Some(start_spinner(Arc::clone(&spinner_active)))
} else {
None
};
let result = if deep && llm {
let body = serde_json::json!({ "path": scan_path });
let _tier2_result = match client.post_json("/scan/tier2", &body).await {
Ok(r) => r,
Err(e) => { stop_spinner(&spinner_active, spinner_handle); eprintln!("Tier 2 scan failed: {e}"); return 1; }
};
let llm_result = match client.post_json_long("/scan/llm", &body).await {
Ok(r) => r,
Err(e) => { stop_spinner(&spinner_active, spinner_handle); eprintln!("LLM scan failed: {e}"); return 1; }
};
match serde_json::from_value::<crate::types::ScanResult>(llm_result) {
Ok(r) => { stop_spinner(&spinner_active, spinner_handle); r },
Err(e) => { stop_spinner(&spinner_active, spinner_handle); eprintln!("Failed to parse scan result: {e}"); return 1; }
}
} else if deep {
let body = serde_json::json!({ "path": scan_path });
match client.post_json("/scan/tier2", &body).await {
Ok(r) => {
stop_spinner(&spinner_active, spinner_handle);
match serde_json::from_value::<crate::types::ScanResult>(r) {
Ok(result) => result,
Err(e) => { eprintln!("Failed to parse scan result: {e}"); return 1; }
}
},
Err(e) => { stop_spinner(&spinner_active, spinner_handle); eprintln!("Tier 2 scan failed: {e}"); return 1; }
}
} else if llm {
let body = serde_json::json!({ "path": scan_path });
match client.post_json_long("/scan/llm", &body).await {
Ok(r) => {
stop_spinner(&spinner_active, spinner_handle);
match serde_json::from_value::<crate::types::ScanResult>(r) {
Ok(result) => result,
Err(e) => { eprintln!("Failed to parse scan result: {e}"); return 1; }
}
},
Err(e) => { stop_spinner(&spinner_active, spinner_handle); eprintln!("LLM scan failed: {e}"); return 1; }
}
} else {
match client.scan(&scan_path).await {
Ok(r) => { stop_spinner(&spinner_active, spinner_handle); r },
Err(e) => {
stop_spinner(&spinner_active, spinner_handle);
eprintln!("Scan failed: {e}");
return 1;
}
}
};
let result = if let Some(agent_name) = agent {
let filtered_findings: Vec<_> = result.findings
.into_iter()
.filter(|f| f.agent_id.as_deref() == Some(agent_name))
.collect();
crate::types::ScanResult {
findings: filtered_findings,
..result
}
} else {
result
};
let framework_scores = client.framework_scores().await.ok();
if json {
println!("{}", format_json(&result));
} else if sarif {
println!("{}", format_sarif(&result));
} else {
let prev_score = if deep {
read_last_score(&scan_path)
} else {
None
};
let finding_count = result.findings.iter()
.filter(|f| f.r#type == crate::types::CheckResultType::Fail)
.count();
let layers = if deep && llm { "L1-L5" } else if deep { "L1-L4 + external" } else if llm { "L1-L4 + L5" } else { "L1-L4" };
let findings_label = if llm {
let llm_count = result.findings.iter()
.filter(|f| f.r#type == crate::types::CheckResultType::Fail && f.l5_analyzed == Some(true))
.count();
let base_count = finding_count.saturating_sub(llm_count);
if llm_count > 0 {
format!("{base_count} findings + {llm_count} LLM")
} else {
format!("{finding_count} findings")
}
} else {
format!("{finding_count} findings")
};
eprintln!(
" {} Scan complete ({}) {} {} {}",
green(check_mark()),
layers,
dim(&findings_label),
dim(&format!("Score: {:.0}/100", result.score.total_score)),
dim(&format!("{:.0}s", scan_elapsed.elapsed().as_secs_f64())),
);
eprintln!();
let opts = FormatOptions {
framework_scores: framework_scores.as_ref().map(|mf| mf.frameworks.clone()),
quiet,
prev_score,
};
let text = format_human(&result, &opts);
print_paged(&text);
}
if ci {
let score = result.score.total_score.round() as u32;
let grade = super::format::colors::resolve_grade(result.score.total_score);
let finding_count = result.findings.iter()
.filter(|f| f.r#type == crate::types::CheckResultType::Fail)
.count();
eprintln!("COMPLIOR_SCORE={score} COMPLIOR_GRADE={grade} COMPLIOR_FINDINGS={finding_count}");
}
if !ci && !json && !sarif {
let hint_url = format!(
"/agent/list?path={}",
super::common::url_encode(&scan_path)
);
if let Ok(list) = client.get_json(&hint_url).await {
let count = list.as_array().map_or(0, std::vec::Vec::len);
if count == 0 {
eprintln!();
eprintln!(
" {}",
super::format::colors::dim(
"Hint: No agent passports found. Run `complior agent init` for \
passport-aware scanning and pre-filled fix scaffolds."
)
);
}
}
}
if ci {
let score = result.score.total_score.round() as u32;
if score < threshold {
eprintln!(
"CI FAIL: Score {score} is below threshold {threshold}"
);
return 2;
}
if let Some(level) = fail_on {
let has_severity = result.findings.iter().any(|f| {
matches!(
(level, &f.severity),
("critical", Severity::Critical)
| ("high", Severity::Critical | Severity::High)
| ("medium", Severity::Critical | Severity::High | Severity::Medium)
| ("low", Severity::Critical | Severity::High | Severity::Medium | Severity::Low)
)
});
if has_severity {
eprintln!(
"CI FAIL: Found findings at severity '{level}' or above"
);
return 2;
}
}
}
0
}
pub async fn run_scan_diff(
base_branch: &str,
json: bool,
fail_on_regression: bool,
comment: bool,
path: Option<&str>,
config: &TuiConfig,
) -> i32 {
let engine_url = config
.engine_url_override
.clone()
.unwrap_or_else(|| config.engine_url());
let client = EngineClient::from_url(&engine_url);
match client.status().await {
Ok(status) if status.ready => {}
Ok(_) => { eprintln!("Error: Engine is not ready"); return 1; }
Err(e) => {
eprintln!("Error: Cannot connect to engine at {engine_url}: {e}");
return 1;
}
}
let scan_path = super::common::resolve_project_path(path);
let changed_files = get_changed_files(base_branch, &scan_path);
if changed_files.is_empty() {
if !json {
println!("No changed files found between HEAD and {base_branch}.");
}
return 0;
}
if !json {
eprintln!("Scanning diff against {base_branch} ({} files changed)...", changed_files.len());
}
let body = serde_json::json!({
"path": scan_path,
"changedFiles": changed_files,
"markdown": comment,
});
match client.post_json("/scan/diff", &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());
} else {
print_diff_human(&result);
}
if comment
&& let Some(md) = result.get("markdown").and_then(|v| v.as_str()) {
post_pr_comment(md);
}
if fail_on_regression {
let regression = result.get("hasRegression").and_then(serde_json::Value::as_bool).unwrap_or(false);
if regression {
eprintln!("CI FAIL: Compliance regression detected");
return 2;
}
}
0
}
Err(e) => {
eprintln!("Scan diff failed: {e}");
1
}
}
}
fn start_spinner(active: Arc<AtomicBool>) -> tokio::task::JoinHandle<()> {
active.store(true, Ordering::SeqCst);
tokio::spawn(async move {
let frames = ['◐', '◓', '◑', '◒'];
let mut i: usize = 0;
let start = std::time::Instant::now();
while active.load(Ordering::SeqCst) {
let elapsed = start.elapsed().as_secs();
eprint!("\r {} Scanning... ({}s)", frames[i % frames.len()], elapsed);
i += 1;
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
}
eprint!("\r{}\r", " ".repeat(40)); })
}
fn stop_spinner(active: &AtomicBool, handle: Option<tokio::task::JoinHandle<()>>) {
active.store(false, Ordering::SeqCst);
if let Some(h) = handle {
h.abort();
eprint!("\r{}\r", " ".repeat(40)); }
}
fn check_uv_available() -> bool {
match std::process::Command::new("uv")
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
{
Ok(s) if s.success() => true,
_ => {
eprintln!(" {} {}", red("X"), bold("Error: uv not found. Deep scan requires uv for tool management."));
eprintln!(" Install: curl -LsSf https://astral.sh/uv/install.sh | sh");
false
}
}
}
fn show_deep_scan_tools() {
let tools_dir = dirs::home_dir()
.map(|h| h.join(".complior/tools"))
.unwrap_or_default();
let tools = [
("Semgrep", "semgrep"),
("Bandit", "bandit"),
("ModelScan", "modelscan"),
];
let all_cached = tools_dir.exists()
&& tools.iter().all(|(_, dir_name)| tools_dir.join(dir_name).exists());
if all_cached {
eprintln!(
" Deep scan tools: Semgrep, Bandit, ModelScan {}",
green(&format!("{} ready", check_mark()))
);
} else {
eprintln!();
eprintln!(" {}", bold("First run — downloading deep scan tools (~150MB)"));
for (i, (name, dir_name)) in tools.iter().enumerate() {
let cached = tools_dir.join(dir_name).exists();
let status = if cached {
format!("{} cached", green(check_mark()))
} else {
dim("pending").clone()
};
let bar = if cached {
super::format::colors::bar_filled().repeat(20)
} else {
super::format::colors::bar_empty().repeat(20)
};
let prefix = if i < tools.len() - 1 { tree_branch() } else { tree_end() };
eprintln!(" {} {:<20}{} {}", prefix, name, dim(&bar), status);
}
eprintln!();
}
}
fn read_last_score(project_path: &str) -> Option<f64> {
let path = std::path::Path::new(project_path).join(".complior/last-scan.json");
let content = std::fs::read_to_string(path).ok()?;
let v: serde_json::Value = serde_json::from_str(&content).ok()?;
v.get("score")?.get("totalScore")?.as_f64()
}
fn get_changed_files(base_branch: &str, project_path: &str) -> Vec<String> {
let output = std::process::Command::new("git")
.args(["diff", "--name-only", &format!("{base_branch}...HEAD")])
.current_dir(project_path)
.output();
match output {
Ok(o) if o.status.success() => {
String::from_utf8_lossy(&o.stdout)
.lines()
.filter(|l| !l.is_empty())
.map(String::from)
.collect()
}
Ok(o) => {
let fallback = std::process::Command::new("git")
.args(["diff", "--name-only", base_branch])
.current_dir(project_path)
.output();
match fallback {
Ok(f) if f.status.success() => {
String::from_utf8_lossy(&f.stdout)
.lines()
.filter(|l| !l.is_empty())
.map(String::from)
.collect()
}
_ => {
eprintln!("Warning: git diff failed: {}", String::from_utf8_lossy(&o.stderr).trim());
vec![]
}
}
}
Err(e) => {
eprintln!("Warning: Could not run git: {e}");
vec![]
}
}
}
fn print_diff_human(value: &serde_json::Value) {
let before = value.get("scoreBefore").and_then(serde_json::Value::as_u64).unwrap_or(0);
let after = value.get("scoreAfter").and_then(serde_json::Value::as_u64).unwrap_or(0);
let delta = value.get("scoreDelta").and_then(serde_json::Value::as_i64).unwrap_or(0);
let new_count = value.get("newFindings").and_then(|v| v.as_array()).map_or(0, std::vec::Vec::len);
let resolved_count = value.get("resolvedFindings").and_then(|v| v.as_array()).map_or(0, std::vec::Vec::len);
let unchanged = value.get("unchangedCount").and_then(serde_json::Value::as_u64).unwrap_or(0);
let regression = value.get("hasRegression").and_then(serde_json::Value::as_bool).unwrap_or(false);
let delta_str = if delta > 0 { format!("+{delta}") } else { format!("{delta}") };
println!();
println!(" Compliance Diff");
println!(" ---------------");
println!(" Score: {before}% -> {after}% ({delta_str}%)");
println!(" New findings: {new_count}");
println!(" Resolved: {resolved_count}");
println!(" Unchanged: {unchanged}");
if regression {
println!(" Status: REGRESSION DETECTED");
} else {
println!(" Status: OK");
}
println!();
if let Some(findings) = value.get("newFindings").and_then(|v| v.as_array())
&& !findings.is_empty() {
println!(" New Findings:");
for f in findings {
let sev = f.get("severity").and_then(|v| v.as_str()).unwrap_or("?");
let msg = f.get("message").and_then(|v| v.as_str()).unwrap_or("?");
let file = f.get("file").and_then(|v| v.as_str()).unwrap_or("-");
println!(" [{sev}] {msg} ({file})");
}
println!();
}
if let Some(findings) = value.get("resolvedFindings").and_then(|v| v.as_array())
&& !findings.is_empty() {
println!(" Resolved Findings:");
for f in findings {
let sev = f.get("severity").and_then(|v| v.as_str()).unwrap_or("?");
let msg = f.get("message").and_then(|v| v.as_str()).unwrap_or("?");
println!(" [{sev}] {msg}");
}
println!();
}
}
fn post_pr_comment(markdown: &str) {
let output = std::process::Command::new("gh")
.args(["pr", "comment", "--body", markdown])
.output();
match output {
Ok(o) if o.status.success() => {
eprintln!("PR comment posted successfully.");
}
Ok(o) => {
eprintln!("Warning: Failed to post PR comment: {}", String::from_utf8_lossy(&o.stderr).trim());
}
Err(_) => {
eprintln!("Warning: `gh` CLI not found. Install GitHub CLI to post PR comments.");
}
}
}