kindling_client/config.rs
1//! Client configuration and the daemon-spawn abstraction.
2
3use std::io;
4use std::path::PathBuf;
5use std::process::{Command, Stdio};
6use std::sync::Arc;
7use std::time::Duration;
8
9/// The canonical schema version this client expects the daemon to report.
10///
11/// Sourced at compile time from a vendored copy of `schema/version.json` — the
12/// same single source of truth `kindling-store` embeds — so the client, store,
13/// and daemon never disagree about the wire/schema contract.
14///
15/// The vendored copy (`crates/kindling-client/schema/version.json`) lives
16/// inside the crate directory so `cargo publish` packages it. It is kept in
17/// lock-step with the repo-root canonical `schema/version.json` by
18/// `scripts/sync-vendored-schema.sh`, enforced by the `vendored-schema` CI
19/// drift gate.
20pub const EXPECTED_SCHEMA_VERSION: u32 = parse_schema_version();
21
22const SCHEMA_VERSION_JSON: &str = include_str!("../schema/version.json");
23
24/// Minimal compile-time-friendly extraction of the integer `"version"` field
25/// from `schema/version.json`.
26///
27/// We avoid pulling `serde_json` into a `const` context (it is not const) by
28/// scanning the embedded JSON for the `"version"` key and parsing the integer
29/// that follows. The format is a hand-maintained, stable contract file, so a
30/// targeted scan is sufficient and keeps this dependency-free and `const`.
31const fn parse_schema_version() -> u32 {
32 let bytes = SCHEMA_VERSION_JSON.as_bytes();
33 let key = b"\"version\"";
34 let mut i = 0;
35 while i + key.len() <= bytes.len() {
36 // Match the `"version"` key.
37 let mut matched = true;
38 let mut k = 0;
39 while k < key.len() {
40 if bytes[i + k] != key[k] {
41 matched = false;
42 break;
43 }
44 k += 1;
45 }
46 if matched {
47 // Advance past the key, the colon, and any whitespace.
48 let mut j = i + key.len();
49 while j < bytes.len() {
50 let c = bytes[j];
51 if c == b':' || c == b' ' || c == b'\t' || c == b'\n' || c == b'\r' {
52 j += 1;
53 } else {
54 break;
55 }
56 }
57 // Parse the integer.
58 let mut value: u32 = 0;
59 let mut saw_digit = false;
60 while j < bytes.len() {
61 let c = bytes[j];
62 if c.is_ascii_digit() {
63 value = value * 10 + (c - b'0') as u32;
64 saw_digit = true;
65 j += 1;
66 } else {
67 break;
68 }
69 }
70 if saw_digit {
71 return value;
72 }
73 }
74 i += 1;
75 }
76 panic!("schema/version.json does not contain an integer \"version\" field");
77}
78
79/// Default file name of the daemon socket under the kindling home.
80const SOCKET_FILE: &str = "kindling.sock";
81
82/// Default file name of the daemon TCP port file under the kindling home.
83const PORT_FILE: &str = "kindling.port";
84
85/// Diagnostic log for auto-spawn / cold-start failures under the kindling home.
86const SPAWN_LOG_FILE: &str = "spawn.log";
87
88/// Which transport the client uses to reach the daemon.
89///
90/// Mirrors `kindling_server::Transport` (each crate keeps its own copy so the
91/// client need not depend on the server). `Uds` exists only on Unix; `Tcp`
92/// exists everywhere and is the Windows default. Defaults to UDS on Unix and
93/// TCP on Windows.
94#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
95pub enum Transport {
96 /// Unix domain socket at [`ClientConfig::socket_path`] (Unix only; the
97 /// platform default there).
98 #[cfg(unix)]
99 #[cfg_attr(unix, default)]
100 Uds,
101 /// Loopback TCP; the port is read from [`ClientConfig::port_path`]. The
102 /// platform default on non-Unix (Windows).
103 #[cfg_attr(not(unix), default)]
104 Tcp,
105}
106
107/// How the client starts the daemon when it is not already running.
108///
109/// The default execs the real `kindling` binary; tests inject a closure that
110/// starts an in-process daemon so cold-spawn can be exercised without the
111/// (not-yet-built) binary on `PATH`.
112#[derive(Clone, Default)]
113pub enum Spawner {
114 /// Production path: `kindling serve --daemonize`, detached (not awaited).
115 /// The `kindling` binary is PORT-013; until it exists this path simply
116 /// surfaces a clean spawn error, which the connect logic maps to
117 /// [`ClientError::Unavailable`](crate::ClientError::Unavailable).
118 #[default]
119 Command,
120 /// Test/custom path: invoke this closure to start the daemon.
121 Custom(Arc<dyn Fn() -> io::Result<()> + Send + Sync>),
122}
123
124impl Spawner {
125 /// Build a custom spawner from a closure.
126 pub fn custom<F>(f: F) -> Self
127 where
128 F: Fn() -> io::Result<()> + Send + Sync + 'static,
129 {
130 Spawner::Custom(Arc::new(f))
131 }
132
133 /// Run the spawn action once.
134 pub(crate) fn spawn(&self) -> io::Result<()> {
135 match self {
136 Spawner::Command => {
137 let mut cmd = Command::new("kindling");
138 cmd.args(["serve", "--daemonize"])
139 // Detach the daemon's stdio from ours. Without this the
140 // long-lived daemon inherits the spawner's stdout — fatal
141 // for a Claude Code hook, whose stdout must carry only the
142 // hook's JSON response. Null also lets the spawner exit
143 // without the pipe keeping the daemon's fds open.
144 .stdin(Stdio::null())
145 .stdout(Stdio::null())
146 .stderr(Stdio::null());
147 // Put the daemon in its own process group so a signal sent to
148 // the spawner's group (e.g. Ctrl-C in an interactive shell, or
149 // the shell reaping a hook) does not also kill the daemon.
150 #[cfg(unix)]
151 {
152 use std::os::unix::process::CommandExt;
153 cmd.process_group(0);
154 }
155 cmd.spawn()?;
156 Ok(())
157 }
158 Spawner::Custom(f) => f(),
159 }
160 }
161}
162
163impl std::fmt::Debug for Spawner {
164 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165 match self {
166 Spawner::Command => f.write_str("Spawner::Command"),
167 Spawner::Custom(_) => f.write_str("Spawner::Custom(..)"),
168 }
169 }
170}
171
172/// Configuration for a [`Client`](crate::Client).
173#[derive(Clone, Debug)]
174pub struct ClientConfig {
175 /// Unix domain socket the daemon listens on
176 /// (`~/.kindling/kindling.sock` by default). Used when [`Self::transport`]
177 /// is [`Transport::Uds`].
178 pub socket_path: PathBuf,
179 /// File the daemon publishes its TCP port to
180 /// (`~/.kindling/kindling.port` by default). Read when [`Self::transport`]
181 /// is [`Transport::Tcp`].
182 pub port_path: PathBuf,
183 /// Project root string, sent as the `X-Kindling-Project` header on every
184 /// data endpoint. The daemon hashes it to route to a per-project DB.
185 /// Defaults to the current working directory.
186 pub project_root: String,
187 /// Schema version the client requires the daemon to report from
188 /// `/v1/health`. Defaults to [`EXPECTED_SCHEMA_VERSION`].
189 pub expected_schema_version: u32,
190 /// Total budget for the auto-spawn connect poll (connect + spawn + retry).
191 /// Defaults to 1 second.
192 pub connect_timeout: Duration,
193 /// Interval between socket-connect attempts while polling for the daemon.
194 /// Defaults to 10ms.
195 pub poll_interval: Duration,
196 /// How to start the daemon when it is not running. Defaults to the real
197 /// `kindling serve --daemonize` binary.
198 pub spawn: Spawner,
199 /// Transport to reach the daemon. Defaults to [`Transport::default`] (UDS
200 /// on Unix, TCP on Windows).
201 pub transport: Transport,
202 /// Override for the spawn-failure diagnostic log path. When `None`,
203 /// [`effective_spawn_log_path`](Self::effective_spawn_log_path) uses
204 /// `~/.kindling/spawn.log`.
205 pub spawn_log_path: Option<PathBuf>,
206}
207
208impl ClientConfig {
209 /// Build a default config: `~/.kindling/kindling.sock`, project root from
210 /// the current directory, the compiled schema version, a 1s connect budget,
211 /// a 10ms poll interval, and the real binary spawner.
212 ///
213 /// Errors only if neither the kindling home nor the current directory can
214 /// be determined.
215 pub fn defaults() -> io::Result<Self> {
216 let socket_path = default_socket_path().ok_or_else(|| {
217 io::Error::new(
218 io::ErrorKind::NotFound,
219 "could not determine kindling home (no HOME/USERPROFILE)",
220 )
221 })?;
222 let port_path = default_port_path().ok_or_else(|| {
223 io::Error::new(
224 io::ErrorKind::NotFound,
225 "could not determine kindling home (no HOME/USERPROFILE)",
226 )
227 })?;
228 let project_root = std::env::current_dir()?.to_string_lossy().into_owned();
229 Ok(Self {
230 socket_path,
231 port_path,
232 project_root,
233 expected_schema_version: EXPECTED_SCHEMA_VERSION,
234 connect_timeout: Duration::from_secs(1),
235 poll_interval: Duration::from_millis(10),
236 spawn: Spawner::default(),
237 transport: Transport::default(),
238 spawn_log_path: None,
239 })
240 }
241
242 /// Path for spawn-failure diagnostics (`~/.kindling/spawn.log` by default).
243 pub fn effective_spawn_log_path(&self) -> Option<PathBuf> {
244 self.spawn_log_path.clone().or_else(default_spawn_log_path)
245 }
246}
247
248/// Default daemon socket path: `~/.kindling/kindling.sock`.
249///
250/// Replicates `kindling_store::default_kindling_home`'s HOME/USERPROFILE logic
251/// locally so the client need not depend on `kindling-store` (which pulls
252/// rusqlite and would defeat the crate's thinness goal).
253pub fn default_socket_path() -> Option<PathBuf> {
254 kindling_home_dir().map(|home| home.join(SOCKET_FILE))
255}
256
257/// Default daemon TCP port file path: `~/.kindling/kindling.port`.
258///
259/// Mirrors [`default_socket_path`] (same HOME/USERPROFILE resolution) but points
260/// at the side-channel file the TCP-transport daemon publishes its bound port
261/// to.
262pub fn default_port_path() -> Option<PathBuf> {
263 kindling_home_dir().map(|home| home.join(PORT_FILE))
264}
265
266/// Default spawn-failure log: `~/.kindling/spawn.log`.
267pub fn default_spawn_log_path() -> Option<PathBuf> {
268 kindling_home_dir().map(|home| home.join(SPAWN_LOG_FILE))
269}
270
271/// Resolve `~/.kindling` from `HOME` / `USERPROFILE` (mirrors socket path logic).
272fn kindling_home_dir() -> Option<PathBuf> {
273 let home = std::env::var_os("HOME")
274 .filter(|v| !v.is_empty())
275 .or_else(|| std::env::var_os("USERPROFILE").filter(|v| !v.is_empty()))?;
276 Some(PathBuf::from(home).join(".kindling"))
277}
278
279/// Append a timestamped spawn-failure line to `path`. Best-effort: errors are
280/// ignored so logging never masks the original connect failure.
281pub(crate) fn append_spawn_log(path: &std::path::Path, detail: &str) {
282 use std::io::Write;
283
284 let _ = (|| -> io::Result<()> {
285 if let Some(parent) = path.parent() {
286 std::fs::create_dir_all(parent)?;
287 }
288 let ts = spawn_log_timestamp_ms();
289 let mut file = std::fs::OpenOptions::new()
290 .create(true)
291 .append(true)
292 .open(path)?;
293 writeln!(file, "[{ts}] {detail}")?;
294 file.flush()?;
295 Ok(())
296 })();
297}
298
299fn spawn_log_timestamp_ms() -> i64 {
300 use std::time::{SystemTime, UNIX_EPOCH};
301 SystemTime::now()
302 .duration_since(UNIX_EPOCH)
303 .unwrap_or_default()
304 .as_millis() as i64
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310
311 #[test]
312 fn expected_schema_version_parses_to_five() {
313 // schema/version.json currently pins version 5.
314 assert_eq!(EXPECTED_SCHEMA_VERSION, 5);
315 }
316}