nornir 0.5.4

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
//! `claude` backend — a [`Generator`] over the shared `crate::viz::claude` client
//! (Anthropic Messages API, default model `claude-opus-4-8`, `$ANTHROPIC_API_KEY`).
//!
//! This is the funnel **executor's** live generator: `run_subtask_loop`/`drain_plan`
//! ask a `Generator` for a patch/file body, and the compiler (`AnswerJudge`) is the
//! oracle. Here that generator is Claude — a DISTINCT call from the funnel *planner*
//! (`funnel::decompose` decompose), keeping planner and executor separate.
//!
//! It reuses the existing `viz::claude` transport seam (Law #1 — no new HTTP client):
//! the real path is `HttpTransport` (feature `claude`), tests inject a `FakeTransport`.
//! Gated on `any(feature = "viz", feature = "server")` because that is where the
//! `viz::claude` client compiles (mirrors `nornir.rs::llm_decomposition`).

use std::sync::Arc;
use std::time::Instant;

use anyhow::Result;

use super::{Backend, GenAnswer, GenRequest, Generator};
use crate::viz::claude::{
    api_key_present, default_transport, ClaudeMessage, ClaudeRequest, ClaudeTransport, DEFAULT_MODEL,
};

/// A [`Generator`] that completes via Claude. The transport is injectable so the
/// backend is unit-testable offline with a `FakeTransport` (no network, no key).
pub struct ClaudeGenerator {
    id: String,
    model: String,
    transport: Arc<dyn ClaudeTransport>,
}

impl ClaudeGenerator {
    /// Build the backend for `model` (empty ⇒ [`DEFAULT_MODEL`]) using the runtime
    /// transport (`HttpTransport` under the `claude` feature, a disabled stub
    /// otherwise — so `complete` returns a clean error, never a panic).
    pub fn new(model: &str) -> Self {
        Self::with_transport_impl(model, default_transport())
    }

    /// Build the backend with an explicit transport (tests pass a `FakeTransport`).
    pub fn with_transport(model: &str, transport: Arc<dyn ClaudeTransport>) -> Self {
        Self::with_transport_impl(model, transport)
    }

    fn with_transport_impl(model: &str, transport: Arc<dyn ClaudeTransport>) -> Self {
        let model = if model.trim().is_empty() { DEFAULT_MODEL.to_string() } else { model.trim().to_string() };
        Self { id: format!("claude:{model}"), model, transport }
    }
}

impl Backend for ClaudeGenerator {
    fn id(&self) -> &str {
        &self.id
    }
    /// Usable when an API key is resolvable (`$ANTHROPIC_API_KEY` or the tab token).
    /// Without the `claude` feature the transport is disabled and `complete` errors,
    /// so callers still degrade gracefully; `available()` here reports key presence.
    fn available(&self) -> bool {
        api_key_present()
    }
}

impl Generator for ClaudeGenerator {
    fn complete(&self, req: &GenRequest) -> Result<GenAnswer> {
        // The `viz::claude` ClaudeRequest has no system field (opus-4-8 rejects some
        // legacy knobs); fold the system prompt into the user turn. `temperature` is
        // intentionally ignored — the client does not send it (unsupported on 4.8).
        let prompt = match &req.system {
            Some(s) if !s.trim().is_empty() => format!("{s}\n\n{}", req.prompt),
            _ => req.prompt.clone(),
        };
        let mut creq = ClaudeRequest::new(self.model.as_str(), vec![ClaudeMessage::user(prompt)]);
        // Respect the caller's token cap (clamp into u32); a 0 cap keeps the client default.
        if req.max_tokens > 0 {
            creq.max_tokens = req.max_tokens.min(u32::MAX as usize) as u32;
        }

        let t0 = Instant::now();
        let text = self
            .transport
            .send(&creq)
            .map_err(|e| anyhow::anyhow!("claude generate: {e}"))?;
        let latency_ms = t0.elapsed().as_secs_f64() * 1000.0;

        // The transport returns assembled text only (no usage block), so token
        // counts are a coarse chars/4 estimate — enough for the bake-off row.
        Ok(GenAnswer {
            text: text.clone(),
            tokens_in: (req.prompt.len() / 4) as i64,
            tokens_out: (text.len() / 4) as i64,
            tokens_per_s: 0.0,
            latency_ms,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::viz::claude::{ClaudeError, FakeTransport};

    #[test]
    fn completes_via_injected_transport() {
        let t = Arc::new(FakeTransport::replying("fn add(a: i32, b: i32) -> i32 { a + b }"));
        let g = ClaudeGenerator::with_transport("claude-opus-4-8", t);
        assert_eq!(g.id(), "claude:claude-opus-4-8");
        let ans = g.complete(&GenRequest::new("implement add")).unwrap();
        assert!(ans.text.contains("fn add"), "returns the transport's completion");
        assert!(ans.tokens_out > 0, "token economics estimated from the text");
    }

    #[test]
    fn empty_model_defaults_and_system_folds_into_the_prompt() {
        let t = Arc::new(FakeTransport::replying("ok"));
        let g = ClaudeGenerator::with_transport("", t.clone());
        assert_eq!(g.id(), format!("claude:{DEFAULT_MODEL}"), "empty model ⇒ default");
        let req = GenRequest::new("do the thing").with_system("You are a Rust expert.");
        g.complete(&req).unwrap();
        let seen = t.requests();
        assert_eq!(seen.len(), 1);
        let sent = &seen[0].messages[0].content;
        assert!(sent.contains("Rust expert"), "system prompt folded in");
        assert!(sent.contains("do the thing"), "user prompt present");
    }

    #[test]
    fn transport_error_surfaces_not_panics() {
        let t = Arc::new(FakeTransport::failing(ClaudeError::NoCredential));
        let g = ClaudeGenerator::with_transport("m", t);
        assert!(g.complete(&GenRequest::new("x")).is_err(), "no key ⇒ Err, never a panic");
    }
}