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