timebomb-cli 0.5.0

Scan source code for deadline-tagged fuses and fail when they detonate
Documentation
use crate::annotation::Fuse;
use std::collections::HashMap;
use std::path::Path;
use std::process::Command;

/// Per-line blame metadata.
pub struct BlameInfo {
    pub author: String,
}

/// Run `git blame --porcelain <file>` from `repo_root` and return a map of
/// 1-based line number → BlameInfo.
/// Returns `None` if git blame fails (e.g. untracked file, not a git repo).
pub fn blame_file(repo_root: &Path, file: &Path) -> Option<HashMap<usize, BlameInfo>> {
    // Reject paths containing '..' to prevent traversal outside the repo root.
    if file
        .components()
        .any(|c| matches!(c, std::path::Component::ParentDir))
    {
        return None;
    }

    let output = Command::new("git")
        .arg("blame")
        .arg("--porcelain")
        .arg(file)
        .current_dir(repo_root)
        .output()
        .ok()?;

    if !output.status.success() {
        return None;
    }

    let text = String::from_utf8_lossy(&output.stdout);
    Some(parse_blame_porcelain(&text))
}

/// Strip ANSI escape sequences and other control characters from a git blame author name.
///
/// A git committer can set any author name, including strings containing ANSI
/// CSI escape sequences (e.g. `\x1b[31m`) or raw control codes.  These must
/// be removed before the name is embedded in terminal or JSON output to prevent
/// display corruption or terminal injection.
///
/// This function handles the most common case: CSI sequences of the form
/// `ESC [ <params> <final-byte>` (final byte in 0x40–0x7E).  Other ESC
/// sequences and bare control characters are dropped as well.
fn sanitize_author(name: &str) -> String {
    let mut result = String::with_capacity(name.len());
    let mut chars = name.chars().peekable();
    while let Some(c) = chars.next() {
        if c == '\x1b' {
            // CSI sequence: ESC '[' <params…> <final 0x40–0x7E>
            if chars.peek() == Some(&'[') {
                chars.next(); // consume '['
                for inner in chars.by_ref() {
                    if ('\x40'..='\x7e').contains(&inner) {
                        break; // final byte consumed; sequence done
                    }
                }
            }
            // Other ESC sequences: just drop the ESC and let the loop continue
        } else if !c.is_control() {
            result.push(c);
        }
        // Bare control characters (other than ESC) are silently dropped
    }
    result.trim().to_string()
}

/// Parse `git blame --porcelain` output into a map of final-line-number → BlameInfo.
///
/// Porcelain format: each "hunk" begins with a header line:
///   `<40-char-hash> <orig-line> <final-line> [<lines-in-group>]`
/// followed by metadata lines (key-value, one per line), then:
///   `\t<line content>`
/// The `author` metadata key gives the author name.
fn parse_blame_porcelain(output: &str) -> HashMap<usize, BlameInfo> {
    let mut map = HashMap::new();
    let mut lines = output.lines().peekable();

    while let Some(line) = lines.next() {
        // A header line is 40 hex chars followed by a space.
        if line.len() < 40 {
            continue;
        }
        let (maybe_hash, rest) = line.split_at(40);
        if !maybe_hash.chars().all(|c| c.is_ascii_hexdigit()) {
            continue;
        }
        // rest starts with a space; fields are <orig> <final> [<count>]
        // Use .next() to avoid collecting into a Vec just to read two elements.
        let mut parts = rest.split_whitespace();
        let _orig = match parts.next() {
            Some(v) => v,
            None => continue,
        };
        let final_line: usize = match parts.next().and_then(|v| v.parse().ok()) {
            Some(n) => n,
            None => continue,
        };

        // Consume metadata lines until we hit the `\t` content line.
        let mut author = String::new();
        for meta in lines.by_ref() {
            if meta.starts_with('\t') {
                break;
            }
            if let Some(name) = meta.strip_prefix("author ") {
                author = sanitize_author(name);
            }
        }

        map.insert(final_line, BlameInfo { author });
    }

    map
}

/// Enrich fuses that have no explicit `[owner]` with the git blame author.
/// Groups by file so each file gets at most one `git blame` invocation.
pub fn enrich_with_blame(annotations: &mut [Fuse], repo_root: &Path) {
    // Collect unique files that have unowned fuses.
    let files_needing_blame: Vec<std::path::PathBuf> = {
        let mut seen = std::collections::HashSet::new();
        annotations
            .iter()
            .filter(|a| a.owner.is_none())
            .map(|a| a.file.clone())
            .filter(|f| seen.insert(f.clone()))
            .collect()
    };

    // Build a map: relative path → blame map.
    let blame_maps: HashMap<std::path::PathBuf, HashMap<usize, BlameInfo>> = files_needing_blame
        .iter()
        .filter_map(|f| {
            let bm = blame_file(repo_root, f)?;
            Some((f.clone(), bm))
        })
        .collect();

    // Enrich fuses.
    for ann in annotations.iter_mut() {
        if ann.owner.is_some() {
            // Has an explicit owner — do not overwrite.
            continue;
        }
        if let Some(blame_map) = blame_maps.get(&ann.file) {
            if let Some(info) = blame_map.get(&ann.line) {
                if !info.author.is_empty() && info.author != "Not Committed Yet" {
                    ann.blamed_owner = Some(info.author.clone());
                }
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::annotation::Status;
    use chrono::NaiveDate;
    use std::path::PathBuf;

    fn make_annotation(line: usize, owner: Option<&str>) -> Fuse {
        Fuse {
            file: PathBuf::from("src/foo.rs"),
            line,
            tag: "TODO".to_string(),
            date: NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(),
            owner: owner.map(|s| s.to_string()),
            message: "test".to_string(),
            status: Status::Detonated,
            blamed_owner: None,
        }
    }

    #[test]
    fn test_sanitize_author_strips_control_chars() {
        // ANSI escape sequences (e.g. \x1b[31m) must be stripped.
        assert_eq!(sanitize_author("\x1b[31mred name\x1b[0m"), "red name");
        // Null bytes and other control characters must be stripped.
        assert_eq!(sanitize_author("alice\x00bob"), "alicebob");
        // Normal names (no ANSI escapes or control characters) pass through unchanged.
        assert_eq!(sanitize_author("Alice Smith"), "Alice Smith");
        // Leading/trailing whitespace is trimmed.
        assert_eq!(sanitize_author("  Alice  "), "Alice");
    }

    #[test]
    fn test_parse_blame_porcelain_basic() {
        // Minimal synthetic porcelain output for two lines.
        let porcelain = "\
abc1234567890123456789012345678901234567 1 1 1\n\
author Alice Smith\n\
author-mail <alice@example.com>\n\
\tsome code on line 1\n\
abc1234567890123456789012345678901234567 2 2 1\n\
author Bob Jones\n\
author-mail <bob@example.com>\n\
\tsome code on line 2\n\
";
        let map = parse_blame_porcelain(porcelain);
        assert_eq!(map.len(), 2);
        assert_eq!(map.get(&1).unwrap().author, "Alice Smith");
        assert_eq!(map.get(&2).unwrap().author, "Bob Jones");
    }

    #[test]
    fn test_parse_blame_porcelain_empty() {
        let map = parse_blame_porcelain("");
        assert!(map.is_empty());
    }

    #[test]
    fn test_enrich_with_blame_skips_owned() {
        // Fuse with an explicit owner must not be touched.
        let mut annotations = vec![make_annotation(1, Some("alice"))];
        // Use a path that won't be a real git repo — blame_file will return None.
        let fake_root = std::path::Path::new("/tmp/not-a-git-repo");
        enrich_with_blame(&mut annotations, fake_root);
        // The explicit owner is preserved and blamed_owner stays None.
        assert_eq!(annotations[0].owner, Some("alice".to_string()));
        assert_eq!(annotations[0].blamed_owner, None);
    }

    #[test]
    fn test_enrich_with_blame_no_blame_data() {
        // Fuse with no owner; blame_file returns None (not a git repo).
        let mut annotations = vec![make_annotation(5, None)];
        let fake_root = std::path::Path::new("/tmp/not-a-git-repo");
        enrich_with_blame(&mut annotations, fake_root);
        // No blame data available — blamed_owner stays None.
        assert_eq!(annotations[0].blamed_owner, None);
    }

    // ── parse_blame_porcelain edge cases ──────────────────────────────────────

    #[test]
    fn test_parse_blame_porcelain_reused_hash() {
        // git blame reuses the full header+metadata block only on first occurrence of a hash;
        // subsequent hunks with the same hash skip metadata lines (just header + tab content).
        // Our parser handles both because it processes each header it finds.
        let hash = "abcdef1234567890123456789012345678901234";
        let input = format!(
            "{hash} 1 1 1\nauthor Carol\nauthor-mail <c@example.com>\n\tline one\n\
             {hash} 2 2 1\n\tline two\n",
        );
        let map = parse_blame_porcelain(&input);
        // Line 1 should have author Carol; line 2 has no author metadata (empty string is fine).
        assert_eq!(map.get(&1).unwrap().author, "Carol");
        assert!(map.contains_key(&2));
    }

    #[test]
    fn test_parse_blame_porcelain_not_committed_yet() {
        // The special "Not Committed Yet" author value must be filtered out by enrich_with_blame.
        let porcelain = "\
0000000000000000000000000000000000000000 1 1 1\n\
author Not Committed Yet\n\
author-mail <not.committed.yet>\n\
\tuncommitted line\n\
";
        let map = parse_blame_porcelain(porcelain);
        // parse_blame_porcelain itself stores the raw author; the filter happens in enrich_with_blame.
        assert_eq!(map.get(&1).unwrap().author, "Not Committed Yet");
    }

    // ── enrich_with_blame logic ───────────────────────────────────────────────

    /// Build a blame map directly (without spawning git) and call enrich_with_blame
    /// via the internal parse_blame_porcelain path to verify the enrichment logic.
    #[test]
    fn test_enrich_with_blame_sets_blamed_owner() {
        // Build a fake porcelain output and parse it into a blame map.
        let porcelain = "\
abc1234567890123456789012345678901234567 1 10 1\n\
author Dave\n\
author-mail <dave@example.com>\n\
\t// TODO[2020-01-01]: some old thing\n\
";
        let blame_map = parse_blame_porcelain(porcelain);
        // Simulate what enrich_with_blame does internally for a single file.
        let mut ann = make_annotation(10, None);
        if let Some(info) = blame_map.get(&ann.line) {
            if !info.author.is_empty() && info.author != "Not Committed Yet" {
                ann.blamed_owner = Some(info.author.clone());
            }
        }
        assert_eq!(ann.blamed_owner, Some("Dave".to_string()));
    }

    #[test]
    fn test_enrich_with_blame_ignores_not_committed_yet() {
        // "Not Committed Yet" must not be set as blamed_owner.
        let porcelain = "\
0000000000000000000000000000000000000000 1 5 1\n\
author Not Committed Yet\n\
author-mail <not.committed.yet>\n\
\tuncommitted line\n\
";
        let blame_map = parse_blame_porcelain(porcelain);
        let mut ann = make_annotation(5, None);
        if let Some(info) = blame_map.get(&ann.line) {
            if !info.author.is_empty() && info.author != "Not Committed Yet" {
                ann.blamed_owner = Some(info.author.clone());
            }
        }
        assert_eq!(ann.blamed_owner, None);
    }

    #[test]
    fn test_enrich_does_not_overwrite_explicit_owner_even_with_blame_data() {
        // Build blame data that has an author at line 3.
        let porcelain = "\
abc1234567890123456789012345678901234567 1 3 1\n\
author Eve\n\
author-mail <eve@example.com>\n\
\tsome line\n\
";
        let blame_map = parse_blame_porcelain(porcelain);
        // Fuse at line 3 with an explicit owner.
        let mut ann = make_annotation(3, Some("alice"));
        // enrich logic: only touch annotations without an owner
        if ann.owner.is_none() {
            if let Some(info) = blame_map.get(&ann.line) {
                if !info.author.is_empty() && info.author != "Not Committed Yet" {
                    ann.blamed_owner = Some(info.author.clone());
                }
            }
        }
        // explicit owner is preserved; blamed_owner was never set
        assert_eq!(ann.owner, Some("alice".to_string()));
        assert_eq!(ann.blamed_owner, None);
    }

    #[test]
    fn test_enrich_with_blame_multiple_files_same_line_number() {
        // Two annotations at line 1 in different files must each get the right blame.
        // Since enrich_with_blame calls git per file, we verify the grouping by
        // checking that owner is only set when the file matches.
        let porcelain_foo = "\
aaaa000000000000000000000000000000000000 1 1 1\n\
author Foo Author\n\
author-mail <foo@example.com>\n\
\tfoo line\n\
";
        let porcelain_bar = "\
bbbb000000000000000000000000000000000000 1 1 1\n\
author Bar Author\n\
author-mail <bar@example.com>\n\
\tbar line\n\
";

        let foo_blame = parse_blame_porcelain(porcelain_foo);
        let bar_blame = parse_blame_porcelain(porcelain_bar);

        let mut ann_foo = Fuse {
            file: PathBuf::from("src/foo.rs"),
            line: 1,
            tag: "TODO".to_string(),
            date: NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(),
            owner: None,
            message: "foo".to_string(),
            status: Status::Detonated,
            blamed_owner: None,
        };
        let mut ann_bar = Fuse {
            file: PathBuf::from("src/bar.rs"),
            line: 1,
            ..ann_foo.clone()
        };

        // Apply blame per-file (mirrors enrich_with_blame logic).
        for (ann, blame_map) in [(&mut ann_foo, &foo_blame), (&mut ann_bar, &bar_blame)] {
            if ann.owner.is_none() {
                if let Some(info) = blame_map.get(&ann.line) {
                    if !info.author.is_empty() && info.author != "Not Committed Yet" {
                        ann.blamed_owner = Some(info.author.clone());
                    }
                }
            }
        }

        assert_eq!(ann_foo.blamed_owner, Some("Foo Author".to_string()));
        assert_eq!(ann_bar.blamed_owner, Some("Bar Author".to_string()));
    }
}