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