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/// Which transport the client uses to reach the daemon.
86///
87/// Mirrors `kindling_server::Transport` (each crate keeps its own copy so the
88/// client need not depend on the server). `Uds` exists only on Unix; `Tcp`
89/// exists everywhere and is the Windows default. Defaults to UDS on Unix and
90/// TCP on Windows.
91#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
92pub enum Transport {
93 /// Unix domain socket at [`ClientConfig::socket_path`] (Unix only; the
94 /// platform default there).
95 #[cfg(unix)]
96 #[cfg_attr(unix, default)]
97 Uds,
98 /// Loopback TCP; the port is read from [`ClientConfig::port_path`]. The
99 /// platform default on non-Unix (Windows).
100 #[cfg_attr(not(unix), default)]
101 Tcp,
102}
103
104/// How the client starts the daemon when it is not already running.
105///
106/// The default execs the real `kindling` binary; tests inject a closure that
107/// starts an in-process daemon so cold-spawn can be exercised without the
108/// (not-yet-built) binary on `PATH`.
109#[derive(Clone, Default)]
110pub enum Spawner {
111 /// Production path: `kindling serve --daemonize`, detached (not awaited).
112 /// The `kindling` binary is PORT-013; until it exists this path simply
113 /// surfaces a clean spawn error, which the connect logic maps to
114 /// [`ClientError::Unavailable`](crate::ClientError::Unavailable).
115 #[default]
116 Command,
117 /// Test/custom path: invoke this closure to start the daemon.
118 Custom(Arc<dyn Fn() -> io::Result<()> + Send + Sync>),
119}
120
121impl Spawner {
122 /// Build a custom spawner from a closure.
123 pub fn custom<F>(f: F) -> Self
124 where
125 F: Fn() -> io::Result<()> + Send + Sync + 'static,
126 {
127 Spawner::Custom(Arc::new(f))
128 }
129
130 /// Run the spawn action once.
131 pub(crate) fn spawn(&self) -> io::Result<()> {
132 match self {
133 Spawner::Command => {
134 let mut cmd = Command::new("kindling");
135 cmd.args(["serve", "--daemonize"])
136 // Detach the daemon's stdio from ours. Without this the
137 // long-lived daemon inherits the spawner's stdout — fatal
138 // for a Claude Code hook, whose stdout must carry only the
139 // hook's JSON response. Null also lets the spawner exit
140 // without the pipe keeping the daemon's fds open.
141 .stdin(Stdio::null())
142 .stdout(Stdio::null())
143 .stderr(Stdio::null());
144 // Put the daemon in its own process group so a signal sent to
145 // the spawner's group (e.g. Ctrl-C in an interactive shell, or
146 // the shell reaping a hook) does not also kill the daemon.
147 #[cfg(unix)]
148 {
149 use std::os::unix::process::CommandExt;
150 cmd.process_group(0);
151 }
152 cmd.spawn()?;
153 Ok(())
154 }
155 Spawner::Custom(f) => f(),
156 }
157 }
158}
159
160impl std::fmt::Debug for Spawner {
161 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162 match self {
163 Spawner::Command => f.write_str("Spawner::Command"),
164 Spawner::Custom(_) => f.write_str("Spawner::Custom(..)"),
165 }
166 }
167}
168
169/// Configuration for a [`Client`](crate::Client).
170#[derive(Clone, Debug)]
171pub struct ClientConfig {
172 /// Unix domain socket the daemon listens on
173 /// (`~/.kindling/kindling.sock` by default). Used when [`Self::transport`]
174 /// is [`Transport::Uds`].
175 pub socket_path: PathBuf,
176 /// File the daemon publishes its TCP port to
177 /// (`~/.kindling/kindling.port` by default). Read when [`Self::transport`]
178 /// is [`Transport::Tcp`].
179 pub port_path: PathBuf,
180 /// Project root string, sent as the `X-Kindling-Project` header on every
181 /// data endpoint. The daemon hashes it to route to a per-project DB.
182 /// Defaults to the current working directory.
183 pub project_root: String,
184 /// Schema version the client requires the daemon to report from
185 /// `/v1/health`. Defaults to [`EXPECTED_SCHEMA_VERSION`].
186 pub expected_schema_version: u32,
187 /// Total budget for the auto-spawn connect poll (connect + spawn + retry).
188 /// Defaults to 1 second.
189 pub connect_timeout: Duration,
190 /// Interval between socket-connect attempts while polling for the daemon.
191 /// Defaults to 10ms.
192 pub poll_interval: Duration,
193 /// How to start the daemon when it is not running. Defaults to the real
194 /// `kindling serve --daemonize` binary.
195 pub spawn: Spawner,
196 /// Transport to reach the daemon. Defaults to [`Transport::default`] (UDS
197 /// on Unix, TCP on Windows).
198 pub transport: Transport,
199}
200
201impl ClientConfig {
202 /// Build a default config: `~/.kindling/kindling.sock`, project root from
203 /// the current directory, the compiled schema version, a 1s connect budget,
204 /// a 10ms poll interval, and the real binary spawner.
205 ///
206 /// Errors only if neither the kindling home nor the current directory can
207 /// be determined.
208 pub fn defaults() -> io::Result<Self> {
209 let socket_path = default_socket_path().ok_or_else(|| {
210 io::Error::new(
211 io::ErrorKind::NotFound,
212 "could not determine kindling home (no HOME/USERPROFILE)",
213 )
214 })?;
215 let port_path = default_port_path().ok_or_else(|| {
216 io::Error::new(
217 io::ErrorKind::NotFound,
218 "could not determine kindling home (no HOME/USERPROFILE)",
219 )
220 })?;
221 let project_root = std::env::current_dir()?.to_string_lossy().into_owned();
222 Ok(Self {
223 socket_path,
224 port_path,
225 project_root,
226 expected_schema_version: EXPECTED_SCHEMA_VERSION,
227 connect_timeout: Duration::from_secs(1),
228 poll_interval: Duration::from_millis(10),
229 spawn: Spawner::default(),
230 transport: Transport::default(),
231 })
232 }
233}
234
235/// Default daemon socket path: `~/.kindling/kindling.sock`.
236///
237/// Replicates `kindling_store::default_kindling_home`'s HOME/USERPROFILE logic
238/// locally so the client need not depend on `kindling-store` (which pulls
239/// rusqlite and would defeat the crate's thinness goal).
240pub fn default_socket_path() -> Option<PathBuf> {
241 let home = std::env::var_os("HOME")
242 .filter(|v| !v.is_empty())
243 .or_else(|| std::env::var_os("USERPROFILE").filter(|v| !v.is_empty()))?;
244 Some(PathBuf::from(home).join(".kindling").join(SOCKET_FILE))
245}
246
247/// Default daemon TCP port file path: `~/.kindling/kindling.port`.
248///
249/// Mirrors [`default_socket_path`] (same HOME/USERPROFILE resolution) but points
250/// at the side-channel file the TCP-transport daemon publishes its bound port
251/// to.
252pub fn default_port_path() -> Option<PathBuf> {
253 let home = std::env::var_os("HOME")
254 .filter(|v| !v.is_empty())
255 .or_else(|| std::env::var_os("USERPROFILE").filter(|v| !v.is_empty()))?;
256 Some(PathBuf::from(home).join(".kindling").join(PORT_FILE))
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262
263 #[test]
264 fn expected_schema_version_parses_to_five() {
265 // schema/version.json currently pins version 5.
266 assert_eq!(EXPECTED_SCHEMA_VERSION, 5);
267 }
268}