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 SKILL_JOURNAL_FILE: &str = "skills.journal";
76pub const BACKUPS_DIR: &str = "backups";
78pub const PLAN_STORE_DIR: &str = "plans";
80pub const SELF_DEPEND_STAMP_DIR: &str = "self-depend";
82pub const BUNDLE_CACHE_DIR: &str = "bundles";
84
85pub const LEGACY_SKILL_RECEIPT_PATH: &str = ".local/state/spec-driven-docs/skills.json";
91pub const LEGACY_SHARED_ROOT: &str = ".local/state/spec-driven-docs/skills/shared";
97pub const CLAUDE_ROOT: &str = ".claude/skills";
99pub const AGENTS_ROOT: &str = ".agents/skills";
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
104#[serde(rename_all = "kebab-case")]
105pub enum AgentId {
106 Claude,
108 Agents,
110}
111
112impl AgentId {
113 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub struct AgentRoot {
132 pub id: AgentId,
134 pub default: &'static str,
136 pub config_env: Option<&'static str>,
138 pub relocated: &'static str,
140}
141
142const CLAUDE: AgentRoot = AgentRoot {
144 id: AgentId::Claude,
145 default: CLAUDE_ROOT,
146 config_env: Some(CLAUDE_CONFIG_DIR_VAR),
147 relocated: "skills",
148};
149
150const AGENTS: AgentRoot = AgentRoot {
152 id: AgentId::Agents,
153 default: AGENTS_ROOT,
154 config_env: None,
155 relocated: "skills",
156};
157
158pub const AGENT_ROOTS: &[AgentRoot] = &[CLAUDE, AGENTS];
165
166#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
177#[serde(rename_all = "kebab-case")]
178pub enum PathSource {
179 Recorded,
181 Default,
183 Env,
185 Profile,
187 Proposal,
189}
190
191#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
193pub struct PathEntry {
194 pub path: Utf8PathBuf,
196 pub source: PathSource,
198}
199
200impl PathEntry {
201 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
222pub struct AgentRootEntry {
223 pub id: AgentId,
225 pub path: Utf8PathBuf,
227 pub source: PathSource,
229 pub variable: Option<&'static str>,
231}
232
233#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
240#[serde(tag = "kind", rename_all = "kebab-case")]
241pub enum ProjectLocation {
242 Tracked {
244 path: Utf8PathBuf,
246 source: PathSource,
248 },
249 Untracked {
251 path: Utf8PathBuf,
253 source: PathSource,
255 },
256 External {
258 path: Utf8PathBuf,
260 source: PathSource,
262 },
263 Env {
265 variable: &'static str,
267 value: Option<String>,
269 },
270 None,
272}
273
274#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
280#[serde(tag = "kind", rename_all = "kebab-case")]
281pub enum LocationChoice {
282 Observed {
284 path: Utf8PathBuf,
286 },
287 Env {
289 variable: &'static str,
291 value: Option<String>,
293 },
294 Operator,
296 None,
298}
299
300#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
302pub struct Proposals {
303 pub docs_scratch: Vec<LocationChoice>,
305}
306
307#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
309pub struct UserPaths {
310 pub state_root: PathEntry,
312 pub cache_root: PathEntry,
314 pub skill_receipt: PathEntry,
316 pub plan_store: PathEntry,
318 pub bundle_cache: PathEntry,
320 pub agent_roots: Vec<AgentRootEntry>,
322}
323
324#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
326pub struct InstancePaths {
327 pub instance_dir: PathEntry,
329 pub manifest: PathEntry,
331 pub declaration: PathEntry,
333 pub debt: PathEntry,
335 pub legacy_debt: PathEntry,
337 pub hooks_config: PathEntry,
339 pub agents_digest: PathEntry,
341 pub docs_root: PathEntry,
343 pub specs: PathEntry,
345 pub decisions: PathEntry,
347 pub reference: PathEntry,
349 pub guides: PathEntry,
351}
352
353#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
355pub struct ActivePaths {
356 pub profile: ProfileId,
358 pub destinations: InstancePaths,
360 pub docs_scratch: ProjectLocation,
362}
363
364#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
366pub struct CandidatePaths {
367 pub profile: ProfileId,
369 pub destinations: InstancePaths,
371}
372
373#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
375pub struct Paths {
376 pub user: Option<UserPaths>,
379 pub active: Option<ActivePaths>,
381 pub candidates: BTreeMap<String, CandidatePaths>,
383 pub proposals: Proposals,
385}
386
387#[derive(Debug, Clone, Default, PartialEq, Eq)]
389pub struct UserEnv {
390 pub home: Option<Utf8PathBuf>,
392 pub claude_config_dir: Option<Utf8PathBuf>,
394 pub xdg_state_home: Option<Utf8PathBuf>,
396 pub xdg_cache_home: Option<Utf8PathBuf>,
398 pub docs_scratch: Option<String>,
400}
401
402#[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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#[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#[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#[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#[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
622pub const DOCS_SCRATCH_LEAVES: &[&str] = &[".docs-scratch", ".scratch"];
628
629pub 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 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}