use std::path::Path;
use std::process::Command;
use crate::rules::{Issue, IssueCategory, IssueSource, Severity};
pub struct OrchestratorResult {
pub issues: Vec<Issue>,
pub tools_run: Vec<&'static str>,
pub tools_skipped: Vec<(&'static str, String)>,
}
pub fn run_external_tools(path: &Path) -> OrchestratorResult {
let mut all_issues: Vec<Issue> = vec![];
let mut tools_run = vec![];
let mut tools_skipped = vec![];
match run_oxlint(path) {
ToolResult::Ok(issues) => {
tools_run.push("oxlint");
all_issues.extend(issues);
}
ToolResult::NotInstalled => {
tools_skipped.push(("oxlint", "not found — install: npm i -g oxlint".into()));
}
ToolResult::Failed(msg) => {
tools_skipped.push(("oxlint", format!("failed: {msg}")));
}
}
if path.join("Cargo.lock").exists() {
match run_cargo_audit(path) {
ToolResult::Ok(issues) => {
tools_run.push("cargo-audit");
all_issues.extend(issues);
}
ToolResult::NotInstalled => {
tools_skipped.push((
"cargo-audit",
"not found — install: cargo install cargo-audit".into(),
));
}
ToolResult::Failed(msg) => {
tools_skipped.push(("cargo-audit", format!("failed: {msg}")));
}
}
}
OrchestratorResult {
issues: all_issues,
tools_run,
tools_skipped,
}
}
enum ToolResult {
Ok(Vec<Issue>),
NotInstalled,
Failed(String),
}
fn run_oxlint(path: &Path) -> ToolResult {
let path_str = match path.to_str() {
Some(s) => s,
None => return ToolResult::Failed("invalid path".into()),
};
let output = Command::new("oxlint")
.args(["--format", "json", path_str])
.output();
let output = match output {
Ok(o) => o,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let npx_args = ["oxlint", "--format", "json", path_str];
match Command::new("npx").args(npx_args).output() {
Ok(o) => o,
Err(_) => return ToolResult::NotInstalled,
}
}
Err(e) => return ToolResult::Failed(e.to_string()),
};
let stdout = String::from_utf8_lossy(&output.stdout);
if stdout.trim().is_empty() {
return ToolResult::Ok(vec![]);
}
parse_oxlint_json(&stdout, path)
}
fn parse_oxlint_json(json: &str, base_path: &Path) -> ToolResult {
let value: serde_json::Value = match serde_json::from_str(json) {
Ok(v) => v,
Err(e) => return ToolResult::Failed(format!("JSON parse error: {e}")),
};
let diagnostics = match value.get("diagnostics").and_then(|d| d.as_array()) {
Some(d) => d,
None => return ToolResult::Ok(vec![]),
};
let mut issues = vec![];
for diag in diagnostics {
let message = diag
.get("message")
.and_then(|m| m.as_str())
.unwrap_or("")
.to_string();
let rule = diag
.get("code")
.and_then(|c| c.as_str())
.unwrap_or("oxlint")
.trim_start_matches("eslint(")
.trim_start_matches("oxc(")
.trim_end_matches(')')
.to_string();
let severity_str = diag
.get("severity")
.and_then(|s| s.as_str())
.unwrap_or("warning");
let severity = match severity_str {
"error" => Severity::High,
_ => Severity::Low,
};
let filename = diag.get("filename").and_then(|f| f.as_str()).unwrap_or("");
let file_path = if std::path::Path::new(filename).is_absolute() {
std::path::PathBuf::from(filename)
} else {
base_path.join(filename)
};
let (line, column) = diag
.get("labels")
.and_then(|l| l.as_array())
.and_then(|l| l.first())
.and_then(|l| l.get("span"))
.map(|span| {
let line = span.get("line").and_then(|v| v.as_u64()).unwrap_or(1) as u32;
let col = span.get("column").and_then(|v| v.as_u64()).unwrap_or(1) as u32;
(line, col)
})
.unwrap_or((1, 1));
if message.is_empty() || filename.is_empty() {
continue;
}
issues.push(Issue {
rule,
message,
file: file_path,
line,
column,
severity,
source: IssueSource::OxcLinter,
category: IssueCategory::Performance, });
}
ToolResult::Ok(issues)
}
fn run_cargo_audit(path: &Path) -> ToolResult {
let output = Command::new("cargo")
.args(["audit", "--json"])
.current_dir(path)
.output();
let output = match output {
Ok(o) => o,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return ToolResult::NotInstalled,
Err(e) => return ToolResult::Failed(e.to_string()),
};
let stdout = String::from_utf8_lossy(&output.stdout);
if stdout.trim().is_empty() {
return ToolResult::Ok(vec![]);
}
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("no such command") || stderr.contains("unknown subcommand") {
return ToolResult::NotInstalled;
}
parse_cargo_audit_json(&stdout, path)
}
fn parse_cargo_audit_json(json: &str, base_path: &Path) -> ToolResult {
let value: serde_json::Value = match serde_json::from_str(json) {
Ok(v) => v,
Err(e) => return ToolResult::Failed(format!("JSON parse error: {e}")),
};
let vuln_list = value
.pointer("/vulnerabilities/list")
.and_then(|l| l.as_array());
let Some(vulns) = vuln_list else {
return ToolResult::Ok(vec![]);
};
let mut issues = vec![];
for vuln in vulns {
let advisory = match vuln.get("advisory") {
Some(a) => a,
None => continue,
};
let id = advisory
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("RUSTSEC-UNKNOWN");
let title = advisory
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("Unknown vulnerability");
let pkg_name = vuln
.pointer("/package/name")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let pkg_version = vuln
.pointer("/package/version")
.and_then(|v| v.as_str())
.unwrap_or("?");
let severity = advisory
.get("cvss")
.and_then(|c| c.as_str())
.and_then(extract_cvss_score)
.map(|score| {
if score >= 9.0 {
Severity::Critical
} else if score >= 7.0 {
Severity::High
} else if score >= 4.0 {
Severity::Medium
} else {
Severity::Low
}
})
.unwrap_or(Severity::High);
let cargo_lock = base_path.join("Cargo.lock");
issues.push(Issue {
rule: id.to_string(),
message: format!("{id}: {title} in {pkg_name} v{pkg_version}"),
file: cargo_lock,
line: 1,
column: 1,
severity,
source: IssueSource::CargoAudit,
category: IssueCategory::Dependency,
});
}
ToolResult::Ok(issues)
}
fn extract_cvss_score(cvss: &str) -> Option<f64> {
cvss.rsplit('/')
.next()
.and_then(|s| s.parse::<f64>().ok())
.filter(|&s| s > 0.0 && s <= 10.0)
}