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(
102 clippy::literal_string_with_formatting_args,
103 reason = "the braces are the destination template's placeholder, not a formatting argument"
104)]
105pub fn resolve_destination(destination: &str, docs_root: DocsRoot) -> Utf8PathBuf {
106 Utf8PathBuf::from(destination.replace("{docs_root}", docs_root.as_str()))
107}
108
109#[derive(Debug)]
111pub struct Profile {
112 pub id: ProfileId,
114 pub docs_root: DocsRoot,
116 pub managed: &'static [Projection],
118 pub adopted: &'static [Projection],
120}
121
122const MANAGED: &[Projection] = &[
131 proj(
132 ".markdownlint/adr.markdownlint-cli2.jsonc",
133 ".spec-driven-docs/markdownlint/adr.markdownlint-cli2.jsonc",
134 ),
135 proj(
136 ".markdownlint/spec.markdownlint-cli2.jsonc",
137 ".spec-driven-docs/markdownlint/spec.markdownlint-cli2.jsonc",
138 ),
139 proj(
140 ".markdownlint/relative-links.markdownlint-cli2.jsonc",
141 ".spec-driven-docs/markdownlint/relative-links.markdownlint-cli2.jsonc",
142 ),
143];
144
145pub const CANON_TEMPLATES: &[&str] = &[
153 "_docs/decisions/TEMPLATE-adr.md",
154 "_docs/reference/TEMPLATE-agents-digest.md",
155];
156
157const ADOPTED: &[Projection] = &[
158 proj(
162 "instance/seeds/config.yaml",
163 ".spec-driven-docs/config.yaml",
164 ),
165 proj(
170 "_docs/specs/SPEC-budget-debt.md",
171 "{docs_root}/specs/SPEC-budget-debt.md",
172 ),
173 proj(
174 "_docs/specs/SPEC-writing-policy.md",
175 "{docs_root}/specs/SPEC-writing-policy.md",
176 ),
177 proj(
178 "_docs/specs/SPEC-decision-records.md",
179 "{docs_root}/specs/SPEC-decision-records.md",
180 ),
181 proj(
182 "_docs/specs/SPEC-instance.md",
183 "{docs_root}/specs/SPEC-instance.md",
184 ),
185 proj(
186 "_docs/specs/SPEC-docs-format.md",
187 "{docs_root}/specs/SPEC-docs-format.md",
188 ),
189 proj(
190 "_docs/specs/SPEC-docs-foundations.md",
191 "{docs_root}/specs/SPEC-docs-foundations.md",
192 ),
193 proj(
194 "_docs/specs/SPEC-docs-specs.md",
195 "{docs_root}/specs/SPEC-docs-specs.md",
196 ),
197 proj(
198 "_docs/specs/SPEC-comparison-docs.md",
199 "{docs_root}/specs/SPEC-comparison-docs.md",
200 ),
201 proj(
202 "_docs/specs/SPEC-known-issues.md",
203 "{docs_root}/specs/SPEC-known-issues.md",
204 ),
205 proj(
206 "_docs/specs/SPEC-spec-to-code.md",
207 "{docs_root}/specs/SPEC-spec-to-code.md",
208 ),
209 proj(
210 "_docs/specs/SPEC-guides.md",
211 "{docs_root}/specs/SPEC-guides.md",
212 ),
213 proj(
214 "_docs/specs/SPEC-writing-style.md",
215 "{docs_root}/specs/SPEC-writing-style.md",
216 ),
217 proj(
218 "_docs/specs/SPEC-tracking.md",
219 "{docs_root}/specs/SPEC-tracking.md",
220 ),
221 proj(
222 "_docs/specs/SPEC-tracking/tracking.schema.json",
223 "{docs_root}/specs/SPEC-tracking/tracking.schema.json",
224 ),
225 proj(
226 "templates/TEMPLATE-tracking.yaml",
227 "{docs_root}/reference/tracking.yaml",
228 ),
229 proj(
230 "templates/TEMPLATE-spec.md",
231 "{docs_root}/specs/TEMPLATE-spec.md",
232 ),
233 proj(
234 "templates/TEMPLATE-adr.md",
235 "{docs_root}/decisions/TEMPLATE-adr.md",
236 ),
237 proj(
238 "templates/TEMPLATE-agents-digest.md",
239 "{docs_root}/reference/TEMPLATE-agents-digest.md",
240 ),
241 proj(
242 "templates/TEMPLATE-guide.md",
243 "{docs_root}/guides/TEMPLATE-guide.md",
244 ),
245 proj(
246 "templates/TEMPLATE-known-issue.md",
247 "{docs_root}/reference/TEMPLATE-known-issue.md",
248 ),
249];
250
251static CODEBASE: Profile = Profile {
252 id: ProfileId::Codebase,
253 docs_root: DocsRoot::Docs,
254 managed: MANAGED,
255 adopted: ADOPTED,
256};
257
258static KNOWLEDGE_BASE: Profile = Profile {
259 id: ProfileId::KnowledgeBase,
260 docs_root: DocsRoot::UnderscoreDocs,
261 managed: MANAGED,
262 adopted: ADOPTED,
263};
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268
269 #[test]
270 fn profiles_bind_their_roots() {
271 assert_eq!(ProfileId::Codebase.profile().docs_root, DocsRoot::Docs);
272 assert_eq!(
273 ProfileId::KnowledgeBase.profile().docs_root,
274 DocsRoot::UnderscoreDocs
275 );
276 }
277
278 #[test]
279 fn destinations_resolve_per_root() {
280 assert_eq!(
281 resolve_destination("{docs_root}/specs/SPEC-distribution.md", DocsRoot::Docs),
282 Utf8PathBuf::from("docs/specs/SPEC-distribution.md")
283 );
284 assert_eq!(
285 resolve_destination(
286 ".spec-driven-docs/markdownlint/x.jsonc",
287 DocsRoot::UnderscoreDocs
288 ),
289 Utf8PathBuf::from(".spec-driven-docs/markdownlint/x.jsonc")
290 );
291 }
292
293 #[test]
294 fn serde_uses_the_kebab_names() {
295 assert_eq!(
296 serde_json::to_string(&ProfileId::KnowledgeBase).unwrap(),
297 "\"knowledge-base\""
298 );
299 assert_eq!(
300 serde_json::to_string(&DocsRoot::UnderscoreDocs).unwrap(),
301 "\"_docs\""
302 );
303 }
304
305 #[test]
306 fn destination_templates_only_use_the_placeholder_in_adopted_paths() {
307 for entry in MANAGED {
308 assert!(
309 !entry.destination.contains('{'),
310 "{} is templated",
311 entry.destination
312 );
313 }
314 for entry in ADOPTED {
315 if entry.destination == ".spec-driven-docs/config.yaml" {
319 continue;
320 }
321 assert!(
322 entry.destination.starts_with("{docs_root}/"),
323 "{} is not rooted",
324 entry.destination
325 );
326 }
327 }
328}