xbp 10.40.0

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
//! Optionally stamp source TODO lines with filed issue identifiers.

use super::ledger::{GithubIssueRef, LinearIssueRef};
use super::scan::TodoHit;
use crate::commands::text_util::normalize_whitespace;
use std::fs;
use std::path::Path;

/// Append issue refs to the source line when not already present.
/// Returns true if the file was modified.
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) {
        // Soft skip: line moved; don't rewrite blindly.
        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,
            source: 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"));
        // Idempotent second pass
        assert!(!annotate_source_line(&dir, &hit, Some(&lin), Some(&gh)).unwrap());
        let _ = fs::remove_dir_all(dir);
    }
}