Skip to main content

lean_ctx/core/decisions/
parse.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Strict parser for one `.leanctx/decisions/<id>.toml` record.
3//!
4//! Unknown keys are errors (a misspelt `scope.path` must not silently widen a
5//! decision to the whole repo), and so are invalid globs and regexes: a record
6//! that cannot be checked as written is reported, never half-applied.
7
8use 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
105/// Upper bound for a compiled check regex (bytes of program); a record is
106/// data from the repository and must not make every edit slow.
107const 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
194/// Parse and validate one record. `file_stem` must equal the record's `id`;
195/// `rel_file` is kept for reporting. Errors name the offending key.
196pub 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        // toml's message already names the key and position; keep it on one line.
199        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
288/// A fresh record for `lean-ctx decisions new` that parses as written.
289pub 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
319/// `dec-<slug>` from free text: lower-case ASCII words joined by `-`.
320pub 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}