Skip to main content

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}