Skip to main content

rac_engine/
identity.rs

1//! Artifact identity (`decided.core.identity`), per PORT-CONTRACT.d/04 §3.
2//!
3//! Precedence: frontmatter id -> `## ID` section -> `spec.id_field` (dead
4//! today) -> filename-stem `^[A-Za-z]+-\d+` prefix -> whole stem. Pathlib
5//! `stem` semantics and Unicode `\d` are replicated exactly.
6
7use crate::parse::Artifact;
8use crate::pycompat::{is_re_digit, py_casefold, py_is_space, py_splitlines, py_strip};
9use crate::spec::ArtifactSpec;
10
11/// Strip ONE leading well-formed Markdown list marker
12/// (`^(?:[-*+]|\d+\.)\s+`, Unicode `\d`/`\s`) from `s`, or return it as-is.
13pub fn strip_list_marker(s: &str) -> &str {
14    let mut chars = s.char_indices();
15    let Some((_, first)) = chars.next() else {
16        return s;
17    };
18    let after_marker = if matches!(first, '-' | '*' | '+') {
19        first.len_utf8()
20    } else if is_re_digit(first) {
21        // \d+ then a literal '.'
22        let mut end = first.len_utf8();
23        let mut rest = s[end..].char_indices();
24        loop {
25            match rest.next() {
26                Some((_, c)) if is_re_digit(c) => end += c.len_utf8(),
27                Some((_, '.')) => {
28                    end += 1;
29                    break;
30                }
31                _ => return s,
32            }
33        }
34        end
35    } else {
36        return s;
37    };
38    // \s+ — at least one Python-whitespace char.
39    let tail = &s[after_marker..];
40    let trimmed = tail.trim_start_matches(py_is_space);
41    if trimmed.len() == tail.len() {
42        return s; // no whitespace after the marker -> no match
43    }
44    trimmed
45}
46
47/// identity's `_first_value(body)`: first non-empty stripped line, one
48/// leading list marker stripped, stripped again.
49pub fn first_value_list_stripped(body: Option<&str>) -> String {
50    let Some(body) = body else {
51        return String::new();
52    };
53    if body.is_empty() {
54        return String::new();
55    }
56    for line in py_splitlines(body) {
57        let stripped = py_strip(line);
58        if !stripped.is_empty() {
59            return py_strip(strip_list_marker(stripped)).to_string();
60        }
61    }
62    String::new()
63}
64
65/// `Path(path).stem` — pathlib semantics: final component (trailing slashes
66/// and `.` segments dropped), minus the last suffix, where a suffix exists
67/// only when the final dot is neither at index 0 nor the last char.
68pub fn path_stem(path: &str) -> String {
69    let name = path
70        .split('/')
71        .rfind(|p| !p.is_empty() && *p != ".")
72        .unwrap_or("");
73    match name.rfind('.') {
74        Some(i) if i > 0 && i < name.len() - 1 => name[..i].to_string(),
75        _ => name.to_string(),
76    }
77}
78
79/// `_ID_PREFIX_RE = ^[A-Za-z]+-\d+` (ASCII letters, Unicode digits) —
80/// returns the matched prefix (`.group(0)`) or None.
81pub fn id_prefix(stem: &str) -> Option<&str> {
82    let bytes = stem.as_bytes();
83    let mut i = 0;
84    while i < bytes.len() && bytes[i].is_ascii_alphabetic() {
85        i += 1;
86    }
87    if i == 0 || i >= bytes.len() || bytes[i] != b'-' {
88        return None;
89    }
90    // \d+ — a contiguous, greedy digit run from digits_start.
91    let digits_start = i + 1;
92    let mut run_end = digits_start;
93    for c in stem[digits_start..].chars() {
94        if is_re_digit(c) {
95            run_end += c.len_utf8();
96        } else {
97            break;
98        }
99    }
100    if run_end == digits_start {
101        None
102    } else {
103        Some(&stem[..run_end])
104    }
105}
106
107/// `_legacy_identifier(product, spec)`: `## ID` first value, then
108/// `spec.id_field` (no spec sets it — ported for fidelity).
109fn legacy_identifier(artifact: &Artifact, spec: Option<&ArtifactSpec>) -> String {
110    let explicit = first_value_list_stripped(artifact.section("id"));
111    if !explicit.is_empty() {
112        return explicit;
113    }
114    if let Some(spec) = spec {
115        if let Some(field) = &spec.id_field {
116            if !field.is_empty() {
117                let declared = first_value_list_stripped(artifact.section(field));
118                if !declared.is_empty() {
119                    return declared;
120                }
121            }
122        }
123    }
124    String::new()
125}
126
127fn metadata_id(artifact: &Artifact) -> Option<&str> {
128    artifact
129        .metadata
130        .as_ref()
131        .and_then(|m| m.id.as_deref())
132        .filter(|s| !s.is_empty())
133}
134
135/// `artifact_identifier(product, spec, path)`.
136pub fn artifact_identifier(artifact: &Artifact, spec: Option<&ArtifactSpec>, path: &str) -> String {
137    if let Some(id) = metadata_id(artifact) {
138        return id.to_string();
139    }
140    let legacy = legacy_identifier(artifact, spec);
141    if !legacy.is_empty() {
142        return legacy;
143    }
144    let stem = path_stem(path);
145    match id_prefix(&stem) {
146        Some(prefix) => prefix.to_string(),
147        None => stem,
148    }
149}
150
151/// `artifact_identifiers(product, spec, path)`: canonical first, then legacy
152/// aliases, de-duplicated case-insensitively (casefold).
153pub fn artifact_identifiers(
154    artifact: &Artifact,
155    spec: Option<&ArtifactSpec>,
156    path: &str,
157) -> Vec<String> {
158    let mut ids: Vec<String> = Vec::new();
159    let mut folded: Vec<String> = Vec::new();
160    let add = |value: String, ids: &mut Vec<String>, folded: &mut Vec<String>| {
161        if value.is_empty() {
162            return;
163        }
164        let f = py_casefold(&value);
165        if folded.contains(&f) {
166            return;
167        }
168        folded.push(f);
169        ids.push(value);
170    };
171    if let Some(id) = metadata_id(artifact) {
172        add(id.to_string(), &mut ids, &mut folded);
173    }
174    add(legacy_identifier(artifact, spec), &mut ids, &mut folded);
175    let stem = path_stem(path);
176    if let Some(prefix) = id_prefix(&stem) {
177        add(prefix.to_string(), &mut ids, &mut folded);
178    }
179    add(stem, &mut ids, &mut folded);
180    ids
181}
182
183/// `identity_conflict(product, spec)` -> `(frontmatter_id, legacy_id)`.
184pub fn identity_conflict(
185    artifact: &Artifact,
186    spec: Option<&ArtifactSpec>,
187) -> Option<(String, String)> {
188    let fm_id = metadata_id(artifact)?;
189    let legacy = legacy_identifier(artifact, spec);
190    if legacy.is_empty() {
191        return None;
192    }
193    // Compared as `legacy.strip().upper() == metadata.id` (upper, not
194    // casefold — the asymmetry is deliberate, PORT-CONTRACT.d/04 §3.4).
195    if py_strip(&legacy).to_uppercase() == fm_id {
196        return None;
197    }
198    Some((fm_id.to_string(), legacy))
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn stem_semantics() {
207        assert_eq!(path_stem("a.b.md"), "a.b");
208        assert_eq!(path_stem("a."), "a.");
209        assert_eq!(path_stem(".hidden"), ".hidden");
210        assert_eq!(path_stem("a/b/"), "b");
211        assert_eq!(path_stem("a..md"), "a.");
212        assert_eq!(path_stem(".md"), ".md");
213        assert_eq!(path_stem("tests/fixtures/valid/feature.md"), "feature");
214    }
215
216    #[test]
217    fn prefix_matching() {
218        assert_eq!(id_prefix("adr-004-parser-strategy"), Some("adr-004"));
219        assert_eq!(id_prefix("adr-x"), None);
220        assert_eq!(id_prefix("-004"), None);
221        assert_eq!(id_prefix("adr004"), None);
222    }
223
224    #[test]
225    fn list_marker_strip() {
226        assert_eq!(strip_list_marker("- ADR-1"), "ADR-1");
227        assert_eq!(strip_list_marker("12. x"), "x");
228        assert_eq!(strip_list_marker("-x"), "-x");
229        assert_eq!(strip_list_marker("+  y"), "y");
230    }
231}