use super::ledger::{GithubIssueRef, LinearIssueRef};
use super::scan::TodoHit;
use crate::commands::text_util::normalize_whitespace;
use std::fs;
use std::path::Path;
pub fn annotate_source_line(
project_root: &Path,
hit: &TodoHit,
linear: Option<&LinearIssueRef>,
github: Option<&GithubIssueRef>,
) -> Result<bool, String> {
if linear.is_none() && github.is_none() {
return Ok(false);
}
let path = project_root.join(&hit.path);
if !path.is_file() {
return Err(format!("Cannot annotate missing file {}", path.display()));
}
let content = fs::read_to_string(&path)
.map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
let mut lines: Vec<String> = content.lines().map(|l| l.to_string()).collect();
let idx = hit.line.saturating_sub(1);
if idx >= lines.len() {
return Err(format!(
"Line {} out of range in {} ({} lines)",
hit.line,
hit.path,
lines.len()
));
}
let original = lines[idx].clone();
if !line_matches_todo(&original, hit) {
return Ok(false);
}
let trimmed_end = original.trim_end();
let mut to_add: Vec<String> = Vec::new();
if let Some(lin) = linear {
if !trimmed_end.contains(&lin.identifier) {
to_add.push(lin.identifier.clone());
}
}
if let Some(gh) = github {
let bare = format!("#{}", gh.number);
if !trimmed_end.contains(&bare) {
to_add.push(format!("(#{})", gh.number));
}
}
if to_add.is_empty() {
return Ok(false);
}
let annotated = if looks_like_hash_comment_file(&hit.path) {
format!("{} # {}", trimmed_end, to_add.join(" "))
} else {
format!("{} // {}", trimmed_end, to_add.join(" "))
};
if annotated == original {
return Ok(false);
}
lines[idx] = annotated;
let mut out = lines.join("\n");
if content.ends_with('\n') {
out.push('\n');
}
fs::write(&path, out).map_err(|e| format!("Failed to write {}: {e}", path.display()))?;
Ok(true)
}
fn line_matches_todo(line: &str, hit: &TodoHit) -> bool {
let upper = line.to_ascii_uppercase();
if !upper.contains(&hit.kind.to_ascii_uppercase()) {
return false;
}
let needle = hit
.text
.split_whitespace()
.take(4)
.collect::<Vec<_>>()
.join(" ");
if needle.len() < 4 {
return true;
}
normalize_whitespace(line)
.to_ascii_lowercase()
.contains(&needle.to_ascii_lowercase())
}
fn looks_like_hash_comment_file(path: &str) -> bool {
let lower = path.to_ascii_lowercase();
lower.ends_with(".py")
|| lower.ends_with(".sh")
|| lower.ends_with(".bash")
|| lower.ends_with(".zsh")
|| lower.ends_with(".yaml")
|| lower.ends_with(".yml")
|| lower.ends_with(".toml")
|| lower.ends_with(".rb")
}
#[cfg(test)]
mod tests {
use super::*;
use super::super::scan::fingerprint;
use std::time::{SystemTime, UNIX_EPOCH};
#[test]
fn annotates_rust_todo_line() {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!("xbp-annotate-{stamp}"));
fs::create_dir_all(dir.join("src")).unwrap();
let file = dir.join("src/a.rs");
fs::write(&file, "// TODO: wire the fallback\nfn main() {}\n").unwrap();
let text = "wire the fallback";
let hit = TodoHit {
fingerprint: fingerprint("src/a.rs", "TODO", text),
kind: "TODO".into(),
path: "src/a.rs".into(),
line: 1,
text: text.into(),
context: None,
};
let lin = LinearIssueRef {
id: "uuid".into(),
identifier: "XLX-99".into(),
url: None,
};
let gh = GithubIssueRef {
number: 42,
url: None,
};
assert!(annotate_source_line(&dir, &hit, Some(&lin), Some(&gh)).unwrap());
let content = fs::read_to_string(&file).unwrap();
assert!(content.contains("XLX-99"));
assert!(content.contains("#42"));
assert!(!annotate_source_line(&dir, &hit, Some(&lin), Some(&gh)).unwrap());
let _ = fs::remove_dir_all(dir);
}
}