spec_driven_docs/domain/
profile.rs1use std::fmt;
10use std::sync::LazyLock;
11
12use camino::Utf8PathBuf;
13use clap::ValueEnum;
14use serde::{Deserialize, Serialize};
15
16pub use crate::domain::projection::Projection;
17use crate::domain::projection::{DECLARATION_PATH, Declaration};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum, Serialize, Deserialize)]
21#[value(rename_all = "kebab-case")]
22#[serde(rename_all = "kebab-case")]
23pub enum ProfileId {
24 Codebase,
26 KnowledgeBase,
28}
29
30pub const EVERY_PROFILE: [ProfileId; 2] = [ProfileId::Codebase, ProfileId::KnowledgeBase];
32
33impl ProfileId {
34 pub fn every() -> impl Iterator<Item = Self> {
36 EVERY_PROFILE.into_iter()
37 }
38
39 #[must_use]
44 pub fn profile(self) -> &'static Profile<'static> {
45 match self {
46 Self::Codebase => &CODEBASE,
47 Self::KnowledgeBase => &KNOWLEDGE_BASE,
48 }
49 }
50
51 #[must_use]
53 pub const fn as_str(self) -> &'static str {
54 match self {
55 Self::Codebase => "codebase",
56 Self::KnowledgeBase => "knowledge-base",
57 }
58 }
59}
60
61impl fmt::Display for ProfileId {
62 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63 f.write_str(self.as_str())
64 }
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
69pub enum DocsRoot {
70 #[serde(rename = "docs")]
72 Docs,
73 #[serde(rename = "_docs")]
75 UnderscoreDocs,
76}
77
78impl DocsRoot {
79 #[must_use]
81 pub const fn as_str(self) -> &'static str {
82 match self {
83 Self::Docs => "docs",
84 Self::UnderscoreDocs => "_docs",
85 }
86 }
87}
88
89impl fmt::Display for DocsRoot {
90 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91 f.write_str(self.as_str())
92 }
93}
94
95#[must_use]
97#[allow(
98 clippy::literal_string_with_formatting_args,
99 reason = "the braces are the destination template's placeholder, not a formatting argument"
100)]
101pub fn resolve_destination(destination: &str, docs_root: DocsRoot) -> Utf8PathBuf {
102 Utf8PathBuf::from(destination.replace("{docs_root}", docs_root.as_str()))
103}
104
105#[derive(Debug, Clone, Copy)]
107pub struct Profile<'a> {
108 pub id: ProfileId,
110 pub docs_root: DocsRoot,
112 pub managed: &'a [Projection],
114 pub adopted: &'a [Projection],
116}
117
118#[expect(
124 clippy::expect_used,
125 reason = "the declaration is compiled in; a parse failure is a build defect the canon suite catches first"
126)]
127pub static DECLARATION: LazyLock<Declaration> = LazyLock::new(|| {
128 let bytes = crate::embedded::asset(DECLARATION_PATH)
129 .expect("the payload carries instance/projection.toml");
130 Declaration::parse(bytes).expect("the embedded projection declaration parses")
131});
132
133pub static CANON_TEMPLATES: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
135 DECLARATION
136 .canon_templates
137 .iter()
138 .map(String::as_str)
139 .collect()
140});
141
142static CODEBASE: LazyLock<Profile<'static>> = LazyLock::new(|| profile_of(ProfileId::Codebase));
143static KNOWLEDGE_BASE: LazyLock<Profile<'static>> =
144 LazyLock::new(|| profile_of(ProfileId::KnowledgeBase));
145
146#[expect(
148 clippy::expect_used,
149 reason = "a profile the declaration omits is the same build defect as a declaration that does not parse"
150)]
151fn profile_of(id: ProfileId) -> Profile<'static> {
152 DECLARATION
153 .profile(id)
154 .expect("the embedded declaration offers every profile")
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160
161 #[test]
162 fn profiles_bind_their_roots() {
163 assert_eq!(ProfileId::Codebase.profile().docs_root, DocsRoot::Docs);
164 assert_eq!(
165 ProfileId::KnowledgeBase.profile().docs_root,
166 DocsRoot::UnderscoreDocs
167 );
168 }
169
170 #[test]
171 fn destinations_resolve_per_root() {
172 assert_eq!(
173 resolve_destination("{docs_root}/specs/SPEC-distribution.md", DocsRoot::Docs),
174 Utf8PathBuf::from("docs/specs/SPEC-distribution.md")
175 );
176 assert_eq!(
177 resolve_destination(
178 ".spec-driven-docs/markdownlint/x.jsonc",
179 DocsRoot::UnderscoreDocs
180 ),
181 Utf8PathBuf::from(".spec-driven-docs/markdownlint/x.jsonc")
182 );
183 }
184
185 #[test]
186 fn serde_uses_the_kebab_names() {
187 assert_eq!(
188 serde_json::to_string(&ProfileId::KnowledgeBase).unwrap(),
189 "\"knowledge-base\""
190 );
191 assert_eq!(
192 serde_json::to_string(&DocsRoot::UnderscoreDocs).unwrap(),
193 "\"_docs\""
194 );
195 }
196
197 #[test]
198 fn destination_templates_only_use_the_placeholder_in_adopted_paths() {
199 let profile = ProfileId::KnowledgeBase.profile();
200 for entry in profile.managed {
201 assert!(
202 !entry.destination.contains('{'),
203 "{} is templated",
204 entry.destination
205 );
206 }
207 for entry in profile.adopted {
208 if entry.destination == crate::domain::paths::CONFIG_PATH {
212 continue;
213 }
214 assert!(
215 entry.destination.starts_with("{docs_root}/"),
216 "{} is not rooted",
217 entry.destination
218 );
219 }
220 }
221
222 #[test]
223 fn the_canon_templates_come_from_the_declaration() {
224 assert_eq!(*CANON_TEMPLATES, DECLARATION.canon_templates);
225 }
226}