use std::collections::BTreeMap;
use camino::{Utf8Path, Utf8PathBuf};
use serde::Serialize;
use crate::domain::profile::{DocsRoot, ProfileId};
pub const HOME_VAR: &str = "HOME";
pub const CLAUDE_CONFIG_DIR_VAR: &str = "CLAUDE_CONFIG_DIR";
pub const XDG_STATE_HOME_VAR: &str = "XDG_STATE_HOME";
pub const XDG_CACHE_HOME_VAR: &str = "XDG_CACHE_HOME";
pub const OFFLINE_VAR: &str = "SDD_OFFLINE";
pub const DOCS_SCRATCH_VAR: &str = "SDD_DOCS_SCRATCH";
pub const SELF_DEPEND_OFF_VAR: &str = "SDD_SELF_DEPEND_OFF";
pub const CI_VAR: &str = "CI";
pub const TOOL_DIR: &str = "spec-driven-docs";
pub const INSTANCE_DIR: &str = ".spec-driven-docs";
pub const MANIFEST_PATH: &str = ".spec-driven-docs/manifest.json";
pub const CONFIG_PATH: &str = ".spec-driven-docs/config.yaml";
pub const DEBT_PATH: &str = ".spec-driven-docs/debt.yaml";
pub const LEGACY_DEBT_PATH: &str = ".spec-driven-docs/chapter-size-debt.txt";
pub const HOOKS_CONFIG_PATH: &str = ".pre-commit-config.yaml";
pub const AGENTS_DIGEST_PATH: &str = "AGENTS.md";
pub const PRUNABLE_ROOTS: &[&str] = &[".spec-driven-docs/", ".claude/skills/", ".agents/skills/"];
pub const STATE_ROOT: &str = ".local/state/spec-driven-docs";
pub const CACHE_ROOT: &str = ".cache/spec-driven-docs";
pub const SKILL_RECEIPT_FILE: &str = "skills.json";
pub const SKILL_FILE: &str = "SKILL.md";
pub const SKILL_REFERENCES_DIR: &str = "references";
pub const SKILL_LOCK_FILE: &str = "skills.lock";
pub const SKILL_JOURNAL_FILE: &str = "skills.journal";
pub const BACKUPS_DIR: &str = "backups";
pub const PLAN_STORE_DIR: &str = "plans";
pub const SELF_DEPEND_STAMP_DIR: &str = "self-depend";
pub const BUNDLE_CACHE_DIR: &str = "bundles";
pub const LEGACY_SKILL_RECEIPT_PATH: &str = ".local/state/spec-driven-docs/skills.json";
pub const LEGACY_SHARED_ROOT: &str = ".local/state/spec-driven-docs/skills/shared";
pub const CLAUDE_ROOT: &str = ".claude/skills";
pub const AGENTS_ROOT: &str = ".agents/skills";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum AgentId {
Claude,
Agents,
}
impl AgentId {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Claude => "claude",
Self::Agents => "agents",
}
}
}
impl std::fmt::Display for AgentId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AgentRoot {
pub id: AgentId,
pub default: &'static str,
pub config_env: Option<&'static str>,
pub relocated: &'static str,
}
const CLAUDE: AgentRoot = AgentRoot {
id: AgentId::Claude,
default: CLAUDE_ROOT,
config_env: Some(CLAUDE_CONFIG_DIR_VAR),
relocated: "skills",
};
const AGENTS: AgentRoot = AgentRoot {
id: AgentId::Agents,
default: AGENTS_ROOT,
config_env: None,
relocated: "skills",
};
pub const AGENT_ROOTS: &[AgentRoot] = &[CLAUDE, AGENTS];
#[must_use]
pub const fn agent_root(id: AgentId) -> &'static AgentRoot {
match id {
AgentId::Claude => &CLAUDE,
AgentId::Agents => &AGENTS,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum PathSource {
Recorded,
Default,
Env,
Profile,
Proposal,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct PathEntry {
pub path: Utf8PathBuf,
pub source: PathSource,
}
impl PathEntry {
#[must_use]
pub fn default_at(path: impl Into<Utf8PathBuf>) -> Self {
Self {
path: path.into(),
source: PathSource::Default,
}
}
#[must_use]
pub fn from_env(path: impl Into<Utf8PathBuf>) -> Self {
Self {
path: path.into(),
source: PathSource::Env,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AgentRootEntry {
pub id: AgentId,
pub path: Utf8PathBuf,
pub source: PathSource,
pub variable: Option<&'static str>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum ProjectLocation {
Tracked {
path: Utf8PathBuf,
source: PathSource,
},
Untracked {
path: Utf8PathBuf,
source: PathSource,
},
External {
path: Utf8PathBuf,
source: PathSource,
},
Env {
variable: &'static str,
value: Option<String>,
},
None,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum LocationChoice {
Observed {
path: Utf8PathBuf,
},
Env {
variable: &'static str,
value: Option<String>,
},
Operator,
None,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Proposals {
pub docs_scratch: Vec<LocationChoice>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct UserPaths {
pub state_root: PathEntry,
pub cache_root: PathEntry,
pub skill_receipt: PathEntry,
pub plan_store: PathEntry,
pub bundle_cache: PathEntry,
pub agent_roots: Vec<AgentRootEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct InstancePaths {
pub instance_dir: PathEntry,
pub manifest: PathEntry,
pub declaration: PathEntry,
pub debt: PathEntry,
pub legacy_debt: PathEntry,
pub hooks_config: PathEntry,
pub agents_digest: PathEntry,
pub docs_root: PathEntry,
pub specs: PathEntry,
pub decisions: PathEntry,
pub reference: PathEntry,
pub guides: PathEntry,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ActivePaths {
pub profile: ProfileId,
pub destinations: InstancePaths,
pub docs_scratch: ProjectLocation,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct CandidatePaths {
pub profile: ProfileId,
pub destinations: InstancePaths,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Paths {
pub user: Option<UserPaths>,
pub active: Option<ActivePaths>,
pub candidates: BTreeMap<String, CandidatePaths>,
pub proposals: Proposals,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct UserEnv {
pub home: Option<Utf8PathBuf>,
pub claude_config_dir: Option<Utf8PathBuf>,
pub xdg_state_home: Option<Utf8PathBuf>,
pub xdg_cache_home: Option<Utf8PathBuf>,
pub docs_scratch: Option<String>,
}
#[must_use]
pub fn variable(name: &str) -> Option<String> {
std::env::var(name)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
impl UserEnv {
#[must_use]
pub fn from_process() -> Self {
let path = |name: &str| variable(name).map(Utf8PathBuf::from);
Self {
home: path(HOME_VAR),
claude_config_dir: path(CLAUDE_CONFIG_DIR_VAR),
xdg_state_home: path(XDG_STATE_HOME_VAR),
xdg_cache_home: path(XDG_CACHE_HOME_VAR),
docs_scratch: variable(DOCS_SCRATCH_VAR),
}
}
#[must_use]
pub fn state_root(&self) -> Option<PathEntry> {
if let Some(base) = self.xdg_state_home.as_ref() {
return Some(PathEntry::from_env(base.join(TOOL_DIR)));
}
self.home
.as_ref()
.map(|home| PathEntry::default_at(home.join(STATE_ROOT)))
}
#[must_use]
pub fn legacy_state_root(&self) -> Option<Utf8PathBuf> {
self.home.as_ref().map(|home| home.join(STATE_ROOT))
}
#[must_use]
pub fn cache_root(&self) -> Option<PathEntry> {
if let Some(base) = self.xdg_cache_home.as_ref() {
return Some(PathEntry::from_env(base.join(TOOL_DIR)));
}
self.home
.as_ref()
.map(|home| PathEntry::default_at(home.join(CACHE_ROOT)))
}
#[must_use]
pub fn agent_root(&self, id: AgentId) -> Option<AgentRootEntry> {
let row = agent_root(id);
let home = self.home.as_ref()?;
let relocated = match row.id {
AgentId::Claude => self.claude_config_dir.as_ref(),
AgentId::Agents => None,
};
Some(relocated.map_or_else(
|| AgentRootEntry {
id: row.id,
path: home.join(row.default),
source: PathSource::Default,
variable: None,
},
|base| AgentRootEntry {
id: row.id,
path: base.join(row.relocated),
source: PathSource::Env,
variable: row.config_env,
},
))
}
#[must_use]
pub fn agent_roots(&self, selected: &[AgentId]) -> Vec<AgentRootEntry> {
let mut resolved: Vec<AgentRootEntry> = Vec::new();
for row in AGENT_ROOTS {
if !selected.contains(&row.id) {
continue;
}
let Some(entry) = self.agent_root(row.id) else {
continue;
};
if resolved.iter().any(|held| held.path == entry.path) {
continue;
}
resolved.push(entry);
}
resolved
}
#[must_use]
pub fn user_paths(&self) -> Option<UserPaths> {
let state = self.state_root()?;
let cache = self.cache_root()?;
Some(UserPaths {
skill_receipt: PathEntry {
path: state.path.join(SKILL_RECEIPT_FILE),
source: state.source,
},
plan_store: PathEntry {
path: state.path.join(PLAN_STORE_DIR),
source: state.source,
},
bundle_cache: PathEntry {
path: cache.path.join(BUNDLE_CACHE_DIR),
source: cache.source,
},
agent_roots: self.agent_roots(&[AgentId::Claude, AgentId::Agents]),
state_root: state,
cache_root: cache,
})
}
}
#[must_use]
pub fn instance_paths(docs_root: DocsRoot) -> InstancePaths {
let docs = Utf8PathBuf::from(docs_root.as_str());
let under = |leaf: &str| PathEntry {
path: docs.join(leaf),
source: PathSource::Profile,
};
InstancePaths {
instance_dir: PathEntry::default_at(INSTANCE_DIR),
manifest: PathEntry::default_at(MANIFEST_PATH),
declaration: PathEntry::default_at(CONFIG_PATH),
debt: PathEntry::default_at(DEBT_PATH),
legacy_debt: PathEntry::default_at(LEGACY_DEBT_PATH),
hooks_config: PathEntry::default_at(HOOKS_CONFIG_PATH),
agents_digest: PathEntry::default_at(AGENTS_DIGEST_PATH),
docs_root: PathEntry {
path: docs.clone(),
source: PathSource::Profile,
},
specs: under("specs"),
decisions: under("decisions"),
reference: under("reference"),
guides: under("guides"),
}
}
#[must_use]
pub fn recorded_paths(docs_root: DocsRoot) -> InstancePaths {
let mut paths = instance_paths(docs_root);
for entry in [
&mut paths.docs_root,
&mut paths.specs,
&mut paths.decisions,
&mut paths.reference,
&mut paths.guides,
] {
entry.source = PathSource::Recorded;
}
paths
}
#[must_use]
pub fn candidates() -> BTreeMap<String, CandidatePaths> {
ProfileId::every()
.map(|profile| {
(
profile.as_str().to_string(),
CandidatePaths {
profile,
destinations: instance_paths(profile.profile().docs_root),
},
)
})
.collect()
}
#[must_use]
pub fn docs_scratch_location(recorded: Option<&Utf8Path>, env: &UserEnv) -> ProjectLocation {
if env.docs_scratch.is_some() {
return ProjectLocation::Env {
variable: DOCS_SCRATCH_VAR,
value: env.docs_scratch.clone(),
};
}
let Some(path) = recorded else {
return ProjectLocation::None;
};
if path.is_absolute() || path.starts_with("..") {
return ProjectLocation::External {
path: path.to_owned(),
source: PathSource::Recorded,
};
}
ProjectLocation::Untracked {
path: path.to_owned(),
source: PathSource::Recorded,
}
}
pub const DOCS_SCRATCH_LEAVES: &[&str] = &[".docs-scratch", ".scratch"];
pub fn proposals(env: &UserEnv, held: impl Fn(&Utf8Path) -> bool) -> Proposals {
let mut docs_scratch: Vec<LocationChoice> = Vec::new();
for leaf in DOCS_SCRATCH_LEAVES {
let candidate = Utf8PathBuf::from(*leaf);
if held(&candidate) {
docs_scratch.push(LocationChoice::Observed { path: candidate });
}
}
docs_scratch.push(LocationChoice::Env {
variable: DOCS_SCRATCH_VAR,
value: env.docs_scratch.clone(),
});
docs_scratch.push(LocationChoice::Operator);
docs_scratch.push(LocationChoice::None);
Proposals { docs_scratch }
}
#[cfg(test)]
mod tests {
use super::*;
fn env(home: &str) -> UserEnv {
UserEnv {
home: Some(Utf8PathBuf::from(home)),
..UserEnv::default()
}
}
#[test]
fn the_agent_root_table_has_two_rows_and_resolves_each_with_its_source() {
assert_eq!(AGENT_ROOTS.len(), 2);
let resolved = env("/h").agent_roots(&[AgentId::Claude, AgentId::Agents]);
assert_eq!(resolved.len(), 2);
assert_eq!(resolved[0].path, "/h/.claude/skills");
assert_eq!(resolved[0].source, PathSource::Default);
assert_eq!(resolved[0].variable, None);
assert_eq!(resolved[1].path, "/h/.agents/skills");
assert_eq!(resolved[1].id, AgentId::Agents);
}
#[test]
fn claude_config_dir_relocates_the_claude_root_and_nothing_else() {
let moved = UserEnv {
claude_config_dir: Some(Utf8PathBuf::from("/elsewhere/claude")),
..env("/h")
};
let resolved = moved.agent_roots(&[AgentId::Claude, AgentId::Agents]);
assert_eq!(resolved[0].path, "/elsewhere/claude/skills");
assert_eq!(resolved[0].source, PathSource::Env);
assert_eq!(resolved[0].variable, Some(CLAUDE_CONFIG_DIR_VAR));
assert_eq!(resolved[1].path, "/h/.agents/skills");
assert_eq!(resolved[1].source, PathSource::Default);
}
#[test]
fn an_empty_claude_config_dir_is_treated_as_unset() {
let blank = UserEnv {
claude_config_dir: None,
..env("/h")
};
assert_eq!(
blank.agent_root(AgentId::Claude).map(|root| root.path),
Some(Utf8PathBuf::from("/h/.claude/skills"))
);
}
#[test]
fn two_selected_roots_that_resolve_to_one_path_are_returned_once() {
let collided = UserEnv {
claude_config_dir: Some(Utf8PathBuf::from("/h/.agents")),
..env("/h")
};
let resolved = collided.agent_roots(&[AgentId::Claude, AgentId::Agents]);
assert_eq!(resolved.len(), 1);
assert_eq!(resolved[0].path, "/h/.agents/skills");
assert_eq!(resolved[0].id, AgentId::Claude);
}
#[test]
fn the_cache_root_follows_xdg_cache_home_and_its_default() {
assert_eq!(
env("/h").cache_root(),
Some(PathEntry::default_at("/h/.cache/spec-driven-docs"))
);
let moved = UserEnv {
xdg_cache_home: Some(Utf8PathBuf::from("/c")),
..env("/h")
};
assert_eq!(
moved.cache_root(),
Some(PathEntry::from_env("/c/spec-driven-docs"))
);
}
#[test]
fn the_state_root_follows_xdg_state_home_and_its_default() {
assert_eq!(
env("/h").state_root(),
Some(PathEntry::default_at("/h/.local/state/spec-driven-docs"))
);
let moved = UserEnv {
xdg_state_home: Some(Utf8PathBuf::from("/s")),
..env("/h")
};
assert_eq!(
moved.state_root(),
Some(PathEntry::from_env("/s/spec-driven-docs"))
);
assert_eq!(
moved.legacy_state_root(),
Some(Utf8PathBuf::from("/h/.local/state/spec-driven-docs"))
);
}
#[test]
fn the_user_paths_hang_off_the_two_roots() {
let paths = env("/h").user_paths().unwrap();
assert_eq!(
paths.skill_receipt.path,
"/h/.local/state/spec-driven-docs/skills.json"
);
assert_eq!(
paths.plan_store.path,
"/h/.local/state/spec-driven-docs/plans"
);
assert_eq!(
paths.bundle_cache.path,
"/h/.cache/spec-driven-docs/bundles"
);
}
#[test]
fn the_home_relative_constants_agree_with_the_resolved_roots() {
let paths = env("/h").user_paths().unwrap();
assert_eq!(
paths.skill_receipt.path,
Utf8Path::new("/h").join(LEGACY_SKILL_RECEIPT_PATH)
);
}
#[test]
fn a_candidate_set_exists_for_every_profile() {
let candidates = candidates();
assert_eq!(candidates.len(), 2);
assert_eq!(candidates["codebase"].destinations.specs.path, "docs/specs");
assert_eq!(
candidates["knowledge-base"].destinations.specs.path,
"_docs/specs"
);
assert_eq!(
candidates["codebase"].destinations.declaration.source,
PathSource::Default
);
assert_eq!(
candidates["codebase"].destinations.specs.source,
PathSource::Profile
);
}
#[test]
fn a_recorded_location_reports_as_recorded_and_an_override_as_env() {
let overridden = UserEnv {
docs_scratch: Some("elsewhere".to_string()),
..env("/h")
};
assert_eq!(
docs_scratch_location(Some(Utf8Path::new(".docs-scratch")), &overridden),
ProjectLocation::Env {
variable: DOCS_SCRATCH_VAR,
value: Some("elsewhere".to_string()),
}
);
}
#[test]
fn a_scratch_beside_the_checkout_reports_as_external() {
let plain = env("/h");
assert_eq!(
docs_scratch_location(Some(Utf8Path::new("../x.docs-scratch")), &plain),
ProjectLocation::External {
path: Utf8PathBuf::from("../x.docs-scratch"),
source: PathSource::Recorded,
}
);
assert_eq!(
docs_scratch_location(Some(Utf8Path::new(".docs-scratch")), &plain),
ProjectLocation::Untracked {
path: Utf8PathBuf::from(".docs-scratch"),
source: PathSource::Recorded,
}
);
assert_eq!(docs_scratch_location(None, &plain), ProjectLocation::None);
}
#[test]
fn a_proposal_carries_a_path_only_where_the_target_holds_one() {
let plain = env("/h");
let bare = proposals(&plain, |_| false);
assert!(
!bare
.docs_scratch
.iter()
.any(|choice| matches!(choice, LocationChoice::Observed { .. }))
);
assert_eq!(bare.docs_scratch.last(), Some(&LocationChoice::None));
let observed = proposals(&plain, |path| path == Utf8Path::new(".docs-scratch"));
assert_eq!(
observed.docs_scratch.first(),
Some(&LocationChoice::Observed {
path: Utf8PathBuf::from(".docs-scratch")
})
);
}
}