1use std::fmt;
10
11use camino::Utf8PathBuf;
12use clap::ValueEnum;
13use serde::{Deserialize, Serialize};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum, Serialize, Deserialize)]
17#[value(rename_all = "kebab-case")]
18#[serde(rename_all = "kebab-case")]
19pub enum ProfileId {
20 Codebase,
22 KnowledgeBase,
24}
25
26impl ProfileId {
27 #[must_use]
29 pub const fn profile(self) -> &'static Profile {
30 match self {
31 Self::Codebase => &CODEBASE,
32 Self::KnowledgeBase => &KNOWLEDGE_BASE,
33 }
34 }
35
36 #[must_use]
38 pub const fn as_str(self) -> &'static str {
39 match self {
40 Self::Codebase => "codebase",
41 Self::KnowledgeBase => "knowledge-base",
42 }
43 }
44}
45
46impl fmt::Display for ProfileId {
47 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48 f.write_str(self.as_str())
49 }
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
54pub enum DocsRoot {
55 #[serde(rename = "docs")]
57 Docs,
58 #[serde(rename = "_docs")]
60 UnderscoreDocs,
61}
62
63impl DocsRoot {
64 #[must_use]
66 pub const fn as_str(self) -> &'static str {
67 match self {
68 Self::Docs => "docs",
69 Self::UnderscoreDocs => "_docs",
70 }
71 }
72}
73
74impl fmt::Display for DocsRoot {
75 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76 f.write_str(self.as_str())
77 }
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub struct Projection {
86 pub source: &'static str,
88 pub destination: &'static str,
90}
91
92const fn proj(source: &'static str, destination: &'static str) -> Projection {
93 Projection {
94 source,
95 destination,
96 }
97}
98
99#[must_use]
101#[allow(clippy::literal_string_with_formatting_args)]
103pub fn resolve_destination(destination: &str, docs_root: DocsRoot) -> Utf8PathBuf {
104 Utf8PathBuf::from(destination.replace("{docs_root}", docs_root.as_str()))
105}
106
107#[derive(Debug)]
109pub struct Profile {
110 pub id: ProfileId,
112 pub docs_root: DocsRoot,
114 pub managed: &'static [Projection],
116 pub adopted: &'static [Projection],
118}
119
120const MANAGED: &[Projection] = &[
121 proj(
122 ".markdownlint/adr.markdownlint-cli2.jsonc",
123 ".spec-driven-docs/markdownlint/adr.markdownlint-cli2.jsonc",
124 ),
125 proj(
126 ".markdownlint/spec.markdownlint-cli2.jsonc",
127 ".spec-driven-docs/markdownlint/spec.markdownlint-cli2.jsonc",
128 ),
129 proj(
130 ".markdownlint/relative-links.markdownlint-cli2.jsonc",
131 ".spec-driven-docs/markdownlint/relative-links.markdownlint-cli2.jsonc",
132 ),
133 proj(
134 "skills/sdd-setup/SKILL.md",
135 ".claude/skills/sdd-setup/SKILL.md",
136 ),
137 proj(
138 "skills/sdd-setup/SKILL.md",
139 ".agents/skills/sdd-setup/SKILL.md",
140 ),
141 proj(
142 "skills/sdd-write-docs/SKILL.md",
143 ".claude/skills/sdd-write-docs/SKILL.md",
144 ),
145 proj(
146 "skills/sdd-write-docs/SKILL.md",
147 ".agents/skills/sdd-write-docs/SKILL.md",
148 ),
149];
150
151const ADOPTED: &[Projection] = &[
152 proj(
153 "_docs/specs/SPEC-decision-records.md",
154 "{docs_root}/specs/SPEC-decision-records.md",
155 ),
156 proj(
157 "_docs/specs/SPEC-distribution.md",
158 "{docs_root}/specs/SPEC-distribution.md",
159 ),
160 proj(
161 "_docs/specs/SPEC-docs-format.md",
162 "{docs_root}/specs/SPEC-docs-format.md",
163 ),
164 proj(
165 "_docs/specs/SPEC-docs-foundations.md",
166 "{docs_root}/specs/SPEC-docs-foundations.md",
167 ),
168 proj(
169 "_docs/specs/SPEC-docs-specs.md",
170 "{docs_root}/specs/SPEC-docs-specs.md",
171 ),
172 proj(
173 "_docs/specs/SPEC-comparison-docs.md",
174 "{docs_root}/specs/SPEC-comparison-docs.md",
175 ),
176 proj(
177 "_docs/specs/SPEC-known-issues.md",
178 "{docs_root}/specs/SPEC-known-issues.md",
179 ),
180 proj(
181 "_docs/specs/SPEC-spec-to-code.md",
182 "{docs_root}/specs/SPEC-spec-to-code.md",
183 ),
184 proj(
185 "templates/TEMPLATE-spec.md",
186 "{docs_root}/specs/TEMPLATE-spec.md",
187 ),
188 proj(
189 "templates/TEMPLATE-adr.md",
190 "{docs_root}/decisions/TEMPLATE-adr.md",
191 ),
192 proj(
193 "templates/TEMPLATE-agents-digest.md",
194 "{docs_root}/reference/TEMPLATE-agents-digest.md",
195 ),
196];
197
198static CODEBASE: Profile = Profile {
199 id: ProfileId::Codebase,
200 docs_root: DocsRoot::Docs,
201 managed: MANAGED,
202 adopted: ADOPTED,
203};
204
205static KNOWLEDGE_BASE: Profile = Profile {
206 id: ProfileId::KnowledgeBase,
207 docs_root: DocsRoot::UnderscoreDocs,
208 managed: MANAGED,
209 adopted: ADOPTED,
210};
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215
216 #[test]
217 fn profiles_bind_their_roots() {
218 assert_eq!(ProfileId::Codebase.profile().docs_root, DocsRoot::Docs);
219 assert_eq!(
220 ProfileId::KnowledgeBase.profile().docs_root,
221 DocsRoot::UnderscoreDocs
222 );
223 }
224
225 #[test]
226 fn destinations_resolve_per_root() {
227 assert_eq!(
228 resolve_destination("{docs_root}/specs/SPEC-distribution.md", DocsRoot::Docs),
229 Utf8PathBuf::from("docs/specs/SPEC-distribution.md")
230 );
231 assert_eq!(
232 resolve_destination(
233 ".spec-driven-docs/markdownlint/x.jsonc",
234 DocsRoot::UnderscoreDocs
235 ),
236 Utf8PathBuf::from(".spec-driven-docs/markdownlint/x.jsonc")
237 );
238 }
239
240 #[test]
241 fn serde_uses_the_kebab_names() {
242 assert_eq!(
243 serde_json::to_string(&ProfileId::KnowledgeBase).unwrap(),
244 "\"knowledge-base\""
245 );
246 assert_eq!(
247 serde_json::to_string(&DocsRoot::UnderscoreDocs).unwrap(),
248 "\"_docs\""
249 );
250 }
251
252 #[test]
253 fn destination_templates_only_use_the_placeholder_in_adopted_paths() {
254 for entry in MANAGED {
255 assert!(
256 !entry.destination.contains('{'),
257 "{} is templated",
258 entry.destination
259 );
260 }
261 for entry in ADOPTED {
262 assert!(
263 entry.destination.starts_with("{docs_root}/"),
264 "{} is not rooted",
265 entry.destination
266 );
267 }
268 }
269}