#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeclaredFile {
pub path: String,
pub is_new: bool,
pub raw_change: String,
}
const RECOGNIZED_EXTENSIONS: &[&str] = &[
".go", ".sql", ".yaml", ".yml", ".md", ".sh", ".ts", ".tsx", ".js", ".jsx",
".rs", ".py", ".java", ".kt", ".rb", ".cs", ".cpp", ".c", ".h", ".hpp",
".swift", ".toml", ".json", ".tf",
];
const SECTION_HEADINGS: &[&str] = &["Files to modify", "Archivos a modificar", "要修改的文件"];
const NEW_MARKERS: &[&str] = &["new", "nuevo", "新建"];
pub fn is_wildcard(path: &str) -> bool {
path.contains("...") || path.contains('*')
}
fn looks_like_path(token: &str) -> bool {
if token.contains(".straymark/") {
return true;
}
RECOGNIZED_EXTENSIONS.iter().any(|ext| token.ends_with(ext))
}
fn first_backtick_token(s: &str) -> Option<&str> {
let start = s.find('`')? + 1;
let rest = &s[start..];
let end = rest.find('`')?;
Some(&rest[..end])
}
fn detect_new(col1_cell: &str, change: &str) -> bool {
let change_lc = change.trim().to_lowercase();
if NEW_MARKERS.iter().any(|m| change_lc.starts_with(m)) {
return true;
}
col1_cell.to_lowercase().contains("(new)")
}
fn strip_new_tag(path: &str) -> String {
let trimmed = path.trim();
let lower = trimmed.to_lowercase();
if let Some(idx) = lower.rfind("(new)") {
if trimmed[idx + 5..].trim().is_empty() {
return trimmed[..idx].trim().to_string();
}
}
trimmed.to_string()
}
pub fn parse_files_to_modify(body: &str) -> Vec<DeclaredFile> {
let mut out = Vec::new();
let mut in_section = false;
for line in body.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with("## ") {
if in_section {
break;
}
let title = trimmed.trim_start_matches('#').trim();
if SECTION_HEADINGS.contains(&title) {
in_section = true;
}
continue;
}
if !in_section {
continue;
}
if trimmed.starts_with('|') {
let cols: Vec<&str> = line.split('|').collect();
if cols.len() < 2 {
continue;
}
let col1 = cols[1].trim();
let col2 = cols.get(2).map(|c| c.trim()).unwrap_or("");
if !col1.is_empty()
&& col1.chars().all(|c| matches!(c, '-' | ':' | ' '))
{
continue;
}
let col1_plain = col1.trim_matches('*').trim();
if matches!(col1_plain, "File" | "Archivo" | "文件") {
continue;
}
let Some(token) = first_backtick_token(col1) else {
continue;
};
if !looks_like_path(token) {
continue;
}
let is_new = detect_new(col1, col2);
out.push(DeclaredFile {
path: strip_new_tag(token),
is_new,
raw_change: col2.to_string(),
});
} else {
let is_new_line = trimmed.to_lowercase().contains("(new)");
let mut rest = line;
while let Some(start) = rest.find('`') {
let after = &rest[start + 1..];
let Some(end) = after.find('`') else { break };
let token = &after[..end];
if looks_like_path(token) {
out.push(DeclaredFile {
path: strip_new_tag(token),
is_new: is_new_line,
raw_change: String::new(),
});
}
rest = &after[end + 1..];
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn paths(files: &[DeclaredFile]) -> Vec<&str> {
files.iter().map(|f| f.path.as_str()).collect()
}
#[test]
fn parses_table_col1_backtick_paths() {
let body = r#"## Files to modify
| File | Change |
|---|---|
| `src/main.rs` | Adds routing |
| `src/config.rs` | New field `foo` |
## Verification
"#;
let files = parse_files_to_modify(body);
assert_eq!(paths(&files), vec!["src/main.rs", "src/config.rs"]);
assert!(files.iter().all(|f| f.path != "foo"));
}
#[test]
fn detects_new_via_change_column() {
let body = r#"## Files to modify
| File | Change |
|---|---|
| `src/old.rs` | Refactor |
| `.straymark/07-ai-audit/agent-logs/AILOG-x.md` | New, `risk_level: low` |
| `src/created.rs` | Nuevo módulo |
"#;
let files = parse_files_to_modify(body);
let by_path = |p: &str| files.iter().find(|f| f.path == p).unwrap();
assert!(!by_path("src/old.rs").is_new);
assert!(by_path(".straymark/07-ai-audit/agent-logs/AILOG-x.md").is_new);
assert!(by_path("src/created.rs").is_new); }
#[test]
fn detects_new_via_path_tag() {
let body = r#"## Files to modify
- `src/foo.rs` (new) — created here
- `src/bar.rs` — existing
"#;
let files = parse_files_to_modify(body);
let by_path = |p: &str| files.iter().find(|f| f.path == p).unwrap();
assert!(by_path("src/foo.rs").is_new);
assert!(!by_path("src/bar.rs").is_new);
assert!(by_path("src/foo.rs").path == "src/foo.rs");
}
#[test]
fn skips_separator_and_header_rows() {
let body = r#"## Files to modify
| File | Change |
| --- | --- |
| `a.rs` | x |
"#;
let files = parse_files_to_modify(body);
assert_eq!(paths(&files), vec!["a.rs"]);
}
#[test]
fn recognizes_spanish_and_chinese_headings() {
let es = "## Archivos a modificar\n\n| File | Change |\n|---|---|\n| `x.rs` | y |\n";
let zh = "## 要修改的文件\n\n| File | Change |\n|---|---|\n| `z.go` | w |\n";
assert_eq!(paths(&parse_files_to_modify(es)), vec!["x.rs"]);
assert_eq!(paths(&parse_files_to_modify(zh)), vec!["z.go"]);
}
#[test]
fn preserves_wildcards_for_caller_to_skip() {
let body = "## Files to modify\n\n| File | Change |\n|---|---|\n| `AILOG-*.md` | bulk |\n| `.straymark/07-ai-audit/agent-logs/AILOG-...md` | log |\n";
let files = parse_files_to_modify(body);
assert!(files.iter().any(|f| is_wildcard(&f.path)));
assert_eq!(files.len(), 2);
}
#[test]
fn stops_at_next_heading() {
let body = r#"## Files to modify
| File | Change |
|---|---|
| `in.rs` | x |
## Risks
- `out.rs` should not be captured
"#;
let files = parse_files_to_modify(body);
assert_eq!(paths(&files), vec!["in.rs"]);
}
#[test]
fn empty_when_section_absent() {
let body = "## Context\n\nNo files section here.\n";
assert!(parse_files_to_modify(body).is_empty());
}
}