Skip to main content

recon_cli/script/bindings/ai/
backend.rs

1//! Backend trait + dispatcher.
2
3use std::time::Duration;
4use std::collections::HashMap;
5
6use crate::config::AiConfig;
7use super::request::Request;
8
9/// Per-call context passed to each `AiBackend::invoke`. Holds the
10/// effective config and a verbosity level for logging.
11pub struct BackendCtx<'a> {
12    pub config: &'a AiConfig,
13    pub effective_model: Option<String>,
14    pub effective_timeout: Duration,
15    pub verbose: u8,
16}
17
18/// Successful backend response.
19#[derive(Debug, Clone)]
20pub struct Response {
21    pub text: String,
22    pub backend: String,
23    pub model: Option<String>,
24    pub duration: Duration,
25    pub exit_code: i32,
26}
27
28/// A backend dispatches a `Request` to an underlying CLI / API and
29/// returns a `Response`. Implementations live in `backends/<name>.rs`.
30pub trait AiBackend: Send + Sync {
31    fn name(&self) -> &'static str;
32    fn invoke(&self, req: &Request, ctx: &BackendCtx<'_>) -> Result<Response, String>;
33}
34
35/// Registry of available backends. Built-in backends are registered
36/// at engine startup; the `cmd` backend is materialized on demand
37/// from `[ai.backends.<name>]` config entries.
38pub struct Registry {
39    built_ins: HashMap<&'static str, Box<dyn AiBackend>>,
40}
41
42impl Registry {
43    /// Returns an empty registry. Built-in backends are added by
44    /// `with_built_ins` in later tasks.
45    pub fn empty() -> Self {
46        Self { built_ins: HashMap::new() }
47    }
48
49    pub fn register(&mut self, backend: Box<dyn AiBackend>) {
50        self.built_ins.insert(backend.name(), backend);
51    }
52
53    pub fn get(&self, name: &str) -> Option<&dyn AiBackend> {
54        self.built_ins.get(name).map(|b| b.as_ref())
55    }
56
57    pub fn has(&self, name: &str) -> bool {
58        self.built_ins.contains_key(name)
59    }
60}
61
62/// Resolves the named backend (built-in or config-defined `cmd`) and
63/// invokes it. Returns the `Response` or a tagged error string.
64pub fn dispatch(
65    backend_name: &str,
66    req: &Request,
67    config: &AiConfig,
68    ctx: &BackendCtx<'_>,
69    registry: &Registry,
70) -> Result<Response, String> {
71    if let Some(b) = registry.get(backend_name) {
72        return b.invoke(req, ctx);
73    }
74    // Fall through to config-defined cmd entries.
75    if let Some(bcfg) = config.backends.get(backend_name) {
76        if !bcfg.cmd.is_empty() {
77            return super::backends::cmd::invoke(backend_name, bcfg, req, ctx);
78        }
79    }
80    Err(format!(
81        "ai: backend '{backend_name}' not found (no built-in, and no \
82         `[ai.backends.{backend_name}]` with non-empty `cmd` in ~/.recon/config.toml)"
83    ))
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    struct FakeBackend;
91    impl AiBackend for FakeBackend {
92        fn name(&self) -> &'static str { "fake" }
93        fn invoke(&self, _req: &Request, _ctx: &BackendCtx<'_>) -> Result<Response, String> {
94            Ok(Response {
95                text: "ok".into(),
96                backend: "fake".into(),
97                model: None,
98                duration: Duration::from_millis(1),
99                exit_code: 0,
100            })
101        }
102    }
103
104    #[test]
105    fn registry_round_trip() {
106        let mut reg = Registry::empty();
107        reg.register(Box::new(FakeBackend));
108        assert!(reg.has("fake"));
109        assert!(!reg.has("missing"));
110        let b = reg.get("fake").expect("present");
111        assert_eq!(b.name(), "fake");
112    }
113}