use crate::parse::Artifact;
use crate::pycompat::{is_re_digit, py_casefold, py_is_space, py_splitlines, py_strip};
use crate::spec::ArtifactSpec;
pub fn strip_list_marker(s: &str) -> &str {
let mut chars = s.char_indices();
let Some((_, first)) = chars.next() else {
return s;
};
let after_marker = if matches!(first, '-' | '*' | '+') {
first.len_utf8()
} else if is_re_digit(first) {
let mut end = first.len_utf8();
let mut rest = s[end..].char_indices();
loop {
match rest.next() {
Some((_, c)) if is_re_digit(c) => end += c.len_utf8(),
Some((_, '.')) => {
end += 1;
break;
}
_ => return s,
}
}
end
} else {
return s;
};
let tail = &s[after_marker..];
let trimmed = tail.trim_start_matches(py_is_space);
if trimmed.len() == tail.len() {
return s; }
trimmed
}
pub fn first_value_list_stripped(body: Option<&str>) -> String {
let Some(body) = body else {
return String::new();
};
if body.is_empty() {
return String::new();
}
for line in py_splitlines(body) {
let stripped = py_strip(line);
if !stripped.is_empty() {
return py_strip(strip_list_marker(stripped)).to_string();
}
}
String::new()
}
pub fn path_stem(path: &str) -> String {
let name = path
.split('/')
.rfind(|p| !p.is_empty() && *p != ".")
.unwrap_or("");
match name.rfind('.') {
Some(i) if i > 0 && i < name.len() - 1 => name[..i].to_string(),
_ => name.to_string(),
}
}
pub fn id_prefix(stem: &str) -> Option<&str> {
let bytes = stem.as_bytes();
let mut i = 0;
while i < bytes.len() && bytes[i].is_ascii_alphabetic() {
i += 1;
}
if i == 0 || i >= bytes.len() || bytes[i] != b'-' {
return None;
}
let digits_start = i + 1;
let mut run_end = digits_start;
for c in stem[digits_start..].chars() {
if is_re_digit(c) {
run_end += c.len_utf8();
} else {
break;
}
}
if run_end == digits_start {
None
} else {
Some(&stem[..run_end])
}
}
fn legacy_identifier(artifact: &Artifact, spec: Option<&ArtifactSpec>) -> String {
let explicit = first_value_list_stripped(artifact.section("id"));
if !explicit.is_empty() {
return explicit;
}
if let Some(spec) = spec {
if let Some(field) = &spec.id_field {
if !field.is_empty() {
let declared = first_value_list_stripped(artifact.section(field));
if !declared.is_empty() {
return declared;
}
}
}
}
String::new()
}
fn metadata_id(artifact: &Artifact) -> Option<&str> {
artifact
.metadata
.as_ref()
.and_then(|m| m.id.as_deref())
.filter(|s| !s.is_empty())
}
pub fn artifact_identifier(artifact: &Artifact, spec: Option<&ArtifactSpec>, path: &str) -> String {
if let Some(id) = metadata_id(artifact) {
return id.to_string();
}
let legacy = legacy_identifier(artifact, spec);
if !legacy.is_empty() {
return legacy;
}
let stem = path_stem(path);
match id_prefix(&stem) {
Some(prefix) => prefix.to_string(),
None => stem,
}
}
pub fn artifact_identifiers(
artifact: &Artifact,
spec: Option<&ArtifactSpec>,
path: &str,
) -> Vec<String> {
let mut ids: Vec<String> = Vec::new();
let mut folded: Vec<String> = Vec::new();
let add = |value: String, ids: &mut Vec<String>, folded: &mut Vec<String>| {
if value.is_empty() {
return;
}
let f = py_casefold(&value);
if folded.contains(&f) {
return;
}
folded.push(f);
ids.push(value);
};
if let Some(id) = metadata_id(artifact) {
add(id.to_string(), &mut ids, &mut folded);
}
add(legacy_identifier(artifact, spec), &mut ids, &mut folded);
let stem = path_stem(path);
if let Some(prefix) = id_prefix(&stem) {
add(prefix.to_string(), &mut ids, &mut folded);
}
add(stem, &mut ids, &mut folded);
ids
}
pub fn identity_conflict(
artifact: &Artifact,
spec: Option<&ArtifactSpec>,
) -> Option<(String, String)> {
let fm_id = metadata_id(artifact)?;
let legacy = legacy_identifier(artifact, spec);
if legacy.is_empty() {
return None;
}
if py_strip(&legacy).to_uppercase() == fm_id {
return None;
}
Some((fm_id.to_string(), legacy))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stem_semantics() {
assert_eq!(path_stem("a.b.md"), "a.b");
assert_eq!(path_stem("a."), "a.");
assert_eq!(path_stem(".hidden"), ".hidden");
assert_eq!(path_stem("a/b/"), "b");
assert_eq!(path_stem("a..md"), "a.");
assert_eq!(path_stem(".md"), ".md");
assert_eq!(path_stem("tests/fixtures/valid/feature.md"), "feature");
}
#[test]
fn prefix_matching() {
assert_eq!(id_prefix("adr-004-parser-strategy"), Some("adr-004"));
assert_eq!(id_prefix("adr-x"), None);
assert_eq!(id_prefix("-004"), None);
assert_eq!(id_prefix("adr004"), None);
}
#[test]
fn list_marker_strip() {
assert_eq!(strip_list_marker("- ADR-1"), "ADR-1");
assert_eq!(strip_list_marker("12. x"), "x");
assert_eq!(strip_list_marker("-x"), "-x");
assert_eq!(strip_list_marker("+ y"), "y");
}
}