Skip to main content

rac_engine/
rename.rs

1//! Safe artifact-id rename — `decided rename` (PORT-CONTRACT.d/16 §4).
2//!
3//! Port of `src/asdecided/services/rename.py` (`compute_rename`, `apply_rename`):
4//! the deterministic, reversible corpus-wide edit set for renaming one
5//! artifact identity. Resolution reuses the relationship validation alias
6//! index; the raw reference TEXT is the source of truth — an edit replaces
7//! exactly the `old_ref` token inside a relationship list line, preserving
8//! everything around it, and only where the line NAMES that token (a line
9//! naming a different alias of the same target is left untouched).
10
11use std::collections::HashSet;
12
13use crate::pycompat::{py_casefold, py_is_space, py_splitlines, py_strip};
14use crate::relationships::{
15    corpus_items, resolution_index_from_rows, validation_row, CorpusItem, ValidationRow,
16};
17use crate::spec::RELATIONSHIP_SECTIONS;
18
19// Stable reason codes for an invalid plan (part of the JSON contract).
20pub const REASON_OLD_NOT_FOUND: &str = "old-ref-not-found";
21pub const REASON_OLD_AMBIGUOUS: &str = "old-ref-ambiguous";
22pub const REASON_NEW_COLLIDES: &str = "new-ref-collides";
23pub const REASON_NEW_INVALID: &str = "new-ref-invalid";
24pub const REASON_OLD_FILENAME_ONLY: &str = "old-ref-filename-only";
25
26// Where the rewritten identity token lived in the target file.
27pub const IDENTITY_FRONTMATTER: &str = "frontmatter_id";
28pub const IDENTITY_ID_SECTION: &str = "id_section";
29pub const IDENTITY_ID_FIELD: &str = "id_field";
30
31pub const KIND_REFERENCE: &str = "reference";
32pub const KIND_IDENTITY: &str = "identity";
33
34/// One line-level replacement (1-based `line`; exact line text without the
35/// trailing newline).
36pub struct RenameEdit {
37    pub path: String,
38    pub line: i64,
39    pub old_line: String,
40    pub new_line: String,
41    pub kind: &'static str,
42}
43
44/// A deterministic, reversible corpus-wide rename edit set (ADR-007).
45pub struct RenamePlan {
46    pub directory: String,
47    pub recursive: bool,
48    pub old_ref: String,
49    pub new_ref: String,
50    pub ok: bool,
51    pub target_path: Option<String>,
52    pub identity_field: Option<&'static str>,
53    pub reason: Option<&'static str>,
54    pub edits: Vec<RenameEdit>,
55}
56
57impl RenamePlan {
58    pub fn reference_edits(&self) -> usize {
59        self.edits.iter().filter(|e| e.kind == KIND_REFERENCE).count()
60    }
61
62    pub fn identity_edits(&self) -> usize {
63        self.edits.iter().filter(|e| e.kind == KIND_IDENTITY).count()
64    }
65
66    pub fn files_changed(&self) -> usize {
67        self.edits
68            .iter()
69            .map(|e| e.path.as_str())
70            .collect::<HashSet<_>>()
71            .len()
72    }
73}
74
75/// The outcome of applying a plan to disk.
76pub struct RenameResult {
77    pub directory: String,
78    pub old_ref: String,
79    pub new_ref: String,
80    pub applied: bool,
81    pub files_changed: usize,
82    pub reference_edits: usize,
83    pub identity_edits: usize,
84    pub target_path: Option<String>,
85}
86
87fn refused(
88    directory: &str,
89    recursive: bool,
90    old_ref: &str,
91    new_ref: &str,
92    target_path: Option<String>,
93    reason: &'static str,
94) -> RenamePlan {
95    RenamePlan {
96        directory: directory.to_string(),
97        recursive,
98        old_ref: old_ref.to_string(),
99        new_ref: new_ref.to_string(),
100        ok: false,
101        target_path,
102        identity_field: None,
103        reason: Some(reason),
104        edits: Vec::new(),
105    }
106}
107
108/// `_NEW_REF_RE = ^[A-Za-z][\w.-]*$` — an ASCII letter, then Unicode word
109/// chars / `.` / `-`. (`new_ref` is stripped first, so the Python `$`
110/// trailing-newline allowance cannot fire.)
111fn valid_new_ref(new_ref: &str) -> bool {
112    let mut chars = new_ref.chars();
113    match chars.next() {
114        Some(c) if c.is_ascii_alphabetic() => {}
115        _ => return false,
116    }
117    chars.all(|c| c.is_alphanumeric() || matches!(c, '_' | '.' | '-'))
118}
119
120/// `_replace_token(text, old_ref, new_ref)` — replace the LEADING token,
121/// case-insensitively, whole-token (the next char must not be an
122/// identifier char), preserving everything after it. `old_ref` arrives
123/// pre-casefolded on the identity paths, exactly like the oracle.
124fn replace_token(text: &str, old_ref: &str, new_ref: &str) -> Option<String> {
125    let folded_old = py_casefold(old_ref);
126    // `text[: len(old_ref)]` — Python slices by CHARS and simply yields a
127    // shorter prefix when text is shorter; casefold-expansion can still
128    // match it (the deliberate length quirk the oracle documents).
129    let n = old_ref.chars().count();
130    let byte_len: usize = text.chars().take(n).map(char::len_utf8).sum();
131    if py_casefold(&text[..byte_len]) != folded_old {
132        return None;
133    }
134    let rest = &text[byte_len..];
135    if let Some(c) = rest.chars().next() {
136        if c.is_alphanumeric() || matches!(c, '_' | '-' | '.') {
137            return None;
138        }
139    }
140    Some(format!("{new_ref}{rest}"))
141}
142
143/// `_LIST_MARKER_RE = ^(\s*(?:[-*+]|\d+\.)\s+)(.*)$` — returns the byte
144/// length of group 1 (marker prefix incl. surrounding whitespace), or None.
145fn list_marker_prefix_len(raw: &str) -> Option<usize> {
146    let mut i = 0;
147    for c in raw.chars() {
148        if py_is_space(c) {
149            i += c.len_utf8();
150        } else {
151            break;
152        }
153    }
154    let rest = &raw[i..];
155    let mut marker_len = 0;
156    let mut chars = rest.chars();
157    match chars.next() {
158        Some(c @ ('-' | '*' | '+')) => marker_len += c.len_utf8(),
159        Some(c) if crate::pycompat::is_re_digit(c) => {
160            marker_len += c.len_utf8();
161            loop {
162                match rest[marker_len..].chars().next() {
163                    Some(d) if crate::pycompat::is_re_digit(d) => marker_len += d.len_utf8(),
164                    Some('.') => {
165                        marker_len += 1;
166                        break;
167                    }
168                    _ => return None,
169                }
170            }
171        }
172        _ => return None,
173    }
174    let tail = &rest[marker_len..];
175    let ws: usize = tail
176        .chars()
177        .take_while(|c| py_is_space(*c))
178        .map(char::len_utf8)
179        .sum();
180    if ws == 0 {
181        return None;
182    }
183    Some(i + marker_len + ws)
184}
185
186/// Raw file text for one corpus path. The oracle's strict
187/// `read_text(encoding="utf-8")` would traceback on invalid UTF-8; the
188/// walk already decoded these files leniently, so a lossy decode here is
189/// the same documented divergence class (stderr-only).
190fn read_raw(path: &str) -> String {
191    match std::fs::read(path) {
192        Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
193        Err(_) => String::new(),
194    }
195}
196
197/// `_relationship_reference_lines(raw_lines, sections)` — 1-based
198/// `(line_no, raw_line)` for every non-empty line inside a relevant
199/// relationship section, tracking the current `##` heading.
200fn relationship_reference_lines<'a>(
201    raw_lines: &[&'a str],
202    sections: &HashSet<String>,
203) -> Vec<(usize, &'a str)> {
204    let mut result = Vec::new();
205    let mut current: Option<String> = None;
206    for (i, raw) in raw_lines.iter().enumerate() {
207        let stripped = py_strip(raw);
208        if let Some(rest) = stripped.strip_prefix("## ") {
209            current = Some(py_casefold(py_strip(rest)));
210            continue;
211        }
212        if stripped.starts_with('#') {
213            current = None; // any other heading ends the section
214            continue;
215        }
216        if let Some(cur) = &current {
217            if sections.contains(cur) && !stripped.is_empty() {
218                result.push((i + 1, *raw));
219            }
220        }
221    }
222    result
223}
224
225/// `_reference_edits(items, target_path, old_ref, new_ref)` — every inbound
226/// relationship line whose leading reference token equals `old_ref`.
227fn reference_edits(items: &[CorpusItem], old_ref: &str, new_ref: &str) -> Vec<RenameEdit> {
228    let mut edits = Vec::new();
229    for item in items {
230        let Some(spec) = item.spec else { continue };
231        let present: HashSet<String> = spec
232            .optional
233            .iter()
234            .filter(|section| {
235                RELATIONSHIP_SECTIONS.iter().any(|(name, _)| name == section)
236                    && item
237                        .artifact
238                        .section(section)
239                        .map(|body| !body.is_empty())
240                        .unwrap_or(false)
241            })
242            .cloned()
243            .collect();
244        if present.is_empty() {
245            continue;
246        }
247        let raw = read_raw(&item.path);
248        let raw_lines = py_splitlines(&raw);
249        for (line_no, raw_line) in relationship_reference_lines(&raw_lines, &present) {
250            let prefix_len = list_marker_prefix_len(raw_line).unwrap_or(0);
251            let ref_text = &raw_line[prefix_len..];
252            let Some(rewritten) = replace_token(py_strip(ref_text), old_ref, new_ref) else {
253                continue;
254            };
255            // Preserve the marker and surrounding whitespace by rebuilding
256            // only the reference portion (first occurrence in the tail).
257            let new_line = format!(
258                "{}{}",
259                &raw_line[..prefix_len],
260                raw_line[prefix_len..].replacen(py_strip(ref_text), &rewritten, 1)
261            );
262            if new_line != raw_line {
263                edits.push(RenameEdit {
264                    path: item.path.clone(),
265                    line: line_no as i64,
266                    old_line: raw_line.to_string(),
267                    new_line,
268                    kind: KIND_REFERENCE,
269                });
270            }
271        }
272    }
273    edits
274}
275
276/// One matched frontmatter `id:` line: `(g1 prefix, g2 quote, g3 value,
277/// g5 suffix)` per `_FRONTMATTER_ID_RE` — value may be quoted, a trailing
278/// `#` comment is preserved.
279fn frontmatter_id_line(line: &str) -> Option<(String, String, String, String)> {
280    let mut i = 0;
281    for c in line.chars() {
282        if py_is_space(c) {
283            i += c.len_utf8();
284        } else {
285            break;
286        }
287    }
288    let after_ws = &line[i..];
289    if !after_ws.starts_with("id") {
290        return None;
291    }
292    i += 2;
293    for c in line[i..].chars() {
294        if py_is_space(c) {
295            i += c.len_utf8();
296        } else {
297            break;
298        }
299    }
300    if !line[i..].starts_with(':') {
301        return None;
302    }
303    i += 1;
304    for c in line[i..].chars() {
305        if py_is_space(c) {
306            i += c.len_utf8();
307        } else {
308            break;
309        }
310    }
311    let g1 = line[..i].to_string();
312    let rest = &line[i..];
313    let quote = match rest.chars().next() {
314        Some(q @ ('\'' | '"')) => Some(q),
315        _ => None,
316    };
317    if let Some(q) = quote {
318        // Quoted: value is [^'"#]+ up to the SAME quote, then ws + optional
319        // comment. Any quote or '#' inside the value fails the whole match.
320        let body = &rest[q.len_utf8()..];
321        let mut vlen = 0;
322        let mut closed = false;
323        for c in body.chars() {
324            if c == q {
325                closed = true;
326                break;
327            }
328            if matches!(c, '\'' | '"' | '#') {
329                return None;
330            }
331            vlen += c.len_utf8();
332        }
333        if !closed || vlen == 0 {
334            return None;
335        }
336        let after = &body[vlen + q.len_utf8()..];
337        if !ws_then_optional_comment(after) {
338            return None;
339        }
340        Some((g1, q.to_string(), body[..vlen].to_string(), after.to_string()))
341    } else {
342        // Unquoted: a quote anywhere before the comment fails; the lazy
343        // group pushes trailing whitespace into the suffix.
344        let mut vlen = 0;
345        let mut has_hash = false;
346        for c in rest.chars() {
347            if c == '#' {
348                has_hash = true;
349                break;
350            }
351            if matches!(c, '\'' | '"') {
352                return None;
353            }
354            vlen += c.len_utf8();
355        }
356        let run = &rest[..vlen];
357        let value = run.trim_end_matches(py_is_space);
358        if value.is_empty() {
359            // A pure-whitespace value strips to "", which can never equal a
360            // non-empty old_ref — no edit either way.
361            return None;
362        }
363        let suffix = format!(
364            "{}{}",
365            &run[value.len()..],
366            if has_hash { &rest[vlen..] } else { "" }
367        );
368        Some((g1, String::new(), value.to_string(), suffix))
369    }
370}
371
372fn ws_then_optional_comment(s: &str) -> bool {
373    let rest = s.trim_start_matches(py_is_space);
374    rest.is_empty() || rest.starts_with('#')
375}
376
377/// Partial edit: (1-based line, old text, new text).
378type LineEdit = (usize, String, String);
379
380/// `_frontmatter_id_edit(raw_lines, old_ref, new_ref)` — rewrite the value
381/// of the `id:` line inside the LEADING `---` block.
382fn frontmatter_id_edit(raw_lines: &[&str], old_ref: &str, new_ref: &str) -> Option<LineEdit> {
383    if raw_lines.first().map(|l| py_strip(l)) != Some("---") {
384        return None;
385    }
386    for (i, raw) in raw_lines.iter().enumerate().skip(1) {
387        if py_strip(raw) == "---" {
388            break;
389        }
390        if let Some((g1, g2, g3, g5)) = frontmatter_id_line(raw) {
391            if py_casefold(py_strip(&g3)) == py_casefold(old_ref) {
392                let new_line = format!("{g1}{g2}{new_ref}{g2}{g5}");
393                if new_line != *raw {
394                    return Some((i + 1, (*raw).to_string(), new_line));
395                }
396            }
397        }
398    }
399    None
400}
401
402/// `^\s*##\s+<name>\s*$` (IGNORECASE) — the `## ID` / `## <id_field>`
403/// heading matcher.
404fn heading_matches(raw: &str, name: &str) -> bool {
405    let after_ws = raw.trim_start_matches(py_is_space);
406    let Some(rest) = after_ws.strip_prefix("##") else {
407        return false;
408    };
409    let after_hash_ws = rest.trim_start_matches(py_is_space);
410    if after_hash_ws.len() == rest.len() {
411        return false; // \s+ requires at least one space after ##
412    }
413    let n = name.chars().count();
414    let byte_len: usize = after_hash_ws.chars().take(n).map(char::len_utf8).sum();
415    if after_hash_ws.chars().count() < n
416        || py_casefold(&after_hash_ws[..byte_len]) != py_casefold(name)
417    {
418        return false;
419    }
420    after_hash_ws[byte_len..].chars().all(py_is_space)
421}
422
423/// `_section_first_value_edit` — rewrite the first value line under a
424/// matching heading; only the FIRST value line of each matching section is
425/// the identity (scanning continues after a non-rewriting first value).
426fn section_first_value_edit(
427    raw_lines: &[&str],
428    section_name: &str,
429    folded_old: &str,
430    new_ref: &str,
431) -> Option<LineEdit> {
432    let mut in_section = false;
433    for (i, raw) in raw_lines.iter().enumerate() {
434        let stripped = py_strip(raw);
435        if stripped.starts_with('#') {
436            in_section = heading_matches(raw, section_name);
437            continue;
438        }
439        if !in_section || stripped.is_empty() {
440            continue;
441        }
442        let prefix_len = list_marker_prefix_len(raw).unwrap_or(0);
443        let value = &raw[prefix_len..];
444        let rewritten = replace_token(py_strip(value), folded_old, new_ref);
445        in_section = false; // only the first value line is the identity
446        let Some(rewritten) = rewritten else { continue };
447        let new_line = format!(
448            "{}{}",
449            &raw[..prefix_len],
450            raw[prefix_len..].replacen(py_strip(value), &rewritten, 1)
451        );
452        if new_line != *raw {
453            return Some((i + 1, (*raw).to_string(), new_line));
454        }
455    }
456    None
457}
458
459/// `_identity_edit(target_path, product, spec, old_ref, new_ref)` —
460/// `(edit, identity_field)` on success, or the filename-only refusal.
461fn identity_edit(
462    item: &CorpusItem,
463    old_ref: &str,
464    new_ref: &str,
465) -> Result<(LineEdit, &'static str), &'static str> {
466    let raw = read_raw(&item.path);
467    let raw_lines = py_splitlines(&raw);
468    let folded_old = py_casefold(old_ref);
469
470    // 1. Canonical frontmatter `id` — only when old_ref IS it.
471    if let Some(meta) = &item.artifact.metadata {
472        if let Some(id) = meta.id.as_deref().filter(|s| !s.is_empty()) {
473            if py_casefold(id) == folded_old {
474                if let Some(edit) = frontmatter_id_edit(&raw_lines, old_ref, new_ref) {
475                    return Ok((edit, IDENTITY_FRONTMATTER));
476                }
477            }
478        }
479    }
480
481    // 2. `## ID` section value.
482    if let Some(edit) = section_first_value_edit(&raw_lines, "id", &folded_old, new_ref) {
483        return Ok((edit, IDENTITY_ID_SECTION));
484    }
485
486    // 3. The type's `spec.id_field` section (no spec sets one — ported for
487    //    fidelity, dead today).
488    if let Some(spec) = item.spec {
489        if let Some(field) = spec.id_field.as_deref().filter(|f| !f.is_empty()) {
490            if let Some(edit) = section_first_value_edit(&raw_lines, field, &folded_old, new_ref) {
491                return Ok((edit, IDENTITY_ID_FIELD));
492            }
493        }
494    }
495
496    // 4. Filename-derived alias only — nothing editable in-file.
497    Err(REASON_OLD_FILENAME_ONLY)
498}
499
500/// `compute_rename(directory, old_ref, new_ref, recursive)`.
501pub fn compute_rename(
502    directory: &str,
503    old_ref: &str,
504    new_ref: &str,
505    recursive: bool,
506) -> RenamePlan {
507    let new_ref = py_strip(new_ref).to_string();
508    if !valid_new_ref(&new_ref) {
509        return refused(directory, recursive, old_ref, &new_ref, None, REASON_NEW_INVALID);
510    }
511
512    let items = corpus_items(directory, recursive);
513    let rows: Vec<ValidationRow> = items
514        .iter()
515        .map(|item| validation_row(&item.path, &item.artifact, item.spec))
516        .collect();
517    let index = resolution_index_from_rows(&rows);
518    let mut targets: Vec<&str> = index
519        .get(&py_casefold(old_ref))
520        .iter()
521        .map(|(path, _)| path.as_str())
522        .collect::<HashSet<_>>()
523        .into_iter()
524        .collect();
525    targets.sort_unstable();
526    let target_path = match targets.as_slice() {
527        [] => {
528            return refused(directory, recursive, old_ref, &new_ref, None, REASON_OLD_NOT_FOUND)
529        }
530        [one] => (*one).to_string(),
531        _ => {
532            return refused(directory, recursive, old_ref, &new_ref, None, REASON_OLD_AMBIGUOUS)
533        }
534    };
535
536    // A no-op rename (new == old case-insensitively) skips the collision
537    // check; otherwise a `new_ref` naming ANOTHER artifact refuses.
538    if py_casefold(&new_ref) != py_casefold(old_ref) {
539        let folded_new = py_casefold(&new_ref);
540        let collides = rows.iter().any(|row| {
541            row.path != target_path
542                && row.identifiers.iter().any(|i| py_casefold(i) == folded_new)
543        });
544        if collides {
545            return refused(
546                directory,
547                recursive,
548                old_ref,
549                &new_ref,
550                Some(target_path),
551                REASON_NEW_COLLIDES,
552            );
553        }
554    }
555
556    let target_item = items
557        .iter()
558        .find(|item| item.path == target_path)
559        .expect("resolved target is in the walked corpus");
560    let (identity, identity_field) = match identity_edit(target_item, old_ref, &new_ref) {
561        Ok(pair) => pair,
562        Err(reason) => {
563            return refused(directory, recursive, old_ref, &new_ref, Some(target_path), reason)
564        }
565    };
566
567    let mut edits = reference_edits(&items, old_ref, &new_ref);
568    let (line, old_line, new_line) = identity;
569    edits.push(RenameEdit {
570        path: target_path.clone(),
571        line: line as i64,
572        old_line,
573        new_line,
574        kind: KIND_IDENTITY,
575    });
576    edits.sort_by(|a, b| (a.path.as_str(), a.line).cmp(&(b.path.as_str(), b.line)));
577
578    RenamePlan {
579        directory: directory.to_string(),
580        recursive,
581        old_ref: old_ref.to_string(),
582        new_ref,
583        ok: true,
584        target_path: Some(target_path),
585        identity_field: Some(identity_field),
586        reason: None,
587        edits,
588    }
589}
590
591/// `apply_rename(plan)` — exact line replacements, original final-newline
592/// shape preserved. A stale plan (the file changed since it was computed)
593/// is the oracle's uncaught `ValueError` traceback (exit 1); surfaced here
594/// as `Err(message)` for the command to fail with the same code.
595pub fn apply_rename(plan: &RenamePlan) -> Result<RenameResult, String> {
596    if !plan.ok {
597        return Ok(RenameResult {
598            directory: plan.directory.clone(),
599            old_ref: plan.old_ref.clone(),
600            new_ref: plan.new_ref.clone(),
601            applied: false,
602            files_changed: 0,
603            reference_edits: 0,
604            identity_edits: 0,
605            target_path: plan.target_path.clone(),
606        });
607    }
608
609    // Group by path, first-seen order (Python dict setdefault).
610    let mut order: Vec<&str> = Vec::new();
611    for edit in &plan.edits {
612        if !order.contains(&edit.path.as_str()) {
613            order.push(&edit.path);
614        }
615    }
616    for path in order {
617        let original = std::fs::read_to_string(path)
618            .map_err(|e| format!("rename: cannot read {path}: {e}"))?;
619        let had_final_newline = original.ends_with('\n');
620        let mut lines: Vec<String> = py_splitlines(&original)
621            .into_iter()
622            .map(str::to_string)
623            .collect();
624        for edit in plan.edits.iter().filter(|e| e.path == path) {
625            let idx = edit.line - 1;
626            let in_range = idx >= 0 && (idx as usize) < lines.len();
627            if !in_range || lines[idx as usize] != edit.old_line {
628                return Err(format!(
629                    "rename: stale plan for {path} line {}: file changed since the plan was computed",
630                    edit.line
631                ));
632            }
633            lines[idx as usize] = edit.new_line.clone();
634        }
635        let mut text = lines.join("\n");
636        if had_final_newline {
637            text.push('\n');
638        }
639        std::fs::write(path, text).map_err(|e| format!("rename: cannot write {path}: {e}"))?;
640    }
641
642    Ok(RenameResult {
643        directory: plan.directory.clone(),
644        old_ref: plan.old_ref.clone(),
645        new_ref: plan.new_ref.clone(),
646        applied: true,
647        files_changed: plan.files_changed(),
648        reference_edits: plan.reference_edits(),
649        identity_edits: plan.identity_edits(),
650        target_path: plan.target_path.clone(),
651    })
652}
653
654#[cfg(test)]
655mod tests {
656    use super::*;
657
658    #[test]
659    fn new_ref_grammar() {
660        assert!(valid_new_ref("ADR-099"));
661        assert!(valid_new_ref("RAC-ZZZZZZZZZZZZ"));
662        assert!(valid_new_ref("a.b-c_d1"));
663        assert!(!valid_new_ref(""));
664        assert!(!valid_new_ref("1AD"));
665        assert!(!valid_new_ref("bad id!"));
666        assert!(!valid_new_ref("-x"));
667    }
668
669    #[test]
670    fn token_replacement_is_whole_token_and_case_insensitive() {
671        assert_eq!(
672            replace_token("ADR-001 (blocked)", "adr-001", "ADR-099").as_deref(),
673            Some("ADR-099 (blocked)")
674        );
675        assert_eq!(replace_token("ADR-10", "ADR-1", "X"), None);
676        assert_eq!(replace_token("ADR-1.5", "ADR-1", "X"), None);
677        assert_eq!(replace_token("zzz", "ADR-1", "X"), None);
678    }
679
680    #[test]
681    fn frontmatter_id_line_shapes() {
682        assert_eq!(
683            frontmatter_id_line("id: RAC-A"),
684            Some(("id: ".into(), "".into(), "RAC-A".into(), "".into()))
685        );
686        assert_eq!(
687            frontmatter_id_line("  id:  'RAC-A'  # note"),
688            Some(("  id:  ".into(), "'".into(), "RAC-A".into(), "  # note".into()))
689        );
690        assert_eq!(frontmatter_id_line("id: 'RAC"), None);
691        // "ident" begins with the literal `id`, but the regex then demands
692        // `\s*:` and finds 'e' — no match. Same for a missing colon.
693        assert_eq!(frontmatter_id_line("ident: RAC-A"), None);
694        assert_eq!(frontmatter_id_line("id RAC-A"), None);
695    }
696}