1use std::collections::BTreeMap;
16
17use camino::{Utf8Path, Utf8PathBuf};
18use serde::Serialize;
19
20use crate::domain::profile::{DocsRoot, ProfileId};
21
22pub const HOME_VAR: &str = "HOME";
24pub const CLAUDE_CONFIG_DIR_VAR: &str = "CLAUDE_CONFIG_DIR";
26pub const XDG_STATE_HOME_VAR: &str = "XDG_STATE_HOME";
28pub const XDG_CACHE_HOME_VAR: &str = "XDG_CACHE_HOME";
30pub const OFFLINE_VAR: &str = "SDD_OFFLINE";
32pub const DOCS_SCRATCH_VAR: &str = "SDD_DOCS_SCRATCH";
34pub const SELF_DEPEND_OFF_VAR: &str = "SDD_SELF_DEPEND_OFF";
36pub const CI_VAR: &str = "CI";
39
40pub const TOOL_DIR: &str = "spec-driven-docs";
42
43pub const INSTANCE_DIR: &str = ".spec-driven-docs";
45pub const MANIFEST_PATH: &str = ".spec-driven-docs/manifest.json";
47pub const CONFIG_PATH: &str = ".spec-driven-docs/config.yaml";
49pub const DEBT_PATH: &str = ".spec-driven-docs/debt.yaml";
51pub const LEGACY_DEBT_PATH: &str = ".spec-driven-docs/chapter-size-debt.txt";
53pub const HOOKS_CONFIG_PATH: &str = ".pre-commit-config.yaml";
55pub const AGENTS_DIGEST_PATH: &str = "AGENTS.md";
57
58pub const PRUNABLE_ROOTS: &[&str] = &[".spec-driven-docs/", ".claude/skills/", ".agents/skills/"];
60
61pub const STATE_ROOT: &str = ".local/state/spec-driven-docs";
63pub const CACHE_ROOT: &str = ".cache/spec-driven-docs";
65pub const SKILL_RECEIPT_FILE: &str = "skills.json";
67pub const SKILL_FILE: &str = "SKILL.md";
69pub const SKILL_REFERENCES_DIR: &str = "references";
71
72pub const SKILL_LOCK_FILE: &str = "skills.lock";
74pub const SELF_DEPEND_STAMP_DIR: &str = "self-depend";
76pub const LEGACY_SKILL_RECEIPT_PATH: &str = ".local/state/spec-driven-docs/skills.json";
82pub const LEGACY_SHARED_ROOT: &str = ".local/state/spec-driven-docs/skills/shared";
88pub const CLAUDE_ROOT: &str = ".claude/skills";
90pub const AGENTS_ROOT: &str = ".agents/skills";
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
95#[serde(rename_all = "kebab-case")]
96pub enum AgentId {
97 Claude,
99 Agents,
101}
102
103impl AgentId {
104 #[must_use]
106 pub const fn as_str(self) -> &'static str {
107 match self {
108 Self::Claude => "claude",
109 Self::Agents => "agents",
110 }
111 }
112}
113
114impl std::fmt::Display for AgentId {
115 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116 f.write_str(self.as_str())
117 }
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub struct AgentRoot {
123 pub id: AgentId,
125 pub default: &'static str,
127 pub config_env: Option<&'static str>,
129 pub relocated: &'static str,
131}
132
133const CLAUDE: AgentRoot = AgentRoot {
135 id: AgentId::Claude,
136 default: CLAUDE_ROOT,
137 config_env: Some(CLAUDE_CONFIG_DIR_VAR),
138 relocated: "skills",
139};
140
141const AGENTS: AgentRoot = AgentRoot {
143 id: AgentId::Agents,
144 default: AGENTS_ROOT,
145 config_env: None,
146 relocated: "skills",
147};
148
149pub const AGENT_ROOTS: &[AgentRoot] = &[CLAUDE, AGENTS];
156
157#[must_use]
159pub const fn agent_root(id: AgentId) -> &'static AgentRoot {
160 match id {
161 AgentId::Claude => &CLAUDE,
162 AgentId::Agents => &AGENTS,
163 }
164}
165
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
168#[serde(rename_all = "kebab-case")]
169pub enum PathSource {
170 Recorded,
172 Default,
174 Env,
176 Profile,
178 Proposal,
180}
181
182#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
184pub struct PathEntry {
185 pub path: Utf8PathBuf,
187 pub source: PathSource,
189}
190
191impl PathEntry {
192 #[must_use]
194 pub fn default_at(path: impl Into<Utf8PathBuf>) -> Self {
195 Self {
196 path: path.into(),
197 source: PathSource::Default,
198 }
199 }
200
201 #[must_use]
203 pub fn from_env(path: impl Into<Utf8PathBuf>) -> Self {
204 Self {
205 path: path.into(),
206 source: PathSource::Env,
207 }
208 }
209}
210
211#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
213pub struct AgentRootEntry {
214 pub id: AgentId,
216 pub path: Utf8PathBuf,
218 pub source: PathSource,
220 pub variable: Option<&'static str>,
222}
223
224#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
231#[serde(tag = "kind", rename_all = "kebab-case")]
232pub enum ProjectLocation {
233 Tracked {
235 path: Utf8PathBuf,
237 source: PathSource,
239 },
240 Untracked {
242 path: Utf8PathBuf,
244 source: PathSource,
246 },
247 External {
249 path: Utf8PathBuf,
251 source: PathSource,
253 },
254 Env {
256 variable: &'static str,
258 value: Option<String>,
260 },
261 None,
263}
264
265#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
271#[serde(tag = "kind", rename_all = "kebab-case")]
272pub enum LocationChoice {
273 Observed {
275 path: Utf8PathBuf,
277 },
278 Env {
280 variable: &'static str,
282 value: Option<String>,
284 },
285 Operator,
287 None,
289}
290
291#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
293pub struct Proposals {
294 pub docs_scratch: Vec<LocationChoice>,
296}
297
298#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
300pub struct UserPaths {
301 pub state_root: PathEntry,
303 pub cache_root: PathEntry,
305 pub skill_receipt: PathEntry,
307 pub agent_roots: Vec<AgentRootEntry>,
309}
310
311#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
313pub struct InstancePaths {
314 pub instance_dir: PathEntry,
316 pub manifest: PathEntry,
318 pub declaration: PathEntry,
320 pub debt: PathEntry,
322 pub legacy_debt: PathEntry,
324 pub hooks_config: PathEntry,
326 pub agents_digest: PathEntry,
328 pub docs_root: PathEntry,
330 pub specs: PathEntry,
332 pub decisions: PathEntry,
334 pub reference: PathEntry,
336 pub guides: PathEntry,
338}
339
340#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
342pub struct ActivePaths {
343 pub profile: ProfileId,
345 pub destinations: InstancePaths,
347 pub docs_scratch: ProjectLocation,
349}
350
351#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
353pub struct CandidatePaths {
354 pub profile: ProfileId,
356 pub destinations: InstancePaths,
358}
359
360#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
362pub struct Paths {
363 pub user: Option<UserPaths>,
366 pub active: Option<ActivePaths>,
368 pub candidates: BTreeMap<String, CandidatePaths>,
370 pub proposals: Proposals,
372}
373
374#[derive(Debug, Clone, Default, PartialEq, Eq)]
376pub struct UserEnv {
377 pub home: Option<Utf8PathBuf>,
379 pub claude_config_dir: Option<Utf8PathBuf>,
381 pub xdg_state_home: Option<Utf8PathBuf>,
383 pub xdg_cache_home: Option<Utf8PathBuf>,
385 pub docs_scratch: Option<String>,
387}
388
389#[must_use]
395pub fn variable(name: &str) -> Option<String> {
396 std::env::var(name)
397 .ok()
398 .map(|value| value.trim().to_string())
399 .filter(|value| !value.is_empty())
400}
401
402impl UserEnv {
403 #[must_use]
405 pub fn from_process() -> Self {
406 let path = |name: &str| variable(name).map(Utf8PathBuf::from);
407 Self {
408 home: path(HOME_VAR),
409 claude_config_dir: path(CLAUDE_CONFIG_DIR_VAR),
410 xdg_state_home: path(XDG_STATE_HOME_VAR),
411 xdg_cache_home: path(XDG_CACHE_HOME_VAR),
412 docs_scratch: variable(DOCS_SCRATCH_VAR),
413 }
414 }
415
416 #[must_use]
418 pub fn state_root(&self) -> Option<PathEntry> {
419 if let Some(base) = self.xdg_state_home.as_ref() {
420 return Some(PathEntry::from_env(base.join(TOOL_DIR)));
421 }
422 self.home
423 .as_ref()
424 .map(|home| PathEntry::default_at(home.join(STATE_ROOT)))
425 }
426
427 #[must_use]
433 pub fn legacy_state_root(&self) -> Option<Utf8PathBuf> {
434 self.home.as_ref().map(|home| home.join(STATE_ROOT))
435 }
436
437 #[must_use]
439 pub fn cache_root(&self) -> Option<PathEntry> {
440 if let Some(base) = self.xdg_cache_home.as_ref() {
441 return Some(PathEntry::from_env(base.join(TOOL_DIR)));
442 }
443 self.home
444 .as_ref()
445 .map(|home| PathEntry::default_at(home.join(CACHE_ROOT)))
446 }
447
448 #[must_use]
450 pub fn agent_root(&self, id: AgentId) -> Option<AgentRootEntry> {
451 let row = agent_root(id);
452 let home = self.home.as_ref()?;
453 let relocated = match row.id {
454 AgentId::Claude => self.claude_config_dir.as_ref(),
455 AgentId::Agents => None,
456 };
457 Some(relocated.map_or_else(
458 || AgentRootEntry {
459 id: row.id,
460 path: home.join(row.default),
461 source: PathSource::Default,
462 variable: None,
463 },
464 |base| AgentRootEntry {
465 id: row.id,
466 path: base.join(row.relocated),
467 source: PathSource::Env,
468 variable: row.config_env,
469 },
470 ))
471 }
472
473 #[must_use]
479 pub fn agent_roots(&self, selected: &[AgentId]) -> Vec<AgentRootEntry> {
480 let mut resolved: Vec<AgentRootEntry> = Vec::new();
481 for row in AGENT_ROOTS {
482 if !selected.contains(&row.id) {
483 continue;
484 }
485 let Some(entry) = self.agent_root(row.id) else {
486 continue;
487 };
488 if resolved.iter().any(|held| held.path == entry.path) {
489 continue;
490 }
491 resolved.push(entry);
492 }
493 resolved
494 }
495
496 #[must_use]
498 pub fn user_paths(&self) -> Option<UserPaths> {
499 let state = self.state_root()?;
500 let cache = self.cache_root()?;
501 Some(UserPaths {
502 skill_receipt: PathEntry {
503 path: state.path.join(SKILL_RECEIPT_FILE),
504 source: state.source,
505 },
506 agent_roots: self.agent_roots(&[AgentId::Claude, AgentId::Agents]),
507 state_root: state,
508 cache_root: cache,
509 })
510 }
511}
512
513#[must_use]
515pub fn instance_paths(docs_root: DocsRoot) -> InstancePaths {
516 let docs = Utf8PathBuf::from(docs_root.as_str());
517 let under = |leaf: &str| PathEntry {
518 path: docs.join(leaf),
519 source: PathSource::Profile,
520 };
521 InstancePaths {
522 instance_dir: PathEntry::default_at(INSTANCE_DIR),
523 manifest: PathEntry::default_at(MANIFEST_PATH),
524 declaration: PathEntry::default_at(CONFIG_PATH),
525 debt: PathEntry::default_at(DEBT_PATH),
526 legacy_debt: PathEntry::default_at(LEGACY_DEBT_PATH),
527 hooks_config: PathEntry::default_at(HOOKS_CONFIG_PATH),
528 agents_digest: PathEntry::default_at(AGENTS_DIGEST_PATH),
529 docs_root: PathEntry {
530 path: docs.clone(),
531 source: PathSource::Profile,
532 },
533 specs: under("specs"),
534 decisions: under("decisions"),
535 reference: under("reference"),
536 guides: under("guides"),
537 }
538}
539
540#[must_use]
542pub fn recorded_paths(docs_root: DocsRoot) -> InstancePaths {
543 let mut paths = instance_paths(docs_root);
544 for entry in [
545 &mut paths.docs_root,
546 &mut paths.specs,
547 &mut paths.decisions,
548 &mut paths.reference,
549 &mut paths.guides,
550 ] {
551 entry.source = PathSource::Recorded;
552 }
553 paths
554}
555
556#[must_use]
558pub fn candidates() -> BTreeMap<String, CandidatePaths> {
559 ProfileId::every()
560 .map(|profile| {
561 (
562 profile.as_str().to_string(),
563 CandidatePaths {
564 profile,
565 destinations: instance_paths(profile.profile().docs_root),
566 },
567 )
568 })
569 .collect()
570}
571
572#[must_use]
579pub fn docs_scratch_location(recorded: Option<&Utf8Path>, env: &UserEnv) -> ProjectLocation {
580 if env.docs_scratch.is_some() {
581 return ProjectLocation::Env {
582 variable: DOCS_SCRATCH_VAR,
583 value: env.docs_scratch.clone(),
584 };
585 }
586 let Some(path) = recorded else {
587 return ProjectLocation::None;
588 };
589 if path.is_absolute() || path.starts_with("..") {
590 return ProjectLocation::External {
591 path: path.to_owned(),
592 source: PathSource::Recorded,
593 };
594 }
595 ProjectLocation::Untracked {
596 path: path.to_owned(),
597 source: PathSource::Recorded,
598 }
599}
600
601pub const DOCS_SCRATCH_LEAVES: &[&str] = &[".docs-scratch", ".scratch"];
607
608pub fn proposals(env: &UserEnv, held: impl Fn(&Utf8Path) -> bool) -> Proposals {
614 let mut docs_scratch: Vec<LocationChoice> = Vec::new();
615 for leaf in DOCS_SCRATCH_LEAVES {
616 let candidate = Utf8PathBuf::from(*leaf);
617 if held(&candidate) {
618 docs_scratch.push(LocationChoice::Observed { path: candidate });
619 }
620 }
621 docs_scratch.push(LocationChoice::Env {
622 variable: DOCS_SCRATCH_VAR,
623 value: env.docs_scratch.clone(),
624 });
625 docs_scratch.push(LocationChoice::Operator);
626 docs_scratch.push(LocationChoice::None);
627
628 Proposals { docs_scratch }
629}
630
631#[cfg(test)]
632mod tests {
633 use super::*;
634
635 fn env(home: &str) -> UserEnv {
636 UserEnv {
637 home: Some(Utf8PathBuf::from(home)),
638 ..UserEnv::default()
639 }
640 }
641
642 #[test]
643 fn the_agent_root_table_has_two_rows_and_resolves_each_with_its_source() {
644 assert_eq!(AGENT_ROOTS.len(), 2);
645 let resolved = env("/h").agent_roots(&[AgentId::Claude, AgentId::Agents]);
646 assert_eq!(resolved.len(), 2);
647 assert_eq!(resolved[0].path, "/h/.claude/skills");
648 assert_eq!(resolved[0].source, PathSource::Default);
649 assert_eq!(resolved[0].variable, None);
650 assert_eq!(resolved[1].path, "/h/.agents/skills");
651 assert_eq!(resolved[1].id, AgentId::Agents);
652 }
653
654 #[test]
655 fn claude_config_dir_relocates_the_claude_root_and_nothing_else() {
656 let moved = UserEnv {
657 claude_config_dir: Some(Utf8PathBuf::from("/elsewhere/claude")),
658 ..env("/h")
659 };
660 let resolved = moved.agent_roots(&[AgentId::Claude, AgentId::Agents]);
661 assert_eq!(resolved[0].path, "/elsewhere/claude/skills");
662 assert_eq!(resolved[0].source, PathSource::Env);
663 assert_eq!(resolved[0].variable, Some(CLAUDE_CONFIG_DIR_VAR));
664 assert_eq!(resolved[1].path, "/h/.agents/skills");
665 assert_eq!(resolved[1].source, PathSource::Default);
666 }
667
668 #[test]
669 fn an_empty_claude_config_dir_is_treated_as_unset() {
670 let blank = UserEnv {
672 claude_config_dir: None,
673 ..env("/h")
674 };
675 assert_eq!(
676 blank.agent_root(AgentId::Claude).map(|root| root.path),
677 Some(Utf8PathBuf::from("/h/.claude/skills"))
678 );
679 }
680
681 #[test]
682 fn two_selected_roots_that_resolve_to_one_path_are_returned_once() {
683 let collided = UserEnv {
684 claude_config_dir: Some(Utf8PathBuf::from("/h/.agents")),
685 ..env("/h")
686 };
687 let resolved = collided.agent_roots(&[AgentId::Claude, AgentId::Agents]);
688 assert_eq!(resolved.len(), 1);
689 assert_eq!(resolved[0].path, "/h/.agents/skills");
690 assert_eq!(resolved[0].id, AgentId::Claude);
691 }
692
693 #[test]
694 fn the_cache_root_follows_xdg_cache_home_and_its_default() {
695 assert_eq!(
696 env("/h").cache_root(),
697 Some(PathEntry::default_at("/h/.cache/spec-driven-docs"))
698 );
699 let moved = UserEnv {
700 xdg_cache_home: Some(Utf8PathBuf::from("/c")),
701 ..env("/h")
702 };
703 assert_eq!(
704 moved.cache_root(),
705 Some(PathEntry::from_env("/c/spec-driven-docs"))
706 );
707 }
708
709 #[test]
710 fn the_state_root_follows_xdg_state_home_and_its_default() {
711 assert_eq!(
712 env("/h").state_root(),
713 Some(PathEntry::default_at("/h/.local/state/spec-driven-docs"))
714 );
715 let moved = UserEnv {
716 xdg_state_home: Some(Utf8PathBuf::from("/s")),
717 ..env("/h")
718 };
719 assert_eq!(
720 moved.state_root(),
721 Some(PathEntry::from_env("/s/spec-driven-docs"))
722 );
723 assert_eq!(
724 moved.legacy_state_root(),
725 Some(Utf8PathBuf::from("/h/.local/state/spec-driven-docs"))
726 );
727 }
728
729 #[test]
730 fn the_user_paths_hang_off_the_two_roots() {
731 let paths = env("/h").user_paths().unwrap();
732 assert_eq!(
733 paths.skill_receipt.path,
734 "/h/.local/state/spec-driven-docs/skills.json"
735 );
736 }
737
738 #[test]
739 fn the_home_relative_constants_agree_with_the_resolved_roots() {
740 let paths = env("/h").user_paths().unwrap();
741 assert_eq!(
742 paths.skill_receipt.path,
743 Utf8Path::new("/h").join(LEGACY_SKILL_RECEIPT_PATH)
744 );
745 }
746
747 #[test]
748 fn a_candidate_set_exists_for_every_profile() {
749 let candidates = candidates();
750 assert_eq!(candidates.len(), 2);
751 assert_eq!(candidates["codebase"].destinations.specs.path, "docs/specs");
752 assert_eq!(
753 candidates["knowledge-base"].destinations.specs.path,
754 "_docs/specs"
755 );
756 assert_eq!(
757 candidates["codebase"].destinations.declaration.source,
758 PathSource::Default
759 );
760 assert_eq!(
761 candidates["codebase"].destinations.specs.source,
762 PathSource::Profile
763 );
764 }
765
766 #[test]
767 fn a_recorded_location_reports_as_recorded_and_an_override_as_env() {
768 let overridden = UserEnv {
769 docs_scratch: Some("elsewhere".to_string()),
770 ..env("/h")
771 };
772 assert_eq!(
773 docs_scratch_location(Some(Utf8Path::new(".docs-scratch")), &overridden),
774 ProjectLocation::Env {
775 variable: DOCS_SCRATCH_VAR,
776 value: Some("elsewhere".to_string()),
777 }
778 );
779 }
780
781 #[test]
782 fn a_scratch_beside_the_checkout_reports_as_external() {
783 let plain = env("/h");
784 assert_eq!(
785 docs_scratch_location(Some(Utf8Path::new("../x.docs-scratch")), &plain),
786 ProjectLocation::External {
787 path: Utf8PathBuf::from("../x.docs-scratch"),
788 source: PathSource::Recorded,
789 }
790 );
791 assert_eq!(
792 docs_scratch_location(Some(Utf8Path::new(".docs-scratch")), &plain),
793 ProjectLocation::Untracked {
794 path: Utf8PathBuf::from(".docs-scratch"),
795 source: PathSource::Recorded,
796 }
797 );
798 assert_eq!(docs_scratch_location(None, &plain), ProjectLocation::None);
799 }
800
801 #[test]
802 fn a_proposal_carries_a_path_only_where_the_target_holds_one() {
803 let plain = env("/h");
804 let bare = proposals(&plain, |_| false);
805 assert!(
806 !bare
807 .docs_scratch
808 .iter()
809 .any(|choice| matches!(choice, LocationChoice::Observed { .. }))
810 );
811 assert_eq!(bare.docs_scratch.last(), Some(&LocationChoice::None));
812
813 let observed = proposals(&plain, |path| path == Utf8Path::new(".docs-scratch"));
814 assert_eq!(
815 observed.docs_scratch.first(),
816 Some(&LocationChoice::Observed {
817 path: Utf8PathBuf::from(".docs-scratch")
818 })
819 );
820 }
821}