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/// 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}