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
141const ADOPTED: &[Projection] = &[
142 proj(
143 "_docs/specs/SPEC-decision-records.md",
144 "{docs_root}/specs/SPEC-decision-records.md",
145 ),
146 proj(
147 "_docs/specs/SPEC-instance.md",
148 "{docs_root}/specs/SPEC-instance.md",
149 ),
150 proj(
151 "_docs/specs/SPEC-docs-format.md",
152 "{docs_root}/specs/SPEC-docs-format.md",
153 ),
154 proj(
155 "_docs/specs/SPEC-docs-foundations.md",
156 "{docs_root}/specs/SPEC-docs-foundations.md",
157 ),
158 proj(
159 "_docs/specs/SPEC-docs-specs.md",
160 "{docs_root}/specs/SPEC-docs-specs.md",
161 ),
162 proj(
163 "_docs/specs/SPEC-comparison-docs.md",
164 "{docs_root}/specs/SPEC-comparison-docs.md",
165 ),
166 proj(
167 "_docs/specs/SPEC-known-issues.md",
168 "{docs_root}/specs/SPEC-known-issues.md",
169 ),
170 proj(
171 "_docs/specs/SPEC-spec-to-code.md",
172 "{docs_root}/specs/SPEC-spec-to-code.md",
173 ),
174 proj(
175 "_docs/specs/SPEC-guides.md",
176 "{docs_root}/specs/SPEC-guides.md",
177 ),
178 proj(
179 "templates/TEMPLATE-spec.md",
180 "{docs_root}/specs/TEMPLATE-spec.md",
181 ),
182 proj(
183 "templates/TEMPLATE-adr.md",
184 "{docs_root}/decisions/TEMPLATE-adr.md",
185 ),
186 proj(
187 "templates/TEMPLATE-agents-digest.md",
188 "{docs_root}/reference/TEMPLATE-agents-digest.md",
189 ),
190 proj(
191 "templates/TEMPLATE-guide.md",
192 "{docs_root}/guides/TEMPLATE-guide.md",
193 ),
194];
195
196static CODEBASE: Profile = Profile {
197 id: ProfileId::Codebase,
198 docs_root: DocsRoot::Docs,
199 managed: MANAGED,
200 adopted: ADOPTED,
201};
202
203static KNOWLEDGE_BASE: Profile = Profile {
204 id: ProfileId::KnowledgeBase,
205 docs_root: DocsRoot::UnderscoreDocs,
206 managed: MANAGED,
207 adopted: ADOPTED,
208};
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 #[test]
215 fn profiles_bind_their_roots() {
216 assert_eq!(ProfileId::Codebase.profile().docs_root, DocsRoot::Docs);
217 assert_eq!(
218 ProfileId::KnowledgeBase.profile().docs_root,
219 DocsRoot::UnderscoreDocs
220 );
221 }
222
223 #[test]
224 fn destinations_resolve_per_root() {
225 assert_eq!(
226 resolve_destination("{docs_root}/specs/SPEC-distribution.md", DocsRoot::Docs),
227 Utf8PathBuf::from("docs/specs/SPEC-distribution.md")
228 );
229 assert_eq!(
230 resolve_destination(
231 ".spec-driven-docs/markdownlint/x.jsonc",
232 DocsRoot::UnderscoreDocs
233 ),
234 Utf8PathBuf::from(".spec-driven-docs/markdownlint/x.jsonc")
235 );
236 }
237
238 #[test]
239 fn serde_uses_the_kebab_names() {
240 assert_eq!(
241 serde_json::to_string(&ProfileId::KnowledgeBase).unwrap(),
242 "\"knowledge-base\""
243 );
244 assert_eq!(
245 serde_json::to_string(&DocsRoot::UnderscoreDocs).unwrap(),
246 "\"_docs\""
247 );
248 }
249
250 #[test]
251 fn destination_templates_only_use_the_placeholder_in_adopted_paths() {
252 for entry in MANAGED {
253 assert!(
254 !entry.destination.contains('{'),
255 "{} is templated",
256 entry.destination
257 );
258 }
259 for entry in ADOPTED {
260 assert!(
261 entry.destination.starts_with("{docs_root}/"),
262 "{} is not rooted",
263 entry.destination
264 );
265 }
266 }
267}