Skip to main content

locode_provider/
mock.rs

1//! A scripted [`Provider`] — the zero-API-spend test seam for the loop (ADR-0007).
2
3use std::collections::VecDeque;
4use std::sync::{Mutex, PoisonError};
5
6use async_trait::async_trait;
7
8use crate::completion::Completion;
9use crate::provider::{Provider, ProviderError};
10use crate::request::ConversationRequest;
11
12/// A provider that replays a fixed script of results, one per `complete()` call.
13///
14/// This is a real, shippable provider (the `--provider mock` mode runs it in CI
15/// without an API key), not test-only. It ignores the request and returns the next
16/// scripted [`Completion`] (or [`ProviderError`]) in order — letting a test drive the
17/// loop through a tool-call turn, a final-text turn, or a scripted error, with zero
18/// network. Calling `complete()` more times than scripted **panics** (a loud signal
19/// that the loop ran an unexpected extra turn), per the agreed contract.
20pub struct MockProvider {
21    script: Mutex<VecDeque<Result<Completion, ProviderError>>>,
22}
23
24impl MockProvider {
25    /// A mock scripted with a sequence of successful completions.
26    #[must_use]
27    pub fn new(script: Vec<Completion>) -> Self {
28        Self {
29            script: Mutex::new(script.into_iter().map(Ok).collect()),
30        }
31    }
32
33    /// A mock scripted with a sequence of results (to inject errors as well).
34    #[must_use]
35    pub fn with_results(script: Vec<Result<Completion, ProviderError>>) -> Self {
36        Self {
37            script: Mutex::new(script.into_iter().collect()),
38        }
39    }
40}
41
42#[async_trait]
43impl Provider for MockProvider {
44    // The trait ties `&str` to `&self` so real wires return a stored id; the mock's
45    // is a literal.
46    #[allow(clippy::unnecessary_literal_bound)]
47    fn api_schema(&self) -> &str {
48        "mock"
49    }
50
51    async fn complete(&self, _request: &ConversationRequest) -> Result<Completion, ProviderError> {
52        // Recover from a poisoned lock rather than unwrap/expect (denied lints); the
53        // mutex only guards a cursor and is never held across an await.
54        let mut script = self.script.lock().unwrap_or_else(PoisonError::into_inner);
55        match script.pop_front() {
56            Some(result) => result,
57            None => panic!(
58                "MockProvider script exhausted: complete() was called more times than scripted"
59            ),
60        }
61    }
62}