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