Skip to main content

theway_daemon/
paths.rs

1//! Daemon path context (issue #66): every host path the daemon kernel needs is
2//! resolved ONCE at the CLI boundary ([`DaemonPaths::from_cli`]) and then
3//! handed to kernel modules as explicit parameters. Kernel code must not read
4//! `HOME` / `THEWAY_DIR` (or any path-shaped env var) itself — the environment
5//! is consulted only inside [`DaemonPaths::from_cli`].
6//!
7//! **Exception.** `theway_contract::config::base_dir()` (and its
8//! `theway_transport::{client, config}` re-exports) stays env-driven on
9//! purpose: transport port-file discovery and the inbox path are the shared
10//! client↔daemon discovery contract — the TUI/CLI client derives the same
11//! `<THEWAY_DIR>/daemon-port-<cwd-hash>` file from the environment to find a
12//! running daemon, so that derivation must stay identical on both sides.
13//! Call sites that implement that contract (transport port-file discovery,
14//! inbox) are exempt from the "no env reads in the kernel" rule.
15
16use std::path::PathBuf;
17use std::sync::{Arc, RwLock};
18
19use crate::shared_lock::{read_lock, write_lock};
20
21/// Resolved host-path context for one daemon process.
22///
23/// Built once at startup by the composition root (`bin/thewayd.rs`) from CLI
24/// flags + environment; every consumer afterwards receives plain `Path`
25/// values.
26#[derive(Clone, Debug)]
27pub struct DaemonPaths {
28    /// The theway base dir (`config.toml`, `skill-overrides.json`, `skills/`,
29    /// `extensions/`, …): `$THEWAY_DIR` when set, else `<home>/.theway`.
30    pub base: PathBuf,
31    /// The user home dir (user-level `.agents` / `.claude` config roots):
32    /// the `--home` flag when given, else `$HOME`.
33    pub home: PathBuf,
34    /// The working directory (session repo + tool execution): the `--cwd`
35    /// flag when given, else the process cwd. Best-effort canonicalized;
36    /// a failed canonicalize keeps the original value.
37    pub work_dir: PathBuf,
38    /// Extra skill directories supplied via `--skills-dir` (repeatable);
39    /// consumed by the skill-loading node (issue #66 follow-up). Dynamically
40    /// replaceable at runtime via `SetSkillDirs` (issue #68) — shared behind
41    /// an `Arc<RwLock<..>>` so every `Clone` of this struct observes the same
42    /// current value; read through [`Self::current_extra_skill_dirs`] and
43    /// written through [`Self::set_extra_skill_dirs`].
44    pub extra_skill_dirs: Arc<RwLock<Vec<PathBuf>>>,
45}
46
47impl DaemonPaths {
48    /// Resolve the daemon path context at the CLI boundary. This is the ONLY
49    /// place in the daemon that reads `THEWAY_DIR` / `HOME`.
50    ///
51    /// Precedence:
52    /// - `base`: `--theway-dir` overrides `$THEWAY_DIR`, which overrides the
53    ///   `<home>/.theway` derivation.
54    /// - `home`: the explicit flag overrides `$HOME`.
55    /// - `work_dir`: the explicit flag overrides the process cwd; the result
56    ///   is canonicalized best-effort (a failed canonicalize — e.g. the dir
57    ///   does not exist yet — keeps the original value so the caller can
58    ///   still surface a "cd into …" error).
59    pub fn from_cli(
60        cwd: Option<PathBuf>,
61        home: Option<PathBuf>,
62        extra_skill_dirs: Vec<PathBuf>,
63    ) -> Self {
64        Self::from_cli_with_base(cwd, home, extra_skill_dirs, None)
65    }
66
67    /// [`from_cli`] with an explicit base dir (`thewayd --theway-dir`).
68    pub fn from_cli_with_base(
69        cwd: Option<PathBuf>,
70        home: Option<PathBuf>,
71        extra_skill_dirs: Vec<PathBuf>,
72        theway_dir: Option<PathBuf>,
73    ) -> Self {
74        let home = home.unwrap_or_else(|| {
75            std::env::var_os("HOME")
76                .map(PathBuf::from)
77                .unwrap_or_else(|| PathBuf::from("."))
78        });
79        let base = theway_dir
80            .or_else(|| std::env::var_os("THEWAY_DIR").map(PathBuf::from))
81            .unwrap_or_else(|| home.join(".theway"));
82        let work_dir = match cwd {
83            Some(dir) => dir,
84            None => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
85        };
86        let work_dir = work_dir.canonicalize().unwrap_or(work_dir);
87        Self {
88            base,
89            home,
90            work_dir,
91            extra_skill_dirs: Arc::new(RwLock::new(extra_skill_dirs)),
92        }
93    }
94
95    /// The user-global skills root: `<base>/skills`.
96    pub fn skills_root(&self) -> PathBuf {
97        self.base.join("skills")
98    }
99
100    /// Derive a cwd-scoped view while sharing mutable extra skill directories.
101    pub fn with_work_dir(&self, work_dir: impl Into<PathBuf>) -> Self {
102        let work_dir = work_dir.into();
103        let work_dir = work_dir.canonicalize().unwrap_or(work_dir);
104        Self {
105            base: self.base.clone(),
106            home: self.home.clone(),
107            work_dir,
108            extra_skill_dirs: self.extra_skill_dirs.clone(),
109        }
110    }
111
112    /// Replace the extra skill directories at runtime (issue #68: applied by
113    /// the serialized event loop when a `SetSkillDirs` command lands). The
114    /// change is visible through every `Clone` of this struct.
115    pub fn set_extra_skill_dirs(&self, dirs: Vec<PathBuf>) {
116        *write_lock(&self.extra_skill_dirs) = dirs;
117    }
118
119    /// Snapshot of the current extra skill directories (issue #68: the list
120    /// may be replaced at runtime via [`Self::set_extra_skill_dirs`]).
121    pub fn current_extra_skill_dirs(&self) -> Vec<PathBuf> {
122        read_lock(&self.extra_skill_dirs).clone()
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    //! Env-mutating tests: `from_cli` is the single boundary that reads
129    //! `THEWAY_DIR` / `HOME`, so these tests set/restore both. They share the
130    //! crate-wide [`crate::test_env`] lock (issue #16) with every other
131    //! bridged module that mutates process env, and hold the guard across the
132    //! whole test body so a racing test never observes a half-swapped env.
133
134    use super::*;
135    use crate::test_env::{ENV_LOCK, EnvGuard};
136
137    fn canonical(path: &std::path::Path) -> PathBuf {
138        path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
139    }
140
141    #[test]
142    fn theway_dir_overrides_home_derived_base() {
143        let _serial = ENV_LOCK.lock().unwrap();
144        let _theway = EnvGuard::set("THEWAY_DIR", "/custom/theway");
145        let _home_env = EnvGuard::set("HOME", "/env-home");
146
147        let paths = DaemonPaths::from_cli(None, Some(PathBuf::from("/flag-home")), Vec::new());
148        assert_eq!(paths.base, PathBuf::from("/custom/theway"));
149        assert_eq!(paths.home, PathBuf::from("/flag-home"));
150        assert_eq!(paths.skills_root(), PathBuf::from("/custom/theway/skills"));
151    }
152
153    #[test]
154    fn explicit_home_overrides_env_home_and_derives_base() {
155        let _serial = ENV_LOCK.lock().unwrap();
156        let _theway = EnvGuard::remove("THEWAY_DIR");
157        let _home_env = EnvGuard::set("HOME", "/env-home");
158
159        let paths = DaemonPaths::from_cli(None, Some(PathBuf::from("/flag-home")), Vec::new());
160        assert_eq!(paths.home, PathBuf::from("/flag-home"));
161        assert_eq!(paths.base, PathBuf::from("/flag-home/.theway"));
162    }
163
164    #[test]
165    fn theway_dir_flag_overrides_env_and_home() {
166        let _serial = ENV_LOCK.lock().unwrap();
167        let _theway = EnvGuard::set("THEWAY_DIR", "/env/theway");
168        let _home_env = EnvGuard::set("HOME", "/env-home");
169
170        let paths = DaemonPaths::from_cli_with_base(
171            None,
172            Some(PathBuf::from("/flag-home")),
173            Vec::new(),
174            Some(PathBuf::from("/custom/theway")),
175        );
176        assert_eq!(paths.base, PathBuf::from("/custom/theway"));
177        assert_eq!(paths.skills_root(), PathBuf::from("/custom/theway/skills"));
178    }
179
180    #[test]
181    fn from_cli_with_base_keeps_env_precedence_without_flag() {
182        let _serial = ENV_LOCK.lock().unwrap();
183        let _theway = EnvGuard::set("THEWAY_DIR", "/env/theway");
184        let _home_env = EnvGuard::set("HOME", "/env-home");
185
186        let paths = DaemonPaths::from_cli_with_base(
187            None,
188            Some(PathBuf::from("/flag-home")),
189            Vec::new(),
190            None,
191        );
192        assert_eq!(paths.base, PathBuf::from("/env/theway"));
193    }
194
195    #[test]
196    fn from_cli_with_base_defaults_to_home_theway() {
197        let _serial = ENV_LOCK.lock().unwrap();
198        let _theway = EnvGuard::remove("THEWAY_DIR");
199        let _home_env = EnvGuard::set("HOME", "/env-home");
200
201        let paths = DaemonPaths::from_cli_with_base(
202            None,
203            Some(PathBuf::from("/flag-home")),
204            Vec::new(),
205            None,
206        );
207        assert_eq!(paths.base, PathBuf::from("/flag-home/.theway"));
208    }
209
210    #[test]
211    fn env_home_derives_base_when_no_flag() {
212        let _serial = ENV_LOCK.lock().unwrap();
213        let _theway = EnvGuard::remove("THEWAY_DIR");
214        let _home_env = EnvGuard::set("HOME", "/env-home");
215
216        let paths = DaemonPaths::from_cli(None, None, Vec::new());
217        assert_eq!(paths.home, PathBuf::from("/env-home"));
218        assert_eq!(paths.base, PathBuf::from("/env-home/.theway"));
219    }
220
221    #[test]
222    fn work_dir_falls_back_to_process_cwd() {
223        let _serial = ENV_LOCK.lock().unwrap();
224        let _theway = EnvGuard::remove("THEWAY_DIR");
225
226        let paths = DaemonPaths::from_cli(None, Some(PathBuf::from("/h")), Vec::new());
227        let expected = std::env::current_dir().unwrap();
228        assert_eq!(paths.work_dir, canonical(&expected));
229    }
230
231    #[test]
232    fn explicit_work_dir_wins_and_survives_failed_canonicalize() {
233        let _serial = ENV_LOCK.lock().unwrap();
234        let _theway = EnvGuard::remove("THEWAY_DIR");
235
236        // Existing dir: canonicalized.
237        let temp = tempfile::tempdir().unwrap();
238        let paths = DaemonPaths::from_cli(Some(temp.path().to_path_buf()), None, Vec::new());
239        assert_eq!(paths.work_dir, canonical(temp.path()));
240
241        // Missing dir: canonicalize fails → the original value is kept so the
242        // composition root can still fail with a "cd into …" error.
243        let missing = PathBuf::from("/nonexistent-theway-work-dir-66");
244        let paths = DaemonPaths::from_cli(Some(missing.clone()), None, Vec::new());
245        assert_eq!(paths.work_dir, missing);
246    }
247
248    #[test]
249    fn extra_skill_dirs_are_carried_through() {
250        let _serial = ENV_LOCK.lock().unwrap();
251        let _theway = EnvGuard::remove("THEWAY_DIR");
252
253        let extras = vec![PathBuf::from("/a/skills"), PathBuf::from("/b/skills")];
254        let paths = DaemonPaths::from_cli(None, Some(PathBuf::from("/h")), extras.clone());
255        assert_eq!(paths.current_extra_skill_dirs(), extras);
256    }
257
258    #[test]
259    fn extra_skill_dirs_update_dynamically() {
260        let _serial = ENV_LOCK.lock().unwrap();
261        let _theway = EnvGuard::remove("THEWAY_DIR");
262
263        let paths = DaemonPaths::from_cli(
264            None,
265            Some(PathBuf::from("/h")),
266            vec![PathBuf::from("/a/skills")],
267        );
268        assert_eq!(
269            paths.current_extra_skill_dirs(),
270            vec![PathBuf::from("/a/skills")]
271        );
272
273        // Runtime replacement (issue #68 `SetSkillDirs`): the accessor sees
274        // the new list, not the startup value.
275        paths.set_extra_skill_dirs(vec![PathBuf::from("/x/skills"), PathBuf::from("/y/skills")]);
276        assert_eq!(
277            paths.current_extra_skill_dirs(),
278            vec![PathBuf::from("/x/skills"), PathBuf::from("/y/skills")]
279        );
280
281        // Clearing is a legitimate update too (empty list → no extras).
282        paths.set_extra_skill_dirs(Vec::new());
283        assert!(paths.current_extra_skill_dirs().is_empty());
284    }
285
286    #[test]
287    fn with_work_dir_preserves_shared_base_home_and_extra_skill_dirs() {
288        let _serial = ENV_LOCK.lock().unwrap();
289        let _theway = EnvGuard::remove("THEWAY_DIR");
290        let _home_env = EnvGuard::set("HOME", "/env-home");
291
292        let paths = DaemonPaths::from_cli(
293            None,
294            Some(PathBuf::from("/flag-home")),
295            vec![PathBuf::from("/shared/skills")],
296        );
297        let other = tempfile::tempdir().unwrap();
298        let derived = paths.with_work_dir(other.path());
299
300        assert_eq!(derived.base, paths.base);
301        assert_eq!(derived.home, paths.home);
302        assert_eq!(derived.work_dir, canonical(other.path()));
303        assert!(Arc::ptr_eq(
304            &derived.extra_skill_dirs,
305            &paths.extra_skill_dirs
306        ));
307
308        paths.set_extra_skill_dirs(vec![PathBuf::from("/updated/skills")]);
309        assert_eq!(
310            derived.current_extra_skill_dirs(),
311            vec![PathBuf::from("/updated/skills")]
312        );
313    }
314
315    #[test]
316    fn extra_skill_dirs_shared_across_clones() {
317        let _serial = ENV_LOCK.lock().unwrap();
318        let _theway = EnvGuard::remove("THEWAY_DIR");
319
320        let paths = DaemonPaths::from_cli(None, Some(PathBuf::from("/h")), Vec::new());
321        let cloned = paths.clone();
322
323        // The backing list is shared behind an Arc: an update through one
324        // handle is observed through the other (issue #68 — the event loop
325        // and the skill loader may hold separate clones of the same context).
326        paths.set_extra_skill_dirs(vec![PathBuf::from("/shared/skills")]);
327        assert_eq!(
328            cloned.current_extra_skill_dirs(),
329            vec![PathBuf::from("/shared/skills")]
330        );
331        cloned.set_extra_skill_dirs(Vec::new());
332        assert!(paths.current_extra_skill_dirs().is_empty());
333    }
334}