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