Skip to main content

deck_llm/
mock.rs

1//! In-memory mock backend used for tests, offline demos, and the TUI when
2//! no real LLM endpoint is reachable. Deterministic; never touches the
3//! network.
4
5use async_trait::async_trait;
6use deck_core::{LlmBackend, Message, Result, Role};
7use futures::stream::{self, BoxStream, StreamExt};
8
9#[derive(Debug, Default, Clone)]
10pub struct MockBackend {
11    pub reply_template: String,
12}
13
14impl MockBackend {
15    #[must_use]
16    pub fn new(reply_template: impl Into<String>) -> Self {
17        Self {
18            reply_template: reply_template.into(),
19        }
20    }
21
22    fn shape_reply(&self, messages: &[Message]) -> String {
23        let last_user = messages
24            .iter()
25            .rev()
26            .find(|m| matches!(m.role, Role::User))
27            .map_or("(no input)", |m| m.content.as_str());
28        if self.reply_template.is_empty() {
29            format!("[mock] you said: {last_user}")
30        } else {
31            self.reply_template.replace("{input}", last_user)
32        }
33    }
34}
35
36#[async_trait]
37impl LlmBackend for MockBackend {
38    fn id(&self) -> String {
39        "mock@in-process".into()
40    }
41
42    async fn complete(&self, _model: &str, messages: &[Message]) -> Result<Message> {
43        Ok(Message {
44            role: Role::Assistant,
45            content: self.shape_reply(messages),
46            tool_calls: vec![],
47        })
48    }
49
50    async fn stream(
51        &self,
52        _model: &str,
53        messages: &[Message],
54    ) -> Result<BoxStream<'static, Result<Message>>> {
55        let reply = self.shape_reply(messages);
56        let chunks: Vec<Result<Message>> = reply
57            .split_inclusive(' ')
58            .map(|c| {
59                Ok(Message {
60                    role: Role::Assistant,
61                    content: c.to_owned(),
62                    tool_calls: vec![],
63                })
64            })
65            .collect();
66        Ok(stream::iter(chunks).boxed())
67    }
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73    use futures::StreamExt;
74
75    #[tokio::test]
76    async fn mock_complete_echoes_last_user() {
77        let m = MockBackend::default();
78        let msgs = vec![Message {
79            role: Role::User,
80            content: "hi".into(),
81            tool_calls: vec![],
82        }];
83        let reply = m.complete("ignored", &msgs).await.unwrap();
84        assert!(reply.content.contains("hi"));
85    }
86
87    #[tokio::test]
88    async fn mock_stream_emits_chunks() {
89        let m = MockBackend::default();
90        let msgs = vec![Message {
91            role: Role::User,
92            content: "a b c".into(),
93            tool_calls: vec![],
94        }];
95        let mut s = m.stream("ignored", &msgs).await.unwrap();
96        let mut full = String::new();
97        while let Some(c) = s.next().await {
98            full.push_str(&c.unwrap().content);
99        }
100        assert!(full.contains("a b c"));
101    }
102}