Skip to main content

sqlite_graphrag/spawn/
llm_spawn_backend.rs

1//! Shared LLM CLI spawn backend surface (GAP-SG-126).
2//!
3//! Thin trait covering the common binary-resolution and child-spawn entry
4//! points used by `claude_runner` and `codex_spawn`. Full command construction,
5//! OAuth guards, and output parsing stay in the per-backend command modules;
6//! this module only extracts the duplicated adapter surface so call sites can
7//! depend on a single trait instead of ad-hoc `which` + `Command::new` pairs.
8//!
9//! Prefer compile-green, minimal surface area over a full spawn redesign.
10
11use crate::errors::AppError;
12use std::path::{Path, PathBuf};
13use std::process::{Child, Command};
14
15/// Common surface for LLM CLI spawn adapters (`claude`, `codex`, …).
16///
17/// Implementations resolve the backend binary and spawn a prepared
18/// [`Command`]. Argument construction and stdout parsing remain outside
19/// this trait (see [`super::VersionAdapter`] for version/flag adaptation).
20pub trait LlmSpawnBackend: Send + Sync {
21    /// Logical backend name (e.g. `"claude"`, `"codex"`).
22    fn name(&self) -> &'static str;
23
24    /// Default binary basename looked up on `PATH` when no override is set.
25    fn default_binary_name(&self) -> &'static str;
26
27    /// Resolve the executable path, honouring an optional absolute/relative override.
28    ///
29    /// # Errors
30    ///
31    /// Returns [`AppError::BinaryNotFound`] when neither the override nor the
32    /// default basename can be located via `which`.
33    fn resolve_binary(&self, override_path: Option<&Path>) -> Result<PathBuf, AppError> {
34        let candidate = match override_path {
35            Some(p) => p.to_path_buf(),
36            None => PathBuf::from(self.default_binary_name()),
37        };
38        which::which(&candidate).map_err(|_| AppError::BinaryNotFound {
39            name: candidate.display().to_string(),
40        })
41    }
42
43    /// Spawn a prepared command under the shared process policy.
44    ///
45    /// Default implementation uses plain [`Command::spawn`]. Call sites that
46    /// need `setsid` / `RLIMIT_AS` should continue to use
47    /// `commands::claude_runner::spawn_with_memory_limit` until that helper is
48    /// relocated into this module (follow-up).
49    fn spawn_child(&self, cmd: &mut Command) -> std::io::Result<Child> {
50        cmd.spawn()
51    }
52}
53
54/// Claude Code (`claude`) spawn backend stub.
55///
56/// Wires binary resolution for the Claude CLI. Command construction lives in
57/// [`crate::commands::claude_runner`].
58#[derive(Debug, Default, Clone, Copy)]
59pub struct ClaudeSpawnBackend;
60
61impl LlmSpawnBackend for ClaudeSpawnBackend {
62    fn name(&self) -> &'static str {
63        "claude"
64    }
65
66    fn default_binary_name(&self) -> &'static str {
67        "claude"
68    }
69}
70
71/// Codex CLI (`codex`) spawn backend stub.
72///
73/// Wires binary resolution for the Codex CLI. Command construction lives in
74/// [`crate::commands::codex_spawn`].
75#[derive(Debug, Default, Clone, Copy)]
76pub struct CodexSpawnBackend;
77
78impl LlmSpawnBackend for CodexSpawnBackend {
79    fn name(&self) -> &'static str {
80        "codex"
81    }
82
83    fn default_binary_name(&self) -> &'static str {
84        "codex"
85    }
86}
87
88/// OpenCode headless (`opencode`) spawn backend stub.
89///
90/// Wires binary resolution for the OpenCode CLI. Command construction lives in
91/// [`crate::commands::opencode_runner`].
92#[derive(Debug, Default, Clone, Copy)]
93pub struct OpencodeSpawnBackend;
94
95impl LlmSpawnBackend for OpencodeSpawnBackend {
96    fn name(&self) -> &'static str {
97        "opencode"
98    }
99
100    fn default_binary_name(&self) -> &'static str {
101        "opencode"
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[test]
110    fn claude_backend_names() {
111        let b = ClaudeSpawnBackend;
112        assert_eq!(b.name(), "claude");
113        assert_eq!(b.default_binary_name(), "claude");
114    }
115
116    #[test]
117    fn codex_backend_names() {
118        let b = CodexSpawnBackend;
119        assert_eq!(b.name(), "codex");
120        assert_eq!(b.default_binary_name(), "codex");
121    }
122
123    #[test]
124    fn opencode_backend_names() {
125        let b = OpencodeSpawnBackend;
126        assert_eq!(b.name(), "opencode");
127        assert_eq!(b.default_binary_name(), "opencode");
128    }
129
130    #[test]
131    fn resolve_binary_missing_returns_binary_not_found() {
132        let b = ClaudeSpawnBackend;
133        let err = b
134            .resolve_binary(Some(Path::new(
135                "/nonexistent/sqlite-graphrag-no-such-llm-binary",
136            )))
137            .unwrap_err();
138        match err {
139            AppError::BinaryNotFound { name } => {
140                assert!(name.contains("sqlite-graphrag-no-such-llm-binary"));
141            }
142            other => panic!("expected BinaryNotFound, got {other:?}"),
143        }
144    }
145}