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}