Skip to main content

spec_driven_docs/domain/
paths.rs

1//! Every control path this tool names, declared once.
2//!
3//! Two kinds of path meet here and stay apart. A control path is this
4//! tool's own: the instance directory, the declaration, the debt files, the
5//! hook configuration, the agent digest, the state and cache roots, and the
6//! agent skill roots. A destination is what a release projects into a
7//! target, and it is versioned data rather than a constant, so it stays
8//! with the projection and reaches this module only as a resolved value.
9//!
10//! The module is otherwise pure. It reads no filesystem. It reads the
11//! process environment in exactly one place, [`UserEnv::from_process`], so
12//! every resolver below takes the environment as an argument and a test
13//! constructs one instead of mutating the process.
14
15use std::collections::BTreeMap;
16
17use camino::{Utf8Path, Utf8PathBuf};
18use serde::Serialize;
19
20use crate::domain::profile::{DocsRoot, ProfileId};
21
22/// The variable naming the invoking user's home directory.
23pub const HOME_VAR: &str = "HOME";
24/// The variable relocating Claude Code's whole configuration directory.
25pub const CLAUDE_CONFIG_DIR_VAR: &str = "CLAUDE_CONFIG_DIR";
26/// The variable naming the XDG state base directory.
27pub const XDG_STATE_HOME_VAR: &str = "XDG_STATE_HOME";
28/// The variable naming the XDG cache base directory.
29pub const XDG_CACHE_HOME_VAR: &str = "XDG_CACHE_HOME";
30/// The variable that tells this tool to reach no network.
31pub const OFFLINE_VAR: &str = "SDD_OFFLINE";
32/// The variable that names the docs scratch.
33pub const DOCS_SCRATCH_VAR: &str = "SDD_DOCS_SCRATCH";
34/// The variable an operator sets to hold the consumer pin where it is.
35pub const SELF_DEPEND_OFF_VAR: &str = "SDD_SELF_DEPEND_OFF";
36/// The variable a continuous-integration environment sets, where the pin
37/// is whatever the checkout carries.
38pub const CI_VAR: &str = "CI";
39
40/// This tool's directory name under a base directory.
41pub const TOOL_DIR: &str = "spec-driven-docs";
42
43/// The instance directory, relative to the instance root.
44pub const INSTANCE_DIR: &str = ".spec-driven-docs";
45/// The manifest path, relative to the instance root.
46pub const MANIFEST_PATH: &str = ".spec-driven-docs/manifest.json";
47/// Where an instance keeps its declaration.
48pub const CONFIG_PATH: &str = ".spec-driven-docs/config.yaml";
49/// Where an instance keeps its debt.
50pub const DEBT_PATH: &str = ".spec-driven-docs/debt.yaml";
51/// The flat list of exempt chapters an older instance carries.
52pub const LEGACY_DEBT_PATH: &str = ".spec-driven-docs/chapter-size-debt.txt";
53/// Where a pre-commit configuration lives in a target.
54pub const HOOKS_CONFIG_PATH: &str = ".pre-commit-config.yaml";
55/// Where the documentation block lives in a target.
56pub const AGENTS_DIGEST_PATH: &str = "AGENTS.md";
57
58/// The only roots an upgrade may remove dropped managed files from.
59pub const PRUNABLE_ROOTS: &[&str] = &[".spec-driven-docs/", ".claude/skills/", ".agents/skills/"];
60
61/// The state root, relative to the home directory, where no variable moves it.
62pub const STATE_ROOT: &str = ".local/state/spec-driven-docs";
63/// The cache root, relative to the home directory, where no variable moves it.
64pub const CACHE_ROOT: &str = ".cache/spec-driven-docs";
65/// The user-scope skill receipt, relative to the state root.
66pub const SKILL_RECEIPT_FILE: &str = "skills.json";
67/// The one file every skill package must carry.
68pub const SKILL_FILE: &str = "SKILL.md";
69/// Where a skill package holds what the shared source materialized into it.
70pub const SKILL_REFERENCES_DIR: &str = "references";
71
72/// The user-scope lock, relative to the state root.
73pub const SKILL_LOCK_FILE: &str = "skills.lock";
74/// The per-checkout sync stamps, relative to the state root.
75pub const SELF_DEPEND_STAMP_DIR: &str = "self-depend";
76/// The user-scope skill receipt at the home-relative path, read once.
77///
78/// A home installed before the state root followed `XDG_STATE_HOME` holds
79/// its receipt here. The next apply reads it, then writes only the resolved
80/// path.
81pub const LEGACY_SKILL_RECEIPT_PATH: &str = ".local/state/spec-driven-docs/skills.json";
82/// The retired root that once held what every skill shares.
83///
84/// A skill package now carries its own copy under `references/`, so this
85/// root is swept rather than written. A file there the receipt vouches for
86/// is the tool's and goes; anything else is the user's and stays.
87pub const LEGACY_SHARED_ROOT: &str = ".local/state/spec-driven-docs/skills/shared";
88/// The skill root Claude Code reads, relative to the home directory.
89pub const CLAUDE_ROOT: &str = ".claude/skills";
90/// The skill root every other agent reads, relative to the home directory.
91pub const AGENTS_ROOT: &str = ".agents/skills";
92
93/// Which agent family a skill root serves.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
95#[serde(rename_all = "kebab-case")]
96pub enum AgentId {
97    /// Claude Code, which reads its own configuration directory.
98    Claude,
99    /// The shared root Codex, `OpenCode`, and Pi each document that they read.
100    Agents,
101}
102
103impl AgentId {
104    /// The kebab-case name used on the command line and in the report.
105    #[must_use]
106    pub const fn as_str(self) -> &'static str {
107        match self {
108            Self::Claude => "claude",
109            Self::Agents => "agents",
110        }
111    }
112}
113
114impl std::fmt::Display for AgentId {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        f.write_str(self.as_str())
117    }
118}
119
120/// One agent skill root, and what relocates it.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub struct AgentRoot {
123    /// The family this root serves.
124    pub id: AgentId,
125    /// The root, relative to the home directory, where no variable moves it.
126    pub default: &'static str,
127    /// The variable relocating the agent's whole configuration directory.
128    pub config_env: Option<&'static str>,
129    /// The root, relative to that variable's value.
130    pub relocated: &'static str,
131}
132
133/// The root Claude Code reads, and the variable that relocates it.
134const CLAUDE: AgentRoot = AgentRoot {
135    id: AgentId::Claude,
136    default: CLAUDE_ROOT,
137    config_env: Some(CLAUDE_CONFIG_DIR_VAR),
138    relocated: "skills",
139};
140
141/// The root every other agent reads. No variable relocates it.
142const AGENTS: AgentRoot = AgentRoot {
143    id: AgentId::Agents,
144    default: AGENTS_ROOT,
145    config_env: None,
146    relocated: "skills",
147};
148
149/// Every agent skill root this tool writes, in report order.
150///
151/// Two rows, and no third. Codex, `OpenCode`, and Pi each document that they
152/// read the shared root, checked 2026-09-12, so a row per host would be
153/// three names for one directory. A host with a root of its own is one more
154/// row and one more test on the day it needs one.
155pub const AGENT_ROOTS: &[AgentRoot] = &[CLAUDE, AGENTS];
156
157/// The agent root one identifier names.
158#[must_use]
159pub const fn agent_root(id: AgentId) -> &'static AgentRoot {
160    match id {
161        AgentId::Claude => &CLAUDE,
162        AgentId::Agents => &AGENTS,
163    }
164}
165
166/// What decided a path.
167#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
168#[serde(rename_all = "kebab-case")]
169pub enum PathSource {
170    /// The instance manifest records it.
171    Recorded,
172    /// Nothing moved it, so it is the declared default.
173    Default,
174    /// A variable carries it.
175    Env,
176    /// The profile's documentation root derives it.
177    Profile,
178    /// The target's own shape suggests it, and nothing recorded it.
179    Proposal,
180}
181
182/// One resolved path, and what decided it.
183#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
184pub struct PathEntry {
185    /// The resolved path.
186    pub path: Utf8PathBuf,
187    /// What decided it.
188    pub source: PathSource,
189}
190
191impl PathEntry {
192    /// A path nothing moved.
193    #[must_use]
194    pub fn default_at(path: impl Into<Utf8PathBuf>) -> Self {
195        Self {
196            path: path.into(),
197            source: PathSource::Default,
198        }
199    }
200
201    /// A path a variable carried.
202    #[must_use]
203    pub fn from_env(path: impl Into<Utf8PathBuf>) -> Self {
204        Self {
205            path: path.into(),
206            source: PathSource::Env,
207        }
208    }
209}
210
211/// One resolved agent skill root.
212#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
213pub struct AgentRootEntry {
214    /// The family this root serves.
215    pub id: AgentId,
216    /// The absolute root.
217    pub path: Utf8PathBuf,
218    /// What decided it.
219    pub source: PathSource,
220    /// The variable that relocated it, where one did.
221    pub variable: Option<&'static str>,
222}
223
224/// Where a location the project owns actually sits.
225///
226/// The four absent-looking cases are not one case. A repository-relative
227/// directory is checkable from a fresh clone. A directory outside the
228/// repository is not, and neither is one a variable resolves at run time,
229/// and a project that keeps none promises nothing at all.
230#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
231#[serde(tag = "kind", rename_all = "kebab-case")]
232pub enum ProjectLocation {
233    /// A repository-relative directory under version control.
234    Tracked {
235        /// Where it sits, relative to the instance root.
236        path: Utf8PathBuf,
237        /// What decided it.
238        source: PathSource,
239    },
240    /// A repository-relative directory version control does not carry.
241    Untracked {
242        /// Where it sits, relative to the instance root.
243        path: Utf8PathBuf,
244        /// What decided it.
245        source: PathSource,
246    },
247    /// A directory outside the repository.
248    External {
249        /// Where it sits, as the record carries it.
250        path: Utf8PathBuf,
251        /// What decided it.
252        source: PathSource,
253    },
254    /// Wherever a variable resolves at run time.
255    Env {
256        /// The variable that carries it.
257        variable: &'static str,
258        /// What that variable carries here, when it is set.
259        value: Option<String>,
260    },
261    /// The project keeps none.
262    None,
263}
264
265/// One answer the operator can give for a location the project owns.
266///
267/// A choice carries a path only where the target already holds that
268/// directory. Inventing one would put a path this tool made up in front of
269/// an operator as though the repository had suggested it.
270#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
271#[serde(tag = "kind", rename_all = "kebab-case")]
272pub enum LocationChoice {
273    /// A repository-relative directory the target already carries.
274    Observed {
275        /// Where it sits, relative to the target root.
276        path: Utf8PathBuf,
277    },
278    /// Wherever a variable resolves at run time.
279    Env {
280        /// The variable that would carry it.
281        variable: &'static str,
282        /// What that variable carries here, when it is set.
283        value: Option<String>,
284    },
285    /// A path the operator types.
286    Operator,
287    /// The project keeps none.
288    None,
289}
290
291/// The choices this target offers for the docs scratch.
292#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
293pub struct Proposals {
294    /// What the docs scratch can be here.
295    pub docs_scratch: Vec<LocationChoice>,
296}
297
298/// Every user-scope path, resolved.
299#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
300pub struct UserPaths {
301    /// Where this tool keeps state that outlives a command.
302    pub state_root: PathEntry,
303    /// Where this tool keeps what it can fetch again.
304    pub cache_root: PathEntry,
305    /// The receipt vouching for every user-scope file this tool wrote.
306    pub skill_receipt: PathEntry,
307    /// The agent skill roots, deduplicated by resolved path.
308    pub agent_roots: Vec<AgentRootEntry>,
309}
310
311/// Every control and documentation destination inside one target.
312#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
313pub struct InstancePaths {
314    /// The instance directory.
315    pub instance_dir: PathEntry,
316    /// The instance manifest.
317    pub manifest: PathEntry,
318    /// What the project declares about the files its gates judge.
319    pub declaration: PathEntry,
320    /// The inherited violations the budget gates read.
321    pub debt: PathEntry,
322    /// The flat list an instance older than the debt file carries.
323    pub legacy_debt: PathEntry,
324    /// The pre-commit configuration carrying the managed block.
325    pub hooks_config: PathEntry,
326    /// The root agent digest carrying the documentation block.
327    pub agents_digest: PathEntry,
328    /// The documentation root.
329    pub docs_root: PathEntry,
330    /// Where the specifications sit.
331    pub specs: PathEntry,
332    /// Where the decision records sit.
333    pub decisions: PathEntry,
334    /// Where the reference material sits.
335    pub reference: PathEntry,
336    /// Where the step-by-step guides sit.
337    pub guides: PathEntry,
338}
339
340/// One landed instance's paths, as the record and the environment leave them.
341#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
342pub struct ActivePaths {
343    /// The recorded profile.
344    pub profile: ProfileId,
345    /// Every destination the record implies.
346    pub destinations: InstancePaths,
347    /// Where material that is not a statement yet is staged.
348    pub docs_scratch: ProjectLocation,
349}
350
351/// One profile's destinations, derived and recorded nowhere.
352#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
353pub struct CandidatePaths {
354    /// The profile these destinations belong to.
355    pub profile: ProfileId,
356    /// Every destination that profile implies.
357    pub destinations: InstancePaths,
358}
359
360/// Every path this binary can name for one target and one user.
361#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
362pub struct Paths {
363    /// What resolves under the invoking user's home directory, or `null`
364    /// where the home variable is unset and nothing under it resolves.
365    pub user: Option<UserPaths>,
366    /// What the target records, or `null` where it carries no instance.
367    pub active: Option<ActivePaths>,
368    /// What each profile would imply, whether or not one is recorded.
369    pub candidates: BTreeMap<String, CandidatePaths>,
370    /// What this target offers for the docs scratch.
371    pub proposals: Proposals,
372}
373
374/// What the process environment carries, read once and passed down.
375#[derive(Debug, Clone, Default, PartialEq, Eq)]
376pub struct UserEnv {
377    /// The invoking user's home directory.
378    pub home: Option<Utf8PathBuf>,
379    /// Where Claude Code's configuration directory was moved to.
380    pub claude_config_dir: Option<Utf8PathBuf>,
381    /// The XDG state base directory.
382    pub xdg_state_home: Option<Utf8PathBuf>,
383    /// The XDG cache base directory.
384    pub xdg_cache_home: Option<Utf8PathBuf>,
385    /// What the docs-scratch variable carries.
386    pub docs_scratch: Option<String>,
387}
388
389/// What one variable carries, or `None` where it is unset or blank.
390///
391/// A variable set to the empty string is a variable the shell exported and
392/// nothing filled in. Treating it as a path would resolve every root to the
393/// filesystem root.
394#[must_use]
395pub fn variable(name: &str) -> Option<String> {
396    std::env::var(name)
397        .ok()
398        .map(|value| value.trim().to_string())
399        .filter(|value| !value.is_empty())
400}
401
402impl UserEnv {
403    /// Read every variable this module resolves from, once.
404    #[must_use]
405    pub fn from_process() -> Self {
406        let path = |name: &str| variable(name).map(Utf8PathBuf::from);
407        Self {
408            home: path(HOME_VAR),
409            claude_config_dir: path(CLAUDE_CONFIG_DIR_VAR),
410            xdg_state_home: path(XDG_STATE_HOME_VAR),
411            xdg_cache_home: path(XDG_CACHE_HOME_VAR),
412            docs_scratch: variable(DOCS_SCRATCH_VAR),
413        }
414    }
415
416    /// The state root, through the XDG variable or its default.
417    #[must_use]
418    pub fn state_root(&self) -> Option<PathEntry> {
419        if let Some(base) = self.xdg_state_home.as_ref() {
420            return Some(PathEntry::from_env(base.join(TOOL_DIR)));
421        }
422        self.home
423            .as_ref()
424            .map(|home| PathEntry::default_at(home.join(STATE_ROOT)))
425    }
426
427    /// The home-relative state root, whatever the variable says.
428    ///
429    /// One fallback read lives here: a receipt written before the state root
430    /// followed `XDG_STATE_HOME`, and the two gate files that release left
431    /// under the retired shared root.
432    #[must_use]
433    pub fn legacy_state_root(&self) -> Option<Utf8PathBuf> {
434        self.home.as_ref().map(|home| home.join(STATE_ROOT))
435    }
436
437    /// The cache root, through the XDG variable or its default.
438    #[must_use]
439    pub fn cache_root(&self) -> Option<PathEntry> {
440        if let Some(base) = self.xdg_cache_home.as_ref() {
441            return Some(PathEntry::from_env(base.join(TOOL_DIR)));
442        }
443        self.home
444            .as_ref()
445            .map(|home| PathEntry::default_at(home.join(CACHE_ROOT)))
446    }
447
448    /// One agent skill root, resolved with what decided it.
449    #[must_use]
450    pub fn agent_root(&self, id: AgentId) -> Option<AgentRootEntry> {
451        let row = agent_root(id);
452        let home = self.home.as_ref()?;
453        let relocated = match row.id {
454            AgentId::Claude => self.claude_config_dir.as_ref(),
455            AgentId::Agents => None,
456        };
457        Some(relocated.map_or_else(
458            || AgentRootEntry {
459                id: row.id,
460                path: home.join(row.default),
461                source: PathSource::Default,
462                variable: None,
463            },
464            |base| AgentRootEntry {
465                id: row.id,
466                path: base.join(row.relocated),
467                source: PathSource::Env,
468                variable: row.config_env,
469            },
470        ))
471    }
472
473    /// Every selected agent root, in table order, deduplicated by path.
474    ///
475    /// Two selected roots that resolve to one directory are one destination.
476    /// Planning it twice would list every file twice and make an install
477    /// compare a write against itself.
478    #[must_use]
479    pub fn agent_roots(&self, selected: &[AgentId]) -> Vec<AgentRootEntry> {
480        let mut resolved: Vec<AgentRootEntry> = Vec::new();
481        for row in AGENT_ROOTS {
482            if !selected.contains(&row.id) {
483                continue;
484            }
485            let Some(entry) = self.agent_root(row.id) else {
486                continue;
487            };
488            if resolved.iter().any(|held| held.path == entry.path) {
489                continue;
490            }
491            resolved.push(entry);
492        }
493        resolved
494    }
495
496    /// Every user-scope path, with every agent root selected.
497    #[must_use]
498    pub fn user_paths(&self) -> Option<UserPaths> {
499        let state = self.state_root()?;
500        let cache = self.cache_root()?;
501        Some(UserPaths {
502            skill_receipt: PathEntry {
503                path: state.path.join(SKILL_RECEIPT_FILE),
504                source: state.source,
505            },
506            agent_roots: self.agent_roots(&[AgentId::Claude, AgentId::Agents]),
507            state_root: state,
508            cache_root: cache,
509        })
510    }
511}
512
513/// Every destination one documentation root implies.
514#[must_use]
515pub fn instance_paths(docs_root: DocsRoot) -> InstancePaths {
516    let docs = Utf8PathBuf::from(docs_root.as_str());
517    let under = |leaf: &str| PathEntry {
518        path: docs.join(leaf),
519        source: PathSource::Profile,
520    };
521    InstancePaths {
522        instance_dir: PathEntry::default_at(INSTANCE_DIR),
523        manifest: PathEntry::default_at(MANIFEST_PATH),
524        declaration: PathEntry::default_at(CONFIG_PATH),
525        debt: PathEntry::default_at(DEBT_PATH),
526        legacy_debt: PathEntry::default_at(LEGACY_DEBT_PATH),
527        hooks_config: PathEntry::default_at(HOOKS_CONFIG_PATH),
528        agents_digest: PathEntry::default_at(AGENTS_DIGEST_PATH),
529        docs_root: PathEntry {
530            path: docs.clone(),
531            source: PathSource::Profile,
532        },
533        specs: under("specs"),
534        decisions: under("decisions"),
535        reference: under("reference"),
536        guides: under("guides"),
537    }
538}
539
540/// The same destinations, marked as the record's rather than a profile's.
541#[must_use]
542pub fn recorded_paths(docs_root: DocsRoot) -> InstancePaths {
543    let mut paths = instance_paths(docs_root);
544    for entry in [
545        &mut paths.docs_root,
546        &mut paths.specs,
547        &mut paths.decisions,
548        &mut paths.reference,
549        &mut paths.guides,
550    ] {
551        entry.source = PathSource::Recorded;
552    }
553    paths
554}
555
556/// One profile's derived destinations, per profile, keyed by its name.
557#[must_use]
558pub fn candidates() -> BTreeMap<String, CandidatePaths> {
559    ProfileId::every()
560        .map(|profile| {
561            (
562                profile.as_str().to_string(),
563                CandidatePaths {
564                    profile,
565                    destinations: instance_paths(profile.profile().docs_root),
566                },
567            )
568        })
569        .collect()
570}
571
572/// Where the record says the docs scratch sits.
573///
574/// A recorded scratch may leave the repository, because staging beside the
575/// checkout is one of the offered answers, so the leading component decides
576/// which kind it is. The variable overrides the record, and the report says
577/// so by naming the variable rather than the recorded path.
578#[must_use]
579pub fn docs_scratch_location(recorded: Option<&Utf8Path>, env: &UserEnv) -> ProjectLocation {
580    if env.docs_scratch.is_some() {
581        return ProjectLocation::Env {
582            variable: DOCS_SCRATCH_VAR,
583            value: env.docs_scratch.clone(),
584        };
585    }
586    let Some(path) = recorded else {
587        return ProjectLocation::None;
588    };
589    if path.is_absolute() || path.starts_with("..") {
590        return ProjectLocation::External {
591            path: path.to_owned(),
592            source: PathSource::Recorded,
593        };
594    }
595    ProjectLocation::Untracked {
596        path: path.to_owned(),
597        source: PathSource::Recorded,
598    }
599}
600
601/// The directories a target already carries that a docs scratch could be.
602///
603/// Read from the target rather than declared, because a repository that
604/// already stages material somewhere has answered the question and the
605/// operator only has to confirm it.
606pub const DOCS_SCRATCH_LEAVES: &[&str] = &[".docs-scratch", ".scratch"];
607
608/// What this target offers for the docs scratch.
609///
610/// `held` answers whether the target carries a repository-relative
611/// directory, so the pure derivation stays testable and the caller owns the
612/// one filesystem read.
613pub fn proposals(env: &UserEnv, held: impl Fn(&Utf8Path) -> bool) -> Proposals {
614    let mut docs_scratch: Vec<LocationChoice> = Vec::new();
615    for leaf in DOCS_SCRATCH_LEAVES {
616        let candidate = Utf8PathBuf::from(*leaf);
617        if held(&candidate) {
618            docs_scratch.push(LocationChoice::Observed { path: candidate });
619        }
620    }
621    docs_scratch.push(LocationChoice::Env {
622        variable: DOCS_SCRATCH_VAR,
623        value: env.docs_scratch.clone(),
624    });
625    docs_scratch.push(LocationChoice::Operator);
626    docs_scratch.push(LocationChoice::None);
627
628    Proposals { docs_scratch }
629}
630
631#[cfg(test)]
632mod tests {
633    use super::*;
634
635    fn env(home: &str) -> UserEnv {
636        UserEnv {
637            home: Some(Utf8PathBuf::from(home)),
638            ..UserEnv::default()
639        }
640    }
641
642    #[test]
643    fn the_agent_root_table_has_two_rows_and_resolves_each_with_its_source() {
644        assert_eq!(AGENT_ROOTS.len(), 2);
645        let resolved = env("/h").agent_roots(&[AgentId::Claude, AgentId::Agents]);
646        assert_eq!(resolved.len(), 2);
647        assert_eq!(resolved[0].path, "/h/.claude/skills");
648        assert_eq!(resolved[0].source, PathSource::Default);
649        assert_eq!(resolved[0].variable, None);
650        assert_eq!(resolved[1].path, "/h/.agents/skills");
651        assert_eq!(resolved[1].id, AgentId::Agents);
652    }
653
654    #[test]
655    fn claude_config_dir_relocates_the_claude_root_and_nothing_else() {
656        let moved = UserEnv {
657            claude_config_dir: Some(Utf8PathBuf::from("/elsewhere/claude")),
658            ..env("/h")
659        };
660        let resolved = moved.agent_roots(&[AgentId::Claude, AgentId::Agents]);
661        assert_eq!(resolved[0].path, "/elsewhere/claude/skills");
662        assert_eq!(resolved[0].source, PathSource::Env);
663        assert_eq!(resolved[0].variable, Some(CLAUDE_CONFIG_DIR_VAR));
664        assert_eq!(resolved[1].path, "/h/.agents/skills");
665        assert_eq!(resolved[1].source, PathSource::Default);
666    }
667
668    #[test]
669    fn an_empty_claude_config_dir_is_treated_as_unset() {
670        // `variable` is what strips it, so the resolver never sees a blank.
671        let blank = UserEnv {
672            claude_config_dir: None,
673            ..env("/h")
674        };
675        assert_eq!(
676            blank.agent_root(AgentId::Claude).map(|root| root.path),
677            Some(Utf8PathBuf::from("/h/.claude/skills"))
678        );
679    }
680
681    #[test]
682    fn two_selected_roots_that_resolve_to_one_path_are_returned_once() {
683        let collided = UserEnv {
684            claude_config_dir: Some(Utf8PathBuf::from("/h/.agents")),
685            ..env("/h")
686        };
687        let resolved = collided.agent_roots(&[AgentId::Claude, AgentId::Agents]);
688        assert_eq!(resolved.len(), 1);
689        assert_eq!(resolved[0].path, "/h/.agents/skills");
690        assert_eq!(resolved[0].id, AgentId::Claude);
691    }
692
693    #[test]
694    fn the_cache_root_follows_xdg_cache_home_and_its_default() {
695        assert_eq!(
696            env("/h").cache_root(),
697            Some(PathEntry::default_at("/h/.cache/spec-driven-docs"))
698        );
699        let moved = UserEnv {
700            xdg_cache_home: Some(Utf8PathBuf::from("/c")),
701            ..env("/h")
702        };
703        assert_eq!(
704            moved.cache_root(),
705            Some(PathEntry::from_env("/c/spec-driven-docs"))
706        );
707    }
708
709    #[test]
710    fn the_state_root_follows_xdg_state_home_and_its_default() {
711        assert_eq!(
712            env("/h").state_root(),
713            Some(PathEntry::default_at("/h/.local/state/spec-driven-docs"))
714        );
715        let moved = UserEnv {
716            xdg_state_home: Some(Utf8PathBuf::from("/s")),
717            ..env("/h")
718        };
719        assert_eq!(
720            moved.state_root(),
721            Some(PathEntry::from_env("/s/spec-driven-docs"))
722        );
723        assert_eq!(
724            moved.legacy_state_root(),
725            Some(Utf8PathBuf::from("/h/.local/state/spec-driven-docs"))
726        );
727    }
728
729    #[test]
730    fn the_user_paths_hang_off_the_two_roots() {
731        let paths = env("/h").user_paths().unwrap();
732        assert_eq!(
733            paths.skill_receipt.path,
734            "/h/.local/state/spec-driven-docs/skills.json"
735        );
736    }
737
738    #[test]
739    fn the_home_relative_constants_agree_with_the_resolved_roots() {
740        let paths = env("/h").user_paths().unwrap();
741        assert_eq!(
742            paths.skill_receipt.path,
743            Utf8Path::new("/h").join(LEGACY_SKILL_RECEIPT_PATH)
744        );
745    }
746
747    #[test]
748    fn a_candidate_set_exists_for_every_profile() {
749        let candidates = candidates();
750        assert_eq!(candidates.len(), 2);
751        assert_eq!(candidates["codebase"].destinations.specs.path, "docs/specs");
752        assert_eq!(
753            candidates["knowledge-base"].destinations.specs.path,
754            "_docs/specs"
755        );
756        assert_eq!(
757            candidates["codebase"].destinations.declaration.source,
758            PathSource::Default
759        );
760        assert_eq!(
761            candidates["codebase"].destinations.specs.source,
762            PathSource::Profile
763        );
764    }
765
766    #[test]
767    fn a_recorded_location_reports_as_recorded_and_an_override_as_env() {
768        let overridden = UserEnv {
769            docs_scratch: Some("elsewhere".to_string()),
770            ..env("/h")
771        };
772        assert_eq!(
773            docs_scratch_location(Some(Utf8Path::new(".docs-scratch")), &overridden),
774            ProjectLocation::Env {
775                variable: DOCS_SCRATCH_VAR,
776                value: Some("elsewhere".to_string()),
777            }
778        );
779    }
780
781    #[test]
782    fn a_scratch_beside_the_checkout_reports_as_external() {
783        let plain = env("/h");
784        assert_eq!(
785            docs_scratch_location(Some(Utf8Path::new("../x.docs-scratch")), &plain),
786            ProjectLocation::External {
787                path: Utf8PathBuf::from("../x.docs-scratch"),
788                source: PathSource::Recorded,
789            }
790        );
791        assert_eq!(
792            docs_scratch_location(Some(Utf8Path::new(".docs-scratch")), &plain),
793            ProjectLocation::Untracked {
794                path: Utf8PathBuf::from(".docs-scratch"),
795                source: PathSource::Recorded,
796            }
797        );
798        assert_eq!(docs_scratch_location(None, &plain), ProjectLocation::None);
799    }
800
801    #[test]
802    fn a_proposal_carries_a_path_only_where_the_target_holds_one() {
803        let plain = env("/h");
804        let bare = proposals(&plain, |_| false);
805        assert!(
806            !bare
807                .docs_scratch
808                .iter()
809                .any(|choice| matches!(choice, LocationChoice::Observed { .. }))
810        );
811        assert_eq!(bare.docs_scratch.last(), Some(&LocationChoice::None));
812
813        let observed = proposals(&plain, |path| path == Utf8Path::new(".docs-scratch"));
814        assert_eq!(
815            observed.docs_scratch.first(),
816            Some(&LocationChoice::Observed {
817                path: Utf8PathBuf::from(".docs-scratch")
818            })
819        );
820    }
821}