use crate::params::AuditParams;
use fallow_api::{
AnalysisOptions, AuditGate, AuditOptions, run_audit as run_audit_api,
serialize_audit_programmatic_json,
};
use rmcp::ErrorData as McpError;
use rmcp::model::{CallToolResult, Content};
use super::{
VALID_AUDIT_GATES,
api_runtime::{
env_diff_file, json_success, non_empty_path, non_empty_string, programmatic_error_body,
run_api_blocking,
},
fallback_policy::{baseline_fallback_reason, filled, grouped_fallback_reason},
push_global, push_scope, push_str_flag, run_tool, validation_error_body,
};
pub async fn run_audit(binary: &str, params: AuditParams) -> Result<CallToolResult, McpError> {
if !requires_cli_fallback(¶ms) {
let options = match audit_options_from_params(¶ms) {
Ok(options) => options,
Err(msg) => return Ok(CallToolResult::error(vec![Content::text(msg)])),
};
let result = run_api_blocking("audit", move || {
run_audit_api(&options).and_then(serialize_audit_programmatic_json)
})
.await?
.map_or_else(
|err| CallToolResult::error(vec![Content::text(programmatic_error_body(&err))]),
|value| json_success(&value),
);
return Ok(result);
}
match build_audit_args(¶ms) {
Ok(args) => run_tool(binary, "audit", &args).await,
Err(msg) => Ok(CallToolResult::error(vec![Content::text(msg)])),
}
}
pub fn run_audit_api_value(params: &AuditParams) -> Result<Option<serde_json::Value>, String> {
if requires_cli_fallback(params) {
return Ok(None);
}
let options = audit_options_from_params(params)?;
run_audit_api(&options)
.and_then(serialize_audit_programmatic_json)
.map(Some)
.map_err(|err| programmatic_error_body(&err))
}
pub fn build_audit_args(params: &AuditParams) -> Result<Vec<String>, String> {
if let Some(ref gate) = params.gate
&& !VALID_AUDIT_GATES.contains(&gate.as_str())
{
return Err(validation_error_body(format!(
"Invalid gate '{gate}'. Valid values: new-only, all"
)));
}
let mut args = vec![
"audit".to_string(),
"--format".to_string(),
"json".to_string(),
"--quiet".to_string(),
"--explain".to_string(),
];
push_global(
&mut args,
params.root.as_deref(),
params.config.as_deref(),
params.no_cache,
params.threads,
);
push_str_flag(&mut args, "--base", params.base.as_deref());
push_scope(&mut args, params.production, params.workspace.as_deref());
push_audit_production_flags(&mut args, params);
push_str_flag(&mut args, "--group-by", params.group_by.as_deref());
push_str_flag(&mut args, "--gate", params.gate.as_deref());
push_audit_baseline_flags(&mut args, params);
if params.explain_skipped == Some(true) {
args.push("--explain-skipped".to_string());
}
push_audit_coverage_flags(&mut args, params);
Ok(args)
}
fn push_audit_production_flags(args: &mut Vec<String>, params: &AuditParams) {
if params.production_dead_code == Some(true) {
args.push("--production-dead-code".to_string());
}
if params.production_health == Some(true) {
args.push("--production-health".to_string());
}
if params.production_dupes == Some(true) {
args.push("--production-dupes".to_string());
}
}
fn push_audit_baseline_flags(args: &mut Vec<String>, params: &AuditParams) {
push_str_flag(
args,
"--dead-code-baseline",
params.dead_code_baseline.as_deref(),
);
push_str_flag(args, "--health-baseline", params.health_baseline.as_deref());
push_str_flag(args, "--dupes-baseline", params.dupes_baseline.as_deref());
}
fn push_audit_coverage_flags(args: &mut Vec<String>, params: &AuditParams) {
if let Some(max_crap) = params.max_crap {
args.extend(["--max-crap".to_string(), format!("{max_crap}")]);
}
push_str_flag(args, "--coverage", params.coverage.as_deref());
push_str_flag(args, "--coverage-root", params.coverage_root.as_deref());
if params.include_entry_exports == Some(true) {
args.push("--include-entry-exports".to_string());
}
push_str_flag(
args,
"--runtime-coverage",
params.runtime_coverage.as_deref(),
);
if let Some(min_invocations_hot) = params.min_invocations_hot {
args.extend([
"--min-invocations-hot".to_string(),
format!("{min_invocations_hot}"),
]);
}
}
fn requires_cli_fallback(params: &AuditParams) -> bool {
cli_fallback_reason(params).is_some()
}
fn cli_fallback_reason(params: &AuditParams) -> Option<&'static str> {
let gate = params.gate.as_deref().unwrap_or("new-only");
if !VALID_AUDIT_GATES.contains(&gate) {
return Some("invalid gate");
}
baseline_fallback_reason(params.dead_code_baseline.as_deref(), None)
.or_else(|| baseline_fallback_reason(params.health_baseline.as_deref(), None))
.or_else(|| baseline_fallback_reason(params.dupes_baseline.as_deref(), None))
.or_else(|| grouped_fallback_reason(params.group_by.as_deref()))
.map(|_| "baseline or grouped output")
.or_else(|| (params.explain_skipped == Some(true)).then_some("duplication skipped notes"))
.or_else(|| filled(params.runtime_coverage.as_deref()).then_some("runtime coverage"))
}
fn audit_options_from_params(params: &AuditParams) -> Result<AuditOptions, String> {
let gate = audit_gate_from_param(params.gate.as_deref())?;
Ok(AuditOptions {
analysis: AnalysisOptions {
root: non_empty_path(params.root.as_deref()),
config_path: non_empty_path(params.config.as_deref()),
no_cache: params.no_cache.unwrap_or(false),
threads: params.threads,
diff_file: env_diff_file(),
production: params.production.unwrap_or(false),
production_override: params.production,
changed_since: None,
workspace: non_empty_string(params.workspace.as_deref()).map(|value| vec![value]),
changed_workspaces: None,
explain: true,
},
base: non_empty_string(params.base.as_deref()),
production: params.production.unwrap_or(false),
production_dead_code: params.production_dead_code,
production_health: params.production_health,
production_dupes: params.production_dupes,
gate,
max_crap: params.max_crap,
coverage: non_empty_path(params.coverage.as_deref()),
coverage_root: non_empty_path(params.coverage_root.as_deref()),
include_entry_exports: params.include_entry_exports.unwrap_or(false),
runtime_coverage: non_empty_path(params.runtime_coverage.as_deref()),
min_invocations_hot: params.min_invocations_hot.unwrap_or(100),
})
}
fn audit_gate_from_param(value: Option<&str>) -> Result<AuditGate, String> {
match value.unwrap_or("new-only") {
"new-only" => Ok(AuditGate::NewOnly),
"all" => Ok(AuditGate::All),
other => Err(validation_error_body(format!(
"Invalid gate '{other}'. Valid values: new-only, all"
))),
}
}
#[cfg(test)]
mod tests {
use std::process::Command;
use rmcp::model::RawContent;
use super::*;
#[test]
fn default_new_only_audit_uses_programmatic_api_route() {
let params = AuditParams::default();
assert!(!requires_cli_fallback(¶ms));
let options = audit_options_from_params(¶ms).expect("audit options");
assert_eq!(options.gate, AuditGate::NewOnly);
}
#[test]
fn gate_all_audit_uses_programmatic_api_route() {
let params = AuditParams {
gate: Some("all".to_string()),
..AuditParams::default()
};
assert!(!requires_cli_fallback(¶ms));
let options = audit_options_from_params(¶ms).expect("audit options");
assert_eq!(options.gate, AuditGate::All);
assert!(options.analysis.explain);
}
#[test]
fn cli_only_audit_surfaces_keep_fallback() {
let baseline = AuditParams {
gate: Some("all".to_string()),
dead_code_baseline: Some("baseline.json".to_string()),
..AuditParams::default()
};
let grouped = AuditParams {
gate: Some("all".to_string()),
group_by: Some("owner".to_string()),
..AuditParams::default()
};
let runtime = AuditParams {
gate: Some("all".to_string()),
runtime_coverage: Some("coverage".to_string()),
..AuditParams::default()
};
assert!(requires_cli_fallback(&baseline));
assert!(requires_cli_fallback(&grouped));
assert!(requires_cli_fallback(&runtime));
}
#[tokio::test]
async fn run_audit_gate_all_api_path_returns_json_without_cli_binary() {
let project = audit_fixture();
let result = run_audit(
"unused-binary-on-api-path",
AuditParams {
root: Some(project.path().display().to_string()),
base: Some("HEAD".to_string()),
gate: Some("all".to_string()),
no_cache: Some(true),
..AuditParams::default()
},
)
.await
.expect("api result");
assert_eq!(result.is_error, Some(false));
let text = match &result.content[0].raw {
RawContent::Text(text) => &text.text,
_ => panic!("expected text content"),
};
let json: serde_json::Value = serde_json::from_str(text).expect("json");
assert_eq!(json["kind"], "audit");
assert_eq!(json["command"], "audit");
assert!(json["dead_code"].is_object());
}
#[tokio::test]
async fn run_audit_default_new_only_api_path_marks_introduced_without_cli_binary() {
let project = audit_fixture();
let result = run_audit(
"unused-binary-on-api-path",
AuditParams {
root: Some(project.path().display().to_string()),
base: Some("HEAD".to_string()),
no_cache: Some(true),
..AuditParams::default()
},
)
.await
.expect("api result");
assert_eq!(result.is_error, Some(false));
let text = match &result.content[0].raw {
RawContent::Text(text) => &text.text,
_ => panic!("expected text content"),
};
let json: serde_json::Value = serde_json::from_str(text).expect("json");
assert_eq!(json["kind"], "audit");
assert_eq!(json["attribution"]["gate"], "new-only");
assert_eq!(json["attribution"]["dead_code_introduced"], 1);
assert_eq!(json["dead_code"]["unused_files"][0]["introduced"], true);
}
fn audit_fixture() -> tempfile::TempDir {
let project = tempfile::tempdir().expect("project");
std::fs::create_dir_all(project.path().join("src")).expect("create src");
std::fs::write(
project.path().join("package.json"),
r#"{"name":"audit-api","type":"module","main":"src/index.ts"}"#,
)
.expect("write package");
std::fs::write(
project.path().join("src/index.ts"),
"console.log('entry');\n",
)
.expect("write entry");
git(project.path(), &["init"]);
git(project.path(), &["add", "."]);
git(
project.path(),
&[
"-c",
"user.email=test@example.com",
"-c",
"user.name=Test",
"-c",
"commit.gpgsign=false",
"commit",
"-m",
"initial",
],
);
std::fs::write(
project.path().join("src/feature.ts"),
"export const unused = 1;\n",
)
.expect("write changed source");
project
}
fn git(root: &std::path::Path, args: &[&str]) {
let status = Command::new("git")
.args(args)
.current_dir(root)
.status()
.expect("git command");
assert!(status.success(), "git {args:?} failed");
}
}