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