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}