ccc 0.1.0

Call coding CLIs (opencode, claude, codex, kimi, etc.) from Rust programs
Documentation
use call_coding_clis::*;
use std::fs;
use std::path::PathBuf;

fn fixture(path: &str) -> String {
    let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .unwrap()
        .join("tests/fixtures/runner-transcripts");
    fs::read_to_string(root.join(path)).unwrap()
}

#[test]
fn test_opencode_simple_response() {
    let raw = "{\"response\":\"hello world\"}\n";
    let parsed = parse_opencode_json(raw);
    assert_eq!(parsed.schema_name, "opencode");
    assert_eq!(parsed.final_text, "hello world");
    assert_eq!(parsed.events.len(), 1);
    assert_eq!(parsed.events[0].event_type, "text");
    assert_eq!(parsed.events[0].text, "hello world");
}

#[test]
fn test_opencode_error_response() {
    let raw = "{\"error\":\"something broke\"}\n";
    let parsed = parse_opencode_json(raw);
    assert_eq!(parsed.error, "something broke");
    assert_eq!(parsed.events.len(), 1);
    assert_eq!(parsed.events[0].event_type, "error");
    assert_eq!(parsed.events[0].text, "something broke");
    assert_eq!(parsed.final_text, "");
}

#[test]
fn test_opencode_event_stream_response() {
    let raw = concat!(
        "{\"type\":\"step_start\",\"timestamp\":1,\"sessionID\":\"ses_1\",\"part\":{\"type\":\"step-start\"}}\n",
        "{\"type\":\"text\",\"timestamp\":2,\"sessionID\":\"ses_1\",\"part\":{\"type\":\"text\",\"text\":\"alpha\\nbeta\\ngamma\"}}\n",
        "{\"type\":\"step_finish\",\"timestamp\":3,\"sessionID\":\"ses_1\",\"part\":{\"type\":\"step-finish\",\"tokens\":{\"total\":10,\"input\":2,\"output\":3,\"reasoning\":5,\"cache\":{\"write\":0,\"read\":7}},\"cost\":0}}\n"
    );
    let parsed = parse_opencode_json(raw);
    assert_eq!(parsed.session_id, "ses_1");
    assert_eq!(parsed.final_text, "alpha\nbeta\ngamma");
    assert_eq!(parsed.events.last().unwrap().event_type, "text");
    assert_eq!(parsed.usage.get("total").copied(), Some(10));
    assert_eq!(parsed.usage.get("cache_read").copied(), Some(7));
}

#[test]
fn test_claude_code_simple_response() {
    let raw = concat!(
        "{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"sess-123\"}\n",
        "{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"hi there\"}],\"usage\":{\"input_tokens\":10,\"output_tokens\":5}}}\n",
        "{\"type\":\"result\",\"subtype\":\"success\",\"result\":\"hi there\",\"cost_usd\":0.003,\"duration_ms\":1500}\n"
    );
    let parsed = parse_claude_code_json(raw);
    assert_eq!(parsed.schema_name, "claude-code");
    assert_eq!(parsed.session_id, "sess-123");
    assert_eq!(parsed.final_text, "hi there");
    assert_eq!(parsed.cost_usd, 0.003);
    assert_eq!(parsed.duration_ms, 1500);
    assert_eq!(parsed.usage.get("input_tokens").copied(), Some(10));
    assert_eq!(parsed.usage.get("output_tokens").copied(), Some(5));
}

#[test]
fn test_claude_code_tool_use_and_result() {
    let raw = concat!(
        "{\"type\":\"tool_use\",\"tool_name\":\"Read\",\"tool_input\":{\"file\":\"src/main.rs\"}}\n",
        "{\"type\":\"tool_result\",\"tool_use_id\":\"tu-1\",\"content\":\"fn main() {}\",\"is_error\":false}\n"
    );
    let parsed = parse_claude_code_json(raw);
    assert_eq!(parsed.events.len(), 2);

    assert_eq!(parsed.events[0].event_type, "tool_use");
    let tc = parsed.events[0].tool_call.as_ref().unwrap();
    assert_eq!(tc.name, "Read");
    assert!(tc.arguments.contains("src/main.rs"));

    assert_eq!(parsed.events[1].event_type, "tool_result");
    let tr = parsed.events[1].tool_result.as_ref().unwrap();
    assert_eq!(tr.tool_call_id, "tu-1");
    assert_eq!(tr.content, "fn main() {}");
    assert!(!tr.is_error);
}

#[test]
fn test_claude_code_error() {
    let raw = "{\"type\":\"result\",\"subtype\":\"error\",\"error\":\"rate limited\"}\n";
    let parsed = parse_claude_code_json(raw);
    assert_eq!(parsed.error, "rate limited");
    assert_eq!(parsed.events.len(), 1);
    assert_eq!(parsed.events[0].event_type, "error");
}

#[test]
fn test_kimi_simple_response() {
    let raw = "{\"role\":\"assistant\",\"content\":\"hello from kimi\"}\n";
    let parsed = parse_kimi_json(raw);
    assert_eq!(parsed.schema_name, "kimi");
    assert_eq!(parsed.final_text, "hello from kimi");
    assert_eq!(parsed.events.len(), 1);
    assert_eq!(parsed.events[0].event_type, "assistant");
}

#[test]
fn test_kimi_tool_calls() {
    let raw = concat!(
        "{\"role\":\"assistant\",\"content\":\"\",\"tool_calls\":[{\"id\":\"tc-1\",\"function\":{\"name\":\"Read\",\"arguments\":\"{\\\"file\\\":\\\"a.rs\\\"}\"}}]}\n"
    );
    let parsed = parse_kimi_json(raw);
    let tc_event = parsed
        .events
        .iter()
        .find(|e| e.event_type == "tool_call")
        .unwrap();
    let tc = tc_event.tool_call.as_ref().unwrap();
    assert_eq!(tc.id, "tc-1");
    assert_eq!(tc.name, "Read");
    assert!(tc.arguments.contains("a.rs"));
}

#[test]
fn test_kimi_tool_result() {
    let raw = "{\"role\":\"tool\",\"tool_call_id\":\"tc-1\",\"content\":[{\"type\":\"text\",\"text\":\"file contents here\"}]}\n";
    let parsed = parse_kimi_json(raw);
    assert_eq!(parsed.events.len(), 1);
    let tr = parsed.events[0].tool_result.as_ref().unwrap();
    assert_eq!(tr.tool_call_id, "tc-1");
    assert_eq!(tr.content, "file contents here");
}

#[test]
fn test_kimi_thinking() {
    let raw = "{\"role\":\"assistant\",\"content\":[{\"type\":\"think\",\"think\":\"pondering...\"},{\"type\":\"text\",\"text\":\"answer\"}]}\n";
    let parsed = parse_kimi_json(raw);
    assert_eq!(parsed.events.len(), 2);
    assert_eq!(parsed.events[0].event_type, "thinking");
    assert_eq!(parsed.events[0].thinking, "pondering...");
    assert_eq!(parsed.events[1].event_type, "assistant");
    assert_eq!(parsed.events[1].text, "answer");
    assert_eq!(parsed.final_text, "answer");
}

#[test]
fn test_kimi_tool_result_filters_system() {
    let raw = "{\"role\":\"tool\",\"tool_call_id\":\"tc-2\",\"content\":[{\"type\":\"text\",\"text\":\"<system>internal</system>\"},{\"type\":\"text\",\"text\":\"real output\"}]}\n";
    let parsed = parse_kimi_json(raw);
    let tr = parsed.events[0].tool_result.as_ref().unwrap();
    assert_eq!(tr.content, "real output");
}

#[test]
fn test_kimi_passthrough_events() {
    let raw = "{\"type\":\"TurnBegin\"}\n{\"type\":\"StepBegin\"}\n{\"type\":\"StatusUpdate\"}\n";
    let parsed = parse_kimi_json(raw);
    assert_eq!(parsed.events.len(), 3);
    assert_eq!(parsed.events[0].event_type, "turnbegin");
    assert_eq!(parsed.events[1].event_type, "stepbegin");
    assert_eq!(parsed.events[2].event_type, "statusupdate");
}

#[test]
fn test_render_opencode() {
    let raw = "{\"response\":\"hello\"}\n";
    let parsed = parse_opencode_json(raw);
    let rendered = render_parsed(&parsed, true, false);
    assert_eq!(rendered, "[assistant] hello");
}

#[test]
fn test_render_opencode_uses_color_prefixes_on_tty() {
    let raw = "{\"response\":\"hello\"}\n";
    let parsed = parse_opencode_json(raw);
    let rendered = render_parsed(&parsed, true, true);
    assert_eq!(rendered, "\u{1b}[96m💬\u{1b}[0m hello");
}

#[test]
fn test_render_claude_code_tool_use() {
    let raw = "{\"type\":\"tool_use\",\"tool_name\":\"Bash\",\"tool_input\":{\"cmd\":\"ls\"}}\n";
    let parsed = parse_claude_code_json(raw);
    let rendered = render_parsed(&parsed, true, false);
    assert_eq!(rendered, "[tool:start] Bash: ls");
}

#[test]
fn test_render_kimi_thinking() {
    let raw = "{\"role\":\"assistant\",\"content\":[{\"type\":\"think\",\"think\":\"hmm\"},{\"type\":\"text\",\"text\":\"ok\"}]}\n";
    let parsed = parse_kimi_json(raw);
    let rendered = render_parsed(&parsed, true, false);
    assert!(rendered.contains("[thinking] hmm"));
    assert!(rendered.contains("ok"));
}

#[test]
fn test_render_unknown_schema() {
    let parsed = parse_json_output("", "nonexistent");
    let rendered = render_parsed(&parsed, true, false);
    assert_eq!(rendered, "");
    assert_eq!(parsed.error, "unknown schema: nonexistent");
}

#[test]
fn test_empty_input() {
    let parsed = parse_json_output("", "opencode");
    assert_eq!(parsed.events.len(), 0);
    assert_eq!(parsed.final_text, "");
    let rendered = render_parsed(&parsed, true, false);
    assert_eq!(rendered, "");
}

#[test]
fn test_malformed_json_skipped() {
    let raw = "not json at all\n{\"response\":\"valid\"}\nalso not json\n";
    let parsed = parse_opencode_json(raw);
    assert_eq!(parsed.events.len(), 1);
    assert_eq!(parsed.final_text, "valid");
}

#[test]
fn test_render_error_event() {
    let raw = "{\"error\":\"bad\"}\n";
    let parsed = parse_opencode_json(raw);
    let rendered = render_parsed(&parsed, true, false);
    assert_eq!(rendered, "[error] bad");
}

#[test]
fn test_resolve_human_tty_defaults_to_terminal_state() {
    assert!(resolve_human_tty(true, None, None));
    assert!(!resolve_human_tty(false, None, None));
}

#[test]
fn test_resolve_human_tty_force_color_wins() {
    assert!(resolve_human_tty(false, Some("1"), None));
    assert!(resolve_human_tty(true, Some("1"), Some("1")));
}

#[test]
fn test_resolve_human_tty_no_color_disables() {
    assert!(!resolve_human_tty(true, None, Some("1")));
}

#[test]
fn test_stream_processor_renders_incrementally() {
    let raw = concat!(
        "{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_start\",\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_1\",\"name\":\"Bash\"}}}\n",
        "{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"command\\\":\\\"printf hi\\\"}\"}}}\n",
        "{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_1\",\"content\":\"hi\",\"is_error\":false}\n",
        "{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"checking\"}}}\n",
        "{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"done\"}}}\n"
    );
    let mut processor = StructuredStreamProcessor::new(
        "claude-code",
        FormattedRenderer::new(true, false),
    );
    let mut chunks = Vec::new();
    for line in raw.lines() {
        let rendered = processor.feed(&(line.to_string() + "\n"));
        if !rendered.is_empty() {
            chunks.push(rendered);
        }
    }
    assert!(chunks.iter().any(|chunk| chunk.contains("[tool:start] Bash")));
    assert!(chunks.iter().any(|chunk| chunk.contains("[tool:result] Bash (ok): printf hi")));
    assert!(chunks.iter().any(|chunk| chunk.contains("[thinking] checking")));
    assert!(chunks.iter().any(|chunk| chunk.contains("[assistant] done")));
}

#[test]
fn test_stream_processor_dedupes_final_assistant_and_result_after_text_deltas() {
    let raw = concat!(
        "{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"delta\":{\"type\":\"text_delta\",\"text\":\"DONE\"}}}\n",
        "{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"DONE\"}]}}\n",
        "{\"type\":\"result\",\"subtype\":\"success\",\"result\":\"DONE\"}\n"
    );
    let mut processor = StructuredStreamProcessor::new(
        "claude-code",
        FormattedRenderer::new(false, false),
    );
    let mut chunks = Vec::new();
    for line in raw.lines() {
        let rendered = processor.feed(&(line.to_string() + "\n"));
        if !rendered.is_empty() {
            chunks.push(rendered);
        }
    }
    assert_eq!(chunks, vec!["[assistant] DONE"]);
}

#[test]
fn test_fixture_claude_tool_bash() {
    let raw = fixture("claude/tool_bash/stdout.ndjson");
    let parsed = parse_claude_code_json(&raw);
    let rendered = render_parsed(&parsed, true, false);
    assert!(rendered.contains("[tool:start] Bash"));
    assert!(rendered.contains("[tool:result] Bash (ok): echo"));
    assert!(rendered.contains("[assistant] done."));
}

#[test]
fn test_fixture_claude_stream_unknown_events() {
    let raw = fixture("claude/stream_unknown_events/stdout.ndjson");
    let parsed = parse_claude_code_json(&raw);
    let rendered = render_parsed(&parsed, true, false);
    assert!(rendered.contains("[thinking] The user wants a staged tool-using response."));
    assert!(rendered.contains("[assistant] Computing the first multiplication."));
    assert!(parsed.unknown_json_lines.len() >= 6);
    assert!(parsed
        .unknown_json_lines
        .iter()
        .any(|line| line.contains("\"type\":\"message_start\"")));
    assert!(parsed
        .unknown_json_lines
        .iter()
        .any(|line| line.contains("\"type\":\"signature_delta\"")));
    assert!(parsed
        .unknown_json_lines
        .iter()
        .any(|line| line.contains("\"type\":\"content_block_stop\"")));
    assert!(parsed
        .unknown_json_lines
        .iter()
        .any(|line| line.contains("\"type\":\"message_delta\"")));
    assert!(parsed
        .unknown_json_lines
        .iter()
        .any(|line| line.contains("\"type\":\"message_stop\"")));
    assert!(parsed
        .unknown_json_lines
        .iter()
        .any(|line| line.contains("\"type\":\"rate_limit_event\"")));
}

#[test]
fn test_fixture_kimi_tool_bash() {
    let raw = fixture("kimi/tool_bash/stdout.ndjson");
    let parsed = parse_kimi_json(&raw);
    let rendered = render_parsed(&parsed, true, false);
    assert!(rendered.contains("[tool:start] Shell"));
    assert!(rendered.contains("[tool:result] Shell (ok): echo"));
    assert!(rendered.contains("[assistant] done"));
}

#[test]
fn test_claude_code_streaming_thinking() {
    let raw = concat!(
        "{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_start\",\"content_block\":{\"type\":\"thinking\"}}}\n",
        "{\"type\":\"stream_event\",\"event\":{\"type\":\"content_block_delta\",\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"let me think\"}}}\n"
    );
    let parsed = parse_claude_code_json(raw);
    assert_eq!(parsed.events.len(), 2);
    assert_eq!(parsed.events[0].event_type, "thinking_start");
    assert_eq!(parsed.events[1].event_type, "thinking_delta");
    assert_eq!(parsed.events[1].thinking, "let me think");
}

#[test]
fn test_unknown_json_is_captured_but_not_rendered_by_default() {
    let raw = concat!(
        "{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"sess-1\"}\n",
        "{\"type\":\"rate_limit_event\",\"rate_limit_info\":{\"status\":\"allowed\"}}\n"
    );
    let parsed = parse_claude_code_json(raw);
    assert_eq!(render_parsed(&parsed, true, false), "");
    assert_eq!(parsed.unknown_json_lines.len(), 1);
    assert!(parsed.unknown_json_lines[0].contains("\"type\":\"rate_limit_event\""));
}