apr-cli 0.32.0

CLI tool for APR model inspection, debugging, and operations
Documentation
//! `apr ollama-tools-lint` — CRUX-I-04 Ollama function-calling response gate.
//!
//! Reads an already-captured `/api/chat` response that was produced against a
//! request with a `tools[]` array, and dispatches the pure classifiers in
//! `ollama_tool_call_classifier`. Non-streaming mode expects a single JSON
//! object; streaming mode expects NDJSON (one JSON object per line).
//!
//! The optional `--request-file` carries the original request so we can
//! enforce the tool-name allowlist (no model hallucinated a tool the client
//! did not declare).
//!
//! Spec: `contracts/crux-I-04-v1.yaml`. CRUX-SHIP-001 g2/g3 surface.

use std::path::{Path, PathBuf};

use serde_json::Value;

use super::ollama_tool_call_classifier::{
    classify_streaming_tool_call, classify_tool_call_schema, classify_tool_name_allowlist,
    StreamingToolCallOutcome, ToolCallSchemaOutcome, ToolNameAllowlistOutcome,
};
use crate::error::{CliError, Result};

pub(crate) fn run(
    response_file: &Path,
    request_file: Option<&Path>,
    stream: bool,
    json: bool,
) -> Result<()> {
    if !response_file.exists() {
        return Err(CliError::FileNotFound(PathBuf::from(response_file)));
    }
    let body = std::fs::read_to_string(response_file)?;
    let declared: Vec<String> = match request_file {
        Some(p) => load_declared_tool_names(p)?,
        None => Vec::new(),
    };

    if stream {
        run_stream(&body, response_file, json)
    } else {
        run_non_streaming(&body, response_file, &declared, json)
    }
}

fn run_non_streaming(body: &str, path: &Path, declared: &[String], json: bool) -> Result<()> {
    let response: Value = serde_json::from_str(body).map_err(|e| {
        CliError::InvalidFormat(format!(
            "apr ollama-tools-lint: failed to parse JSON from {}: {e}",
            path.display()
        ))
    })?;

    let schema = classify_tool_call_schema(&response);
    let declared_refs: Vec<&str> = declared.iter().map(String::as_str).collect();
    let allowlist = classify_tool_name_allowlist(&response, &declared_refs);

    print_non_streaming_report(path, &schema, &allowlist, declared, json);

    match (&schema, &allowlist) {
        (ToolCallSchemaOutcome::Ok, ToolNameAllowlistOutcome::Ok) => Ok(()),
        (ToolCallSchemaOutcome::Ok, bad) => Err(CliError::ValidationFailed(format!(
            "tool-name allowlist gate rejected response: {bad:?}"
        ))),
        (bad, _) => Err(CliError::ValidationFailed(format!(
            "tool-call schema gate rejected response: {bad:?}"
        ))),
    }
}

fn run_stream(body: &str, path: &Path, json: bool) -> Result<()> {
    let mut frames: Vec<Value> = Vec::new();
    for (idx, line) in body.lines().enumerate() {
        let trimmed = line.trim();
        if trimmed.is_empty() {
            continue;
        }
        let frame: Value = serde_json::from_str(trimmed).map_err(|e| {
            CliError::InvalidFormat(format!(
                "apr ollama-tools-lint: invalid NDJSON at line {} of {}: {e}",
                idx + 1,
                path.display()
            ))
        })?;
        frames.push(frame);
    }
    let outcome = classify_streaming_tool_call(&frames);
    print_stream_report(path, frames.len(), &outcome, json);
    match outcome {
        StreamingToolCallOutcome::Ok => Ok(()),
        bad => Err(CliError::ValidationFailed(format!(
            "NDJSON tool-call stream gate rejected frames: {bad:?}"
        ))),
    }
}

fn load_declared_tool_names(path: &Path) -> Result<Vec<String>> {
    if !path.exists() {
        return Err(CliError::FileNotFound(PathBuf::from(path)));
    }
    let body = std::fs::read_to_string(path)?;
    let req: Value = serde_json::from_str(&body).map_err(|e| {
        CliError::InvalidFormat(format!(
            "apr ollama-tools-lint: failed to parse request JSON from {}: {e}",
            path.display()
        ))
    })?;
    let names = req
        .get("tools")
        .and_then(Value::as_array)
        .map(|tools| {
            tools
                .iter()
                .filter_map(|t| {
                    t.get("function")
                        .and_then(|f| f.get("name"))
                        .and_then(Value::as_str)
                        .map(String::from)
                })
                .collect::<Vec<_>>()
        })
        .unwrap_or_default();
    Ok(names)
}

fn print_non_streaming_report(
    path: &Path,
    schema: &ToolCallSchemaOutcome,
    allowlist: &ToolNameAllowlistOutcome,
    declared: &[String],
    json: bool,
) {
    if json {
        let v = serde_json::json!({
            "mode": "non_streaming",
            "response_path": path.display().to_string(),
            "declared_tool_names": declared,
            "schema_outcome": format!("{:?}", schema),
            "allowlist_outcome": format!("{:?}", allowlist),
            "schema_ok": matches!(schema, ToolCallSchemaOutcome::Ok),
            "allowlist_ok": matches!(allowlist, ToolNameAllowlistOutcome::Ok),
        });
        println!(
            "{}",
            serde_json::to_string_pretty(&v).unwrap_or_else(|_| v.to_string())
        );
    } else {
        println!("ollama-tools-lint (non-streaming) for {}", path.display());
        println!("  declared_tools:    {declared:?}");
        println!("  schema_outcome:    {schema:?}");
        println!("  allowlist_outcome: {allowlist:?}");
    }
}

fn print_stream_report(
    path: &Path,
    frame_count: usize,
    outcome: &StreamingToolCallOutcome,
    json: bool,
) {
    if json {
        let v = serde_json::json!({
            "mode": "streaming_ndjson",
            "response_path": path.display().to_string(),
            "num_frames": frame_count,
            "ndjson_outcome": format!("{:?}", outcome),
            "ndjson_ok": matches!(outcome, StreamingToolCallOutcome::Ok),
        });
        println!(
            "{}",
            serde_json::to_string_pretty(&v).unwrap_or_else(|_| v.to_string())
        );
    } else {
        println!(
            "ollama-tools-lint (streaming NDJSON) for {}",
            path.display()
        );
        println!("  num_frames:     {frame_count}");
        println!("  ndjson_outcome: {outcome:?}");
    }
}