use std::path::{Path, PathBuf};
use serde_json::Value;
use crate::commands::tool_use_classifier as clf;
use crate::error::{CliError, Result};
pub(crate) fn run(observation_file: &Path, json: bool) -> Result<()> {
if !observation_file.exists() {
return Err(CliError::FileNotFound(PathBuf::from(observation_file)));
}
let body = std::fs::read_to_string(observation_file)?;
let obs: Value = serde_json::from_str(&body).map_err(|e| {
CliError::InvalidFormat(format!(
"apr tool-use-lint: failed to parse JSON from {}: {e}",
observation_file.display()
))
})?;
let shape = classify_shape(&obs);
let schema = classify_schema(&obs);
let passthrough = classify_passthrough(&obs);
let fail_reasons: Vec<String> = [
shape.as_ref().and_then(shape_fail_reason),
schema.as_ref().and_then(schema_fail_reason),
passthrough.as_ref().and_then(passthrough_fail_reason),
]
.into_iter()
.flatten()
.collect();
print_report(
observation_file,
shape.as_ref(),
schema.as_ref(),
passthrough.as_ref(),
json,
);
if fail_reasons.is_empty() {
Ok(())
} else {
Err(CliError::ValidationFailed(fail_reasons.join("; ")))
}
}
fn parse_tool_calls(v: &Value) -> Option<Vec<clf::ToolCall>> {
let arr = v.as_array()?;
let mut out = Vec::with_capacity(arr.len());
for el in arr {
let obj = el.as_object()?;
out.push(clf::ToolCall {
id: obj
.get("id")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
call_type: obj
.get("type")
.and_then(Value::as_str)
.unwrap_or("function")
.to_string(),
name: obj.get("name").and_then(Value::as_str)?.to_string(),
arguments_json_string: obj
.get("arguments")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
});
}
Some(out)
}
fn parse_declared_tools(v: &Value) -> Option<Vec<clf::DeclaredTool>> {
let arr = v.as_array()?;
let mut out = Vec::with_capacity(arr.len());
for el in arr {
let obj = el.as_object()?;
out.push(clf::DeclaredTool {
name: obj.get("name").and_then(Value::as_str)?.to_string(),
parameters: obj.get("parameters").cloned().unwrap_or(Value::Null),
});
}
Some(out)
}
fn classify_shape(obs: &Value) -> Option<clf::ToolCallsShapeOutcome> {
let sec = obs.get("shape")?.as_object()?;
let declared = parse_declared_tools(sec.get("declared_tools")?)?;
let calls = parse_tool_calls(sec.get("tool_calls")?)?;
let fr = sec.get("finish_reason")?.as_str()?;
Some(clf::classify_tool_calls_shape(&declared, &calls, fr))
}
fn classify_schema(obs: &Value) -> Option<clf::SchemaValidationOutcome> {
let sec = obs.get("schema")?.as_object()?;
let args = sec.get("arguments")?.as_str()?;
let params = sec.get("parameters")?;
Some(clf::classify_arguments_against_schema(args, params))
}
fn classify_passthrough(obs: &Value) -> Option<clf::NoToolsPassthroughOutcome> {
let sec = obs.get("passthrough")?.as_object()?;
let calls = parse_tool_calls(sec.get("tool_calls")?)?;
let fr = sec.get("finish_reason")?.as_str()?;
Some(clf::classify_no_tools_passthrough(&calls, fr))
}
fn shape_fail_reason(o: &clf::ToolCallsShapeOutcome) -> Option<String> {
match o {
clf::ToolCallsShapeOutcome::Ok { .. } => None,
clf::ToolCallsShapeOutcome::FinishReasonMismatch {
n_calls,
got,
expected_any_of,
} => Some(format!(
"FALSIFY-CRUX-C-11-001 shape: finish_reason={got:?} for n_calls={n_calls} (expected any of {expected_any_of:?})"
)),
clf::ToolCallsShapeOutcome::UnknownToolName { index, got } => Some(format!(
"FALSIFY-CRUX-C-11-001 shape: tool_calls[{index}].name={got:?} not in declared_tools"
)),
clf::ToolCallsShapeOutcome::WrongCallType { index, got } => Some(format!(
"FALSIFY-CRUX-C-11-001 shape: tool_calls[{index}].type={got:?} (expected \"function\")"
)),
clf::ToolCallsShapeOutcome::ArgumentsNotJson { index, .. } => Some(format!(
"FALSIFY-CRUX-C-11-001 shape: tool_calls[{index}].arguments is not a JSON-parseable string"
)),
}
}
fn schema_fail_reason(o: &clf::SchemaValidationOutcome) -> Option<String> {
match o {
clf::SchemaValidationOutcome::Ok => None,
clf::SchemaValidationOutcome::ArgumentsNotJson { .. } => Some(
"FALSIFY-CRUX-C-11-002 schema: arguments is not a JSON-parseable string".to_string(),
),
clf::SchemaValidationOutcome::ArgumentsNotObject => {
Some("FALSIFY-CRUX-C-11-002 schema: arguments is not a JSON object".to_string())
}
clf::SchemaValidationOutcome::MissingRequiredProperty { name } => Some(format!(
"FALSIFY-CRUX-C-11-002 schema: missing required property {name:?}"
)),
clf::SchemaValidationOutcome::WrongPropertyType {
name,
expected,
got,
} => Some(format!(
"FALSIFY-CRUX-C-11-002 schema: property {name:?} type mismatch (expected {expected}, got {got})"
)),
clf::SchemaValidationOutcome::UnsupportedSchema { reason } => Some(format!(
"FALSIFY-CRUX-C-11-002 schema: unsupported schema fragment: {reason}"
)),
}
}
fn passthrough_fail_reason(o: &clf::NoToolsPassthroughOutcome) -> Option<String> {
match o {
clf::NoToolsPassthroughOutcome::Ok => None,
clf::NoToolsPassthroughOutcome::UnexpectedToolCalls { n_calls } => Some(format!(
"FALSIFY-CRUX-C-11-003 passthrough: response synthesized {n_calls} tool_calls despite empty request.tools[]"
)),
clf::NoToolsPassthroughOutcome::WrongFinishReason {
got,
expected_any_of,
} => Some(format!(
"FALSIFY-CRUX-C-11-003 passthrough: finish_reason={got:?} (expected any of {expected_any_of:?})"
)),
}
}
fn print_report(
path: &Path,
shape: Option<&clf::ToolCallsShapeOutcome>,
schema: Option<&clf::SchemaValidationOutcome>,
passthrough: Option<&clf::NoToolsPassthroughOutcome>,
json: bool,
) {
if json {
let v = serde_json::json!({
"observation_path": path.display().to_string(),
"shape": shape.map(|o| format!("{o:?}")),
"schema": schema.map(|o| format!("{o:?}")),
"passthrough": passthrough.map(|o| format!("{o:?}")),
});
println!(
"{}",
serde_json::to_string_pretty(&v).unwrap_or_else(|_| v.to_string())
);
} else {
println!("tool-use-lint report for {}", path.display());
print_line(" shape: ", shape.map(|o| format!("{o:?}")));
print_line(" schema: ", schema.map(|o| format!("{o:?}")));
print_line(" passthrough: ", passthrough.map(|o| format!("{o:?}")));
}
}
fn print_line(prefix: &str, v: Option<String>) {
match v {
Some(s) => println!("{prefix}{s}"),
None => println!("{prefix}(missing fields — classifier skipped)"),
}
}