1use serde::Deserialize;
9
10use super::{Check, CheckKind, Decision, Status};
11
12#[derive(Deserialize)]
13#[serde(deny_unknown_fields)]
14struct RawDecision {
15 id: String,
16 title: String,
17 statement: String,
18 #[serde(default)]
19 rationale: String,
20 status: RawStatus,
21 #[serde(default)]
22 supersedes: Vec<String>,
23 decided_by: RawDecidedBy,
24 decided_at: RawDate,
25 #[serde(default)]
26 source: Option<RawSource>,
27 #[serde(default)]
28 scope: RawScope,
29 #[serde(default, rename = "check")]
30 checks: Vec<RawCheck>,
31}
32
33#[derive(Deserialize, Clone, Copy)]
34#[serde(rename_all = "lowercase")]
35enum RawStatus {
36 Active,
37 Superseded,
38 Revoked,
39}
40
41#[derive(Deserialize, Clone, Copy)]
42#[serde(rename_all = "lowercase")]
43enum RawDecidedBy {
44 Owner,
45 Team,
46 Review,
47}
48
49#[derive(Deserialize)]
50#[serde(untagged)]
51enum RawDate {
52 Text(String),
53 Toml(toml::value::Datetime),
54}
55
56#[derive(Deserialize)]
57#[serde(deny_unknown_fields)]
58struct RawSource {
59 kind: RawSourceKind,
60 #[serde(rename = "ref")]
61 reference: String,
62}
63
64#[derive(Deserialize, Clone, Copy)]
65#[serde(rename_all = "kebab-case")]
66enum RawSourceKind {
67 OwnerAnswer,
68 Review,
69 Mr,
70 Session,
71 Doc,
72}
73
74#[derive(Deserialize, Default)]
75#[serde(deny_unknown_fields)]
76struct RawScope {
77 #[serde(default)]
78 repos: Vec<String>,
79 #[serde(default)]
80 paths: Vec<String>,
81 #[serde(default)]
82 symbols: Vec<String>,
83}
84
85#[derive(Deserialize)]
86#[serde(deny_unknown_fields)]
87struct RawCheck {
88 kind: RawCheckKind,
89 #[serde(default)]
90 paths: Vec<String>,
91 #[serde(default)]
92 pattern: Option<String>,
93 #[serde(default)]
94 message: Option<String>,
95}
96
97#[derive(Deserialize, Clone, Copy)]
98#[serde(rename_all = "snake_case")]
99enum RawCheckKind {
100 ForbidPath,
101 ForbidRegex,
102 RequireRegex,
103}
104
105const REGEX_SIZE_LIMIT: usize = 1 << 20;
108
109fn valid_id(id: &str) -> bool {
110 let Some(rest) = id.strip_prefix("dec-") else {
111 return false;
112 };
113 !rest.is_empty()
114 && id.len() <= 80
115 && rest.split('-').all(|part| {
116 !part.is_empty()
117 && part
118 .bytes()
119 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
120 })
121}
122
123fn valid_date(date: &str) -> bool {
124 chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d").is_ok() && date.len() == 10
125}
126
127fn compile_globs(field: &str, globs: &[String]) -> Result<Vec<glob::Pattern>, String> {
128 globs
129 .iter()
130 .enumerate()
131 .map(|(i, g)| {
132 if g.trim().is_empty() {
133 return Err(format!("{field}[{i}]: empty glob"));
134 }
135 if g.starts_with('/') || g.split('/').any(|c| c == "..") {
136 return Err(format!(
137 "{field}[{i}] \"{g}\": globs are relative to the project root (no leading `/`, no `..`)"
138 ));
139 }
140 glob::Pattern::new(g).map_err(|e| format!("{field}[{i}] \"{g}\": invalid glob: {e}"))
141 })
142 .collect()
143}
144
145fn nonempty_list(field: &str, values: &[String]) -> Result<(), String> {
146 match values.iter().position(|v| v.trim().is_empty()) {
147 Some(i) => Err(format!("{field}[{i}]: empty value")),
148 None => Ok(()),
149 }
150}
151
152fn compile_check(i: usize, raw: RawCheck) -> Result<Check, String> {
153 let kind = match raw.kind {
154 RawCheckKind::ForbidPath => CheckKind::ForbidPath,
155 RawCheckKind::ForbidRegex => CheckKind::ForbidRegex,
156 RawCheckKind::RequireRegex => CheckKind::RequireRegex,
157 };
158 let globs = compile_globs(&format!("check[{i}].paths"), &raw.paths)?;
159 let regex = match (kind, raw.pattern.as_deref()) {
160 (CheckKind::ForbidPath, Some(_)) => {
161 return Err(format!(
162 "check[{i}]: `pattern` is not used by kind forbid_path (it forbids the paths themselves)"
163 ));
164 }
165 (CheckKind::ForbidPath, None) => None,
166 (_, None) => {
167 return Err(format!(
168 "check[{i}]: kind {} requires `pattern`",
169 kind.as_str()
170 ));
171 }
172 (_, Some(p)) => Some(
173 regex::RegexBuilder::new(p)
174 .size_limit(REGEX_SIZE_LIMIT)
175 .build()
176 .map_err(|e| format!("check[{i}].pattern: invalid regex: {e}"))?,
177 ),
178 };
179 if raw.message.as_deref().is_some_and(|m| m.trim().is_empty()) {
180 return Err(format!(
181 "check[{i}].message: empty (omit it to use the statement)"
182 ));
183 }
184 Ok(Check {
185 kind,
186 paths: raw.paths,
187 globs,
188 pattern: raw.pattern,
189 regex,
190 message: raw.message,
191 })
192}
193
194pub fn parse_decision(text: &str, file_stem: &str, rel_file: &str) -> Result<Decision, String> {
197 let raw: RawDecision = toml::from_str(text).map_err(|e| {
198 e.to_string()
200 .split_whitespace()
201 .collect::<Vec<_>>()
202 .join(" ")
203 })?;
204 if !valid_id(&raw.id) {
205 return Err(format!(
206 "id `{}`: must be kebab-case with prefix `dec-` (e.g. dec-no-macos-gate)",
207 raw.id
208 ));
209 }
210 if raw.id != file_stem {
211 return Err(format!(
212 "id `{}` does not match the file name (expected {}.toml)",
213 raw.id, raw.id
214 ));
215 }
216 for (field, value) in [("title", &raw.title), ("statement", &raw.statement)] {
217 if value.trim().is_empty() {
218 return Err(format!("{field}: must not be empty"));
219 }
220 }
221 if let Some(bad) = raw.supersedes.iter().find(|s| !valid_id(s)) {
222 return Err(format!("supersedes: `{bad}` is not a decision id"));
223 }
224 if raw.supersedes.iter().any(|s| s == &raw.id) {
225 return Err("supersedes: a decision cannot supersede itself".to_string());
226 }
227 let decided_at = match raw.decided_at {
228 RawDate::Text(s) => s,
229 RawDate::Toml(d) => d.to_string(),
230 };
231 if !valid_date(&decided_at) {
232 return Err(format!("decided_at `{decided_at}`: expected YYYY-MM-DD"));
233 }
234 let (source_kind, source_ref) = match raw.source {
235 Some(s) => {
236 if s.reference.trim().is_empty() {
237 return Err("source.ref: must not be empty".to_string());
238 }
239 let kind = match s.kind {
240 RawSourceKind::OwnerAnswer => "owner-answer",
241 RawSourceKind::Review => "review",
242 RawSourceKind::Mr => "mr",
243 RawSourceKind::Session => "session",
244 RawSourceKind::Doc => "doc",
245 };
246 (Some(kind.to_string()), Some(s.reference))
247 }
248 None => (None, None),
249 };
250 nonempty_list("scope.repos", &raw.scope.repos)?;
251 nonempty_list("scope.symbols", &raw.scope.symbols)?;
252 let path_globs = compile_globs("scope.paths", &raw.scope.paths)?;
253 let checks = raw
254 .checks
255 .into_iter()
256 .enumerate()
257 .map(|(i, c)| compile_check(i, c))
258 .collect::<Result<Vec<_>, _>>()?;
259 Ok(Decision {
260 id: raw.id,
261 title: raw.title,
262 statement: raw.statement,
263 rationale: raw.rationale,
264 status: match raw.status {
265 RawStatus::Active => Status::Active,
266 RawStatus::Superseded => Status::Superseded,
267 RawStatus::Revoked => Status::Revoked,
268 },
269 supersedes: raw.supersedes,
270 decided_by: match raw.decided_by {
271 RawDecidedBy::Owner => "owner",
272 RawDecidedBy::Team => "team",
273 RawDecidedBy::Review => "review",
274 }
275 .to_string(),
276 decided_at,
277 source_kind,
278 source_ref,
279 repos: raw.scope.repos,
280 paths: raw.scope.paths,
281 path_globs,
282 symbols: raw.scope.symbols,
283 checks,
284 file: rel_file.to_string(),
285 })
286}
287
288pub fn scaffold(id: &str, title: &str, today: &str) -> String {
290 let title = title.replace('\\', "\\\\").replace('"', "\\\"");
291 format!(
292 "id = \"{id}\"\n\
293 title = \"{title}\"\n\
294 statement = \"{title}.\"\n\
295 rationale = \"\"\n\
296 status = \"active\" # active | superseded | revoked\n\
297 supersedes = [] # ids\n\
298 decided_by = \"team\" # owner | team | review\n\
299 decided_at = \"{today}\"\n\
300 \n\
301 [source] # where it was decided\n\
302 kind = \"doc\" # owner-answer | review | mr | session | doc\n\
303 ref = \".leanctx/decisions/{id}.toml\"\n\
304 \n\
305 [scope]\n\
306 repos = [] # empty = all\n\
307 paths = [] # globs; empty = repo-wide\n\
308 symbols = []\n\
309 \n\
310 # Optional, machine-checkable; may be several.\n\
311 # [[check]]\n\
312 # kind = \"forbid_regex\" # forbid_path | forbid_regex | require_regex\n\
313 # paths = [\"src/**\"]\n\
314 # pattern = \"(?m)^.*TODO\"\n\
315 # message = \"why this is not allowed\"\n"
316 )
317}
318
319pub fn id_from_slug(slug: &str) -> Option<String> {
321 let slug = slug.trim().strip_prefix("dec-").unwrap_or(slug.trim());
322 let words: Vec<String> = slug
323 .split(|c: char| !c.is_ascii_alphanumeric())
324 .filter(|w| !w.is_empty())
325 .map(str::to_ascii_lowercase)
326 .collect();
327 let id = format!("dec-{}", words.join("-"));
328 valid_id(&id).then_some(id)
329}