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";
34
35pub const TOOL_DIR: &str = "spec-driven-docs";
37
38pub const INSTANCE_DIR: &str = ".spec-driven-docs";
40pub const MANIFEST_PATH: &str = ".spec-driven-docs/manifest.json";
42pub const CONFIG_PATH: &str = ".spec-driven-docs/config.yaml";
44pub const DEBT_PATH: &str = ".spec-driven-docs/debt.yaml";
46pub const LEGACY_DEBT_PATH: &str = ".spec-driven-docs/chapter-size-debt.txt";
48pub const HOOKS_CONFIG_PATH: &str = ".pre-commit-config.yaml";
50pub const AGENTS_DIGEST_PATH: &str = "AGENTS.md";
52
53pub const PRUNABLE_ROOTS: &[&str] = &[".spec-driven-docs/", ".claude/skills/", ".agents/skills/"];
55
56pub const STATE_ROOT: &str = ".local/state/spec-driven-docs";
58pub const CACHE_ROOT: &str = ".cache/spec-driven-docs";
60pub const SKILL_RECEIPT_FILE: &str = "skills.json";
62pub const SKILL_FILE: &str = "SKILL.md";
64pub const SKILL_REFERENCES_DIR: &str = "references";
66
67pub const SKILL_LOCK_FILE: &str = "skills.lock";
69pub const SKILL_JOURNAL_FILE: &str = "skills.journal";
71pub const BACKUPS_DIR: &str = "backups";
73pub const PLAN_STORE_DIR: &str = "plans";
75pub const BUNDLE_CACHE_DIR: &str = "bundles";
77
78pub const LEGACY_SKILL_RECEIPT_PATH: &str = ".local/state/spec-driven-docs/skills.json";
84pub const LEGACY_SHARED_ROOT: &str = ".local/state/spec-driven-docs/skills/shared";
90pub const CLAUDE_ROOT: &str = ".claude/skills";
92pub const AGENTS_ROOT: &str = ".agents/skills";
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
97#[serde(rename_all = "kebab-case")]
98pub enum AgentId {
99 Claude,
101 Agents,
103}
104
105impl AgentId {
106 #[must_use]
108 pub const fn as_str(self) -> &'static str {
109 match self {
110 Self::Claude => "claude",
111 Self::Agents => "agents",
112 }
113 }
114}
115
116impl std::fmt::Display for AgentId {
117 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118 f.write_str(self.as_str())
119 }
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub struct AgentRoot {
125 pub id: AgentId,
127 pub default: &'static str,
129 pub config_env: Option<&'static str>,
131 pub relocated: &'static str,
133}
134
135const CLAUDE: AgentRoot = AgentRoot {
137 id: AgentId::Claude,
138 default: CLAUDE_ROOT,
139 config_env: Some(CLAUDE_CONFIG_DIR_VAR),
140 relocated: "skills",
141};
142
143const AGENTS: AgentRoot = AgentRoot {
145 id: AgentId::Agents,
146 default: AGENTS_ROOT,
147 config_env: None,
148 relocated: "skills",
149};
150
151pub const AGENT_ROOTS: &[AgentRoot] = &[CLAUDE, AGENTS];
158
159#[must_use]
161pub const fn agent_root(id: AgentId) -> &'static AgentRoot {
162 match id {
163 AgentId::Claude => &CLAUDE,
164 AgentId::Agents => &AGENTS,
165 }
166}
167
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
170#[serde(rename_all = "kebab-case")]
171pub enum PathSource {
172 Recorded,
174 Default,
176 Env,
178 Profile,
180 Proposal,
182}
183
184#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
186pub struct PathEntry {
187 pub path: Utf8PathBuf,
189 pub source: PathSource,
191}
192
193impl PathEntry {
194 #[must_use]
196 pub fn default_at(path: impl Into<Utf8PathBuf>) -> Self {
197 Self {
198 path: path.into(),
199 source: PathSource::Default,
200 }
201 }
202
203 #[must_use]
205 pub fn from_env(path: impl Into<Utf8PathBuf>) -> Self {
206 Self {
207 path: path.into(),
208 source: PathSource::Env,
209 }
210 }
211}
212
213#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
215pub struct AgentRootEntry {
216 pub id: AgentId,
218 pub path: Utf8PathBuf,
220 pub source: PathSource,
222 pub variable: Option<&'static str>,
224}
225
226#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
233#[serde(tag = "kind", rename_all = "kebab-case")]
234pub enum ProjectLocation {
235 Tracked {
237 path: Utf8PathBuf,
239 source: PathSource,
241 },
242 Untracked {
244 path: Utf8PathBuf,
246 source: PathSource,
248 },
249 External {
251 path: Utf8PathBuf,
253 source: PathSource,
255 },
256 Env {
258 variable: &'static str,
260 value: Option<String>,
262 },
263 None,
265}
266
267#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
273#[serde(tag = "kind", rename_all = "kebab-case")]
274pub enum LocationChoice {
275 Observed {
277 path: Utf8PathBuf,
279 },
280 Env {
282 variable: &'static str,
284 value: Option<String>,
286 },
287 Operator,
289 None,
291}
292
293#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
295pub struct Proposals {
296 pub docs_scratch: Vec<LocationChoice>,
298}
299
300#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
302pub struct UserPaths {
303 pub state_root: PathEntry,
305 pub cache_root: PathEntry,
307 pub skill_receipt: PathEntry,
309 pub plan_store: PathEntry,
311 pub bundle_cache: PathEntry,
313 pub agent_roots: Vec<AgentRootEntry>,
315}
316
317#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
319pub struct InstancePaths {
320 pub instance_dir: PathEntry,
322 pub manifest: PathEntry,
324 pub declaration: PathEntry,
326 pub debt: PathEntry,
328 pub legacy_debt: PathEntry,
330 pub hooks_config: PathEntry,
332 pub agents_digest: PathEntry,
334 pub docs_root: PathEntry,
336 pub specs: PathEntry,
338 pub decisions: PathEntry,
340 pub reference: PathEntry,
342 pub guides: PathEntry,
344}
345
346#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
348pub struct ActivePaths {
349 pub profile: ProfileId,
351 pub destinations: InstancePaths,
353 pub docs_scratch: ProjectLocation,
355}
356
357#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
359pub struct CandidatePaths {
360 pub profile: ProfileId,
362 pub destinations: InstancePaths,
364}
365
366#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
368pub struct Paths {
369 pub user: Option<UserPaths>,
372 pub active: Option<ActivePaths>,
374 pub candidates: BTreeMap<String, CandidatePaths>,
376 pub proposals: Proposals,
378}
379
380#[derive(Debug, Clone, Default, PartialEq, Eq)]
382pub struct UserEnv {
383 pub home: Option<Utf8PathBuf>,
385 pub claude_config_dir: Option<Utf8PathBuf>,
387 pub xdg_state_home: Option<Utf8PathBuf>,
389 pub xdg_cache_home: Option<Utf8PathBuf>,
391 pub docs_scratch: Option<String>,
393}
394
395#[must_use]
401pub fn variable(name: &str) -> Option<String> {
402 std::env::var(name)
403 .ok()
404 .map(|value| value.trim().to_string())
405 .filter(|value| !value.is_empty())
406}
407
408impl UserEnv {
409 #[must_use]
411 pub fn from_process() -> Self {
412 let path = |name: &str| variable(name).map(Utf8PathBuf::from);
413 Self {
414 home: path(HOME_VAR),
415 claude_config_dir: path(CLAUDE_CONFIG_DIR_VAR),
416 xdg_state_home: path(XDG_STATE_HOME_VAR),
417 xdg_cache_home: path(XDG_CACHE_HOME_VAR),
418 docs_scratch: variable(DOCS_SCRATCH_VAR),
419 }
420 }
421
422 #[must_use]
424 pub fn state_root(&self) -> Option<PathEntry> {
425 if let Some(base) = self.xdg_state_home.as_ref() {
426 return Some(PathEntry::from_env(base.join(TOOL_DIR)));
427 }
428 self.home
429 .as_ref()
430 .map(|home| PathEntry::default_at(home.join(STATE_ROOT)))
431 }
432
433 #[must_use]
439 pub fn legacy_state_root(&self) -> Option<Utf8PathBuf> {
440 self.home.as_ref().map(|home| home.join(STATE_ROOT))
441 }
442
443 #[must_use]
445 pub fn cache_root(&self) -> Option<PathEntry> {
446 if let Some(base) = self.xdg_cache_home.as_ref() {
447 return Some(PathEntry::from_env(base.join(TOOL_DIR)));
448 }
449 self.home
450 .as_ref()
451 .map(|home| PathEntry::default_at(home.join(CACHE_ROOT)))
452 }
453
454 #[must_use]
456 pub fn agent_root(&self, id: AgentId) -> Option<AgentRootEntry> {
457 let row = agent_root(id);
458 let home = self.home.as_ref()?;
459 let relocated = match row.id {
460 AgentId::Claude => self.claude_config_dir.as_ref(),
461 AgentId::Agents => None,
462 };
463 Some(relocated.map_or_else(
464 || AgentRootEntry {
465 id: row.id,
466 path: home.join(row.default),
467 source: PathSource::Default,
468 variable: None,
469 },
470 |base| AgentRootEntry {
471 id: row.id,
472 path: base.join(row.relocated),
473 source: PathSource::Env,
474 variable: row.config_env,
475 },
476 ))
477 }
478
479 #[must_use]
485 pub fn agent_roots(&self, selected: &[AgentId]) -> Vec<AgentRootEntry> {
486 let mut resolved: Vec<AgentRootEntry> = Vec::new();
487 for row in AGENT_ROOTS {
488 if !selected.contains(&row.id) {
489 continue;
490 }
491 let Some(entry) = self.agent_root(row.id) else {
492 continue;
493 };
494 if resolved.iter().any(|held| held.path == entry.path) {
495 continue;
496 }
497 resolved.push(entry);
498 }
499 resolved
500 }
501
502 #[must_use]
504 pub fn user_paths(&self) -> Option<UserPaths> {
505 let state = self.state_root()?;
506 let cache = self.cache_root()?;
507 Some(UserPaths {
508 skill_receipt: PathEntry {
509 path: state.path.join(SKILL_RECEIPT_FILE),
510 source: state.source,
511 },
512 plan_store: PathEntry {
513 path: state.path.join(PLAN_STORE_DIR),
514 source: state.source,
515 },
516 bundle_cache: PathEntry {
517 path: cache.path.join(BUNDLE_CACHE_DIR),
518 source: cache.source,
519 },
520 agent_roots: self.agent_roots(&[AgentId::Claude, AgentId::Agents]),
521 state_root: state,
522 cache_root: cache,
523 })
524 }
525}
526
527#[must_use]
529pub fn instance_paths(docs_root: DocsRoot) -> InstancePaths {
530 let docs = Utf8PathBuf::from(docs_root.as_str());
531 let under = |leaf: &str| PathEntry {
532 path: docs.join(leaf),
533 source: PathSource::Profile,
534 };
535 InstancePaths {
536 instance_dir: PathEntry::default_at(INSTANCE_DIR),
537 manifest: PathEntry::default_at(MANIFEST_PATH),
538 declaration: PathEntry::default_at(CONFIG_PATH),
539 debt: PathEntry::default_at(DEBT_PATH),
540 legacy_debt: PathEntry::default_at(LEGACY_DEBT_PATH),
541 hooks_config: PathEntry::default_at(HOOKS_CONFIG_PATH),
542 agents_digest: PathEntry::default_at(AGENTS_DIGEST_PATH),
543 docs_root: PathEntry {
544 path: docs.clone(),
545 source: PathSource::Profile,
546 },
547 specs: under("specs"),
548 decisions: under("decisions"),
549 reference: under("reference"),
550 guides: under("guides"),
551 }
552}
553
554#[must_use]
556pub fn recorded_paths(docs_root: DocsRoot) -> InstancePaths {
557 let mut paths = instance_paths(docs_root);
558 for entry in [
559 &mut paths.docs_root,
560 &mut paths.specs,
561 &mut paths.decisions,
562 &mut paths.reference,
563 &mut paths.guides,
564 ] {
565 entry.source = PathSource::Recorded;
566 }
567 paths
568}
569
570#[must_use]
572pub fn candidates() -> BTreeMap<String, CandidatePaths> {
573 ProfileId::every()
574 .map(|profile| {
575 (
576 profile.as_str().to_string(),
577 CandidatePaths {
578 profile,
579 destinations: instance_paths(profile.profile().docs_root),
580 },
581 )
582 })
583 .collect()
584}
585
586#[must_use]
593pub fn docs_scratch_location(recorded: Option<&Utf8Path>, env: &UserEnv) -> ProjectLocation {
594 if env.docs_scratch.is_some() {
595 return ProjectLocation::Env {
596 variable: DOCS_SCRATCH_VAR,
597 value: env.docs_scratch.clone(),
598 };
599 }
600 let Some(path) = recorded else {
601 return ProjectLocation::None;
602 };
603 if path.is_absolute() || path.starts_with("..") {
604 return ProjectLocation::External {
605 path: path.to_owned(),
606 source: PathSource::Recorded,
607 };
608 }
609 ProjectLocation::Untracked {
610 path: path.to_owned(),
611 source: PathSource::Recorded,
612 }
613}
614
615pub const DOCS_SCRATCH_LEAVES: &[&str] = &[".docs-scratch", ".scratch"];
621
622pub fn proposals(env: &UserEnv, held: impl Fn(&Utf8Path) -> bool) -> Proposals {
628 let mut docs_scratch: Vec<LocationChoice> = Vec::new();
629 for leaf in DOCS_SCRATCH_LEAVES {
630 let candidate = Utf8PathBuf::from(*leaf);
631 if held(&candidate) {
632 docs_scratch.push(LocationChoice::Observed { path: candidate });
633 }
634 }
635 docs_scratch.push(LocationChoice::Env {
636 variable: DOCS_SCRATCH_VAR,
637 value: env.docs_scratch.clone(),
638 });
639 docs_scratch.push(LocationChoice::Operator);
640 docs_scratch.push(LocationChoice::None);
641
642 Proposals { docs_scratch }
643}
644
645#[cfg(test)]
646mod tests {
647 use super::*;
648
649 fn env(home: &str) -> UserEnv {
650 UserEnv {
651 home: Some(Utf8PathBuf::from(home)),
652 ..UserEnv::default()
653 }
654 }
655
656 #[test]
657 fn the_agent_root_table_has_two_rows_and_resolves_each_with_its_source() {
658 assert_eq!(AGENT_ROOTS.len(), 2);
659 let resolved = env("/h").agent_roots(&[AgentId::Claude, AgentId::Agents]);
660 assert_eq!(resolved.len(), 2);
661 assert_eq!(resolved[0].path, "/h/.claude/skills");
662 assert_eq!(resolved[0].source, PathSource::Default);
663 assert_eq!(resolved[0].variable, None);
664 assert_eq!(resolved[1].path, "/h/.agents/skills");
665 assert_eq!(resolved[1].id, AgentId::Agents);
666 }
667
668 #[test]
669 fn claude_config_dir_relocates_the_claude_root_and_nothing_else() {
670 let moved = UserEnv {
671 claude_config_dir: Some(Utf8PathBuf::from("/elsewhere/claude")),
672 ..env("/h")
673 };
674 let resolved = moved.agent_roots(&[AgentId::Claude, AgentId::Agents]);
675 assert_eq!(resolved[0].path, "/elsewhere/claude/skills");
676 assert_eq!(resolved[0].source, PathSource::Env);
677 assert_eq!(resolved[0].variable, Some(CLAUDE_CONFIG_DIR_VAR));
678 assert_eq!(resolved[1].path, "/h/.agents/skills");
679 assert_eq!(resolved[1].source, PathSource::Default);
680 }
681
682 #[test]
683 fn an_empty_claude_config_dir_is_treated_as_unset() {
684 let blank = UserEnv {
686 claude_config_dir: None,
687 ..env("/h")
688 };
689 assert_eq!(
690 blank.agent_root(AgentId::Claude).map(|root| root.path),
691 Some(Utf8PathBuf::from("/h/.claude/skills"))
692 );
693 }
694
695 #[test]
696 fn two_selected_roots_that_resolve_to_one_path_are_returned_once() {
697 let collided = UserEnv {
698 claude_config_dir: Some(Utf8PathBuf::from("/h/.agents")),
699 ..env("/h")
700 };
701 let resolved = collided.agent_roots(&[AgentId::Claude, AgentId::Agents]);
702 assert_eq!(resolved.len(), 1);
703 assert_eq!(resolved[0].path, "/h/.agents/skills");
704 assert_eq!(resolved[0].id, AgentId::Claude);
705 }
706
707 #[test]
708 fn the_cache_root_follows_xdg_cache_home_and_its_default() {
709 assert_eq!(
710 env("/h").cache_root(),
711 Some(PathEntry::default_at("/h/.cache/spec-driven-docs"))
712 );
713 let moved = UserEnv {
714 xdg_cache_home: Some(Utf8PathBuf::from("/c")),
715 ..env("/h")
716 };
717 assert_eq!(
718 moved.cache_root(),
719 Some(PathEntry::from_env("/c/spec-driven-docs"))
720 );
721 }
722
723 #[test]
724 fn the_state_root_follows_xdg_state_home_and_its_default() {
725 assert_eq!(
726 env("/h").state_root(),
727 Some(PathEntry::default_at("/h/.local/state/spec-driven-docs"))
728 );
729 let moved = UserEnv {
730 xdg_state_home: Some(Utf8PathBuf::from("/s")),
731 ..env("/h")
732 };
733 assert_eq!(
734 moved.state_root(),
735 Some(PathEntry::from_env("/s/spec-driven-docs"))
736 );
737 assert_eq!(
738 moved.legacy_state_root(),
739 Some(Utf8PathBuf::from("/h/.local/state/spec-driven-docs"))
740 );
741 }
742
743 #[test]
744 fn the_user_paths_hang_off_the_two_roots() {
745 let paths = env("/h").user_paths().unwrap();
746 assert_eq!(
747 paths.skill_receipt.path,
748 "/h/.local/state/spec-driven-docs/skills.json"
749 );
750 assert_eq!(
751 paths.plan_store.path,
752 "/h/.local/state/spec-driven-docs/plans"
753 );
754 assert_eq!(
755 paths.bundle_cache.path,
756 "/h/.cache/spec-driven-docs/bundles"
757 );
758 }
759
760 #[test]
761 fn the_home_relative_constants_agree_with_the_resolved_roots() {
762 let paths = env("/h").user_paths().unwrap();
763 assert_eq!(
764 paths.skill_receipt.path,
765 Utf8Path::new("/h").join(LEGACY_SKILL_RECEIPT_PATH)
766 );
767 }
768
769 #[test]
770 fn a_candidate_set_exists_for_every_profile() {
771 let candidates = candidates();
772 assert_eq!(candidates.len(), 2);
773 assert_eq!(candidates["codebase"].destinations.specs.path, "docs/specs");
774 assert_eq!(
775 candidates["knowledge-base"].destinations.specs.path,
776 "_docs/specs"
777 );
778 assert_eq!(
779 candidates["codebase"].destinations.declaration.source,
780 PathSource::Default
781 );
782 assert_eq!(
783 candidates["codebase"].destinations.specs.source,
784 PathSource::Profile
785 );
786 }
787
788 #[test]
789 fn a_recorded_location_reports_as_recorded_and_an_override_as_env() {
790 let overridden = UserEnv {
791 docs_scratch: Some("elsewhere".to_string()),
792 ..env("/h")
793 };
794 assert_eq!(
795 docs_scratch_location(Some(Utf8Path::new(".docs-scratch")), &overridden),
796 ProjectLocation::Env {
797 variable: DOCS_SCRATCH_VAR,
798 value: Some("elsewhere".to_string()),
799 }
800 );
801 }
802
803 #[test]
804 fn a_scratch_beside_the_checkout_reports_as_external() {
805 let plain = env("/h");
806 assert_eq!(
807 docs_scratch_location(Some(Utf8Path::new("../x.docs-scratch")), &plain),
808 ProjectLocation::External {
809 path: Utf8PathBuf::from("../x.docs-scratch"),
810 source: PathSource::Recorded,
811 }
812 );
813 assert_eq!(
814 docs_scratch_location(Some(Utf8Path::new(".docs-scratch")), &plain),
815 ProjectLocation::Untracked {
816 path: Utf8PathBuf::from(".docs-scratch"),
817 source: PathSource::Recorded,
818 }
819 );
820 assert_eq!(docs_scratch_location(None, &plain), ProjectLocation::None);
821 }
822
823 #[test]
824 fn a_proposal_carries_a_path_only_where_the_target_holds_one() {
825 let plain = env("/h");
826 let bare = proposals(&plain, |_| false);
827 assert!(
828 !bare
829 .docs_scratch
830 .iter()
831 .any(|choice| matches!(choice, LocationChoice::Observed { .. }))
832 );
833 assert_eq!(bare.docs_scratch.last(), Some(&LocationChoice::None));
834
835 let observed = proposals(&plain, |path| path == Utf8Path::new(".docs-scratch"));
836 assert_eq!(
837 observed.docs_scratch.first(),
838 Some(&LocationChoice::Observed {
839 path: Utf8PathBuf::from(".docs-scratch")
840 })
841 );
842 }
843}