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] = &[
127 proj(
128 ".markdownlint/adr.markdownlint-cli2.jsonc",
129 ".spec-driven-docs/markdownlint/adr.markdownlint-cli2.jsonc",
130 ),
131 proj(
132 ".markdownlint/spec.markdownlint-cli2.jsonc",
133 ".spec-driven-docs/markdownlint/spec.markdownlint-cli2.jsonc",
134 ),
135 proj(
136 ".markdownlint/relative-links.markdownlint-cli2.jsonc",
137 ".spec-driven-docs/markdownlint/relative-links.markdownlint-cli2.jsonc",
138 ),
139];
140
141pub const CANON_TEMPLATES: &[&str] = &[
149 "_docs/decisions/TEMPLATE-adr.md",
150 "_docs/reference/TEMPLATE-agents-digest.md",
151];
152
153const ADOPTED: &[Projection] = &[
154 proj(
155 "_docs/specs/SPEC-decision-records.md",
156 "{docs_root}/specs/SPEC-decision-records.md",
157 ),
158 proj(
159 "_docs/specs/SPEC-instance.md",
160 "{docs_root}/specs/SPEC-instance.md",
161 ),
162 proj(
163 "_docs/specs/SPEC-docs-format.md",
164 "{docs_root}/specs/SPEC-docs-format.md",
165 ),
166 proj(
167 "_docs/specs/SPEC-docs-foundations.md",
168 "{docs_root}/specs/SPEC-docs-foundations.md",
169 ),
170 proj(
171 "_docs/specs/SPEC-docs-specs.md",
172 "{docs_root}/specs/SPEC-docs-specs.md",
173 ),
174 proj(
175 "_docs/specs/SPEC-comparison-docs.md",
176 "{docs_root}/specs/SPEC-comparison-docs.md",
177 ),
178 proj(
179 "_docs/specs/SPEC-known-issues.md",
180 "{docs_root}/specs/SPEC-known-issues.md",
181 ),
182 proj(
183 "_docs/specs/SPEC-spec-to-code.md",
184 "{docs_root}/specs/SPEC-spec-to-code.md",
185 ),
186 proj(
187 "_docs/specs/SPEC-guides.md",
188 "{docs_root}/specs/SPEC-guides.md",
189 ),
190 proj(
191 "_docs/specs/SPEC-writing-style.md",
192 "{docs_root}/specs/SPEC-writing-style.md",
193 ),
194 proj(
195 "_docs/specs/SPEC-tracking.md",
196 "{docs_root}/specs/SPEC-tracking.md",
197 ),
198 proj(
199 "_docs/specs/SPEC-tracking/tracking.schema.json",
200 "{docs_root}/specs/SPEC-tracking/tracking.schema.json",
201 ),
202 proj(
203 "templates/TEMPLATE-tracking.yaml",
204 "{docs_root}/reference/tracking.yaml",
205 ),
206 proj(
207 "templates/TEMPLATE-spec.md",
208 "{docs_root}/specs/TEMPLATE-spec.md",
209 ),
210 proj(
211 "templates/TEMPLATE-adr.md",
212 "{docs_root}/decisions/TEMPLATE-adr.md",
213 ),
214 proj(
215 "templates/TEMPLATE-agents-digest.md",
216 "{docs_root}/reference/TEMPLATE-agents-digest.md",
217 ),
218 proj(
219 "templates/TEMPLATE-guide.md",
220 "{docs_root}/guides/TEMPLATE-guide.md",
221 ),
222 proj(
223 "templates/TEMPLATE-known-issue.md",
224 "{docs_root}/reference/TEMPLATE-known-issue.md",
225 ),
226];
227
228static CODEBASE: Profile = Profile {
229 id: ProfileId::Codebase,
230 docs_root: DocsRoot::Docs,
231 managed: MANAGED,
232 adopted: ADOPTED,
233};
234
235static KNOWLEDGE_BASE: Profile = Profile {
236 id: ProfileId::KnowledgeBase,
237 docs_root: DocsRoot::UnderscoreDocs,
238 managed: MANAGED,
239 adopted: ADOPTED,
240};
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245
246 #[test]
247 fn profiles_bind_their_roots() {
248 assert_eq!(ProfileId::Codebase.profile().docs_root, DocsRoot::Docs);
249 assert_eq!(
250 ProfileId::KnowledgeBase.profile().docs_root,
251 DocsRoot::UnderscoreDocs
252 );
253 }
254
255 #[test]
256 fn destinations_resolve_per_root() {
257 assert_eq!(
258 resolve_destination("{docs_root}/specs/SPEC-distribution.md", DocsRoot::Docs),
259 Utf8PathBuf::from("docs/specs/SPEC-distribution.md")
260 );
261 assert_eq!(
262 resolve_destination(
263 ".spec-driven-docs/markdownlint/x.jsonc",
264 DocsRoot::UnderscoreDocs
265 ),
266 Utf8PathBuf::from(".spec-driven-docs/markdownlint/x.jsonc")
267 );
268 }
269
270 #[test]
271 fn serde_uses_the_kebab_names() {
272 assert_eq!(
273 serde_json::to_string(&ProfileId::KnowledgeBase).unwrap(),
274 "\"knowledge-base\""
275 );
276 assert_eq!(
277 serde_json::to_string(&DocsRoot::UnderscoreDocs).unwrap(),
278 "\"_docs\""
279 );
280 }
281
282 #[test]
283 fn destination_templates_only_use_the_placeholder_in_adopted_paths() {
284 for entry in MANAGED {
285 assert!(
286 !entry.destination.contains('{'),
287 "{} is templated",
288 entry.destination
289 );
290 }
291 for entry in ADOPTED {
292 assert!(
293 entry.destination.starts_with("{docs_root}/"),
294 "{} is not rooted",
295 entry.destination
296 );
297 }
298 }
299}