1use crate::parse::Artifact;
8use crate::pycompat::{is_re_digit, py_casefold, py_is_space, py_splitlines, py_strip};
9use crate::spec::ArtifactSpec;
10
11pub 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 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 let tail = &s[after_marker..];
40 let trimmed = tail.trim_start_matches(py_is_space);
41 if trimmed.len() == tail.len() {
42 return s; }
44 trimmed
45}
46
47pub 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
65pub 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
79pub 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 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
107fn 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
135pub 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
151pub 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
183pub 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 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}