use regex::Regex;
use std::sync::LazyLock;
use crate::error::XorcistError;
use crate::jj::runner::JjRunner;
const GRAPH_LOG_TEMPLATE: &str = r#"separate(" ", change_id.shortest(8), author.name(), author.timestamp().ago().replace(regex:"\\s+seconds? ago", "s").replace(regex:"\\s+minutes? ago", "m").replace(regex:"\\s+hours? ago", "h").replace(regex:"\\s+days? ago", "d").replace(regex:"\\s+weeks? ago", "w").replace(regex:"\\s+months? ago", "mo").replace(regex:"\\s+years? ago", "y"), if(bookmarks, "[" ++ bookmarks.map(|b| b.name()).join(",") ++ "]"), description.first_line())"#;
static CHANGE_ID_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^[^a-z]*([a-z]{8})\s").expect("Invalid regex pattern")
});
static COMMIT_LINE_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^[^a-z]*([a-z]{8})\s+(.+?)\s+(now|\d+(?:mo|[smhdwy]))\s*(?:\[([^\]]*)\]\s*)?(.*)$")
.expect("Invalid regex pattern")
});
static ANSI_STRIP_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\x1b\[[0-9;]*m").expect("Invalid ANSI regex pattern"));
#[derive(Debug, Clone)]
pub struct GraphLine {
pub raw: String,
pub plain: String,
pub change_id: Option<String>,
pub description: Option<String>,
pub line_index: usize,
}
impl GraphLine {
fn new(raw: String, line_index: usize) -> Self {
let plain = strip_ansi(&raw);
let (change_id, description) = extract_commit_fields(&plain);
Self {
raw,
plain,
change_id,
description,
line_index,
}
}
pub fn is_commit_line(&self) -> bool {
self.change_id.is_some()
}
}
#[derive(Debug, Clone, Default)]
pub struct GraphLog {
pub lines: Vec<GraphLine>,
pub commit_line_indices: Vec<usize>,
}
impl GraphLog {
pub fn from_output(output: &str) -> Self {
let lines: Vec<GraphLine> = output
.lines()
.enumerate()
.map(|(idx, line)| GraphLine::new(line.to_string(), idx))
.collect();
let commit_line_indices: Vec<usize> = lines
.iter()
.enumerate()
.filter(|(_, line)| line.is_commit_line())
.map(|(idx, _)| idx)
.collect();
Self {
lines,
commit_line_indices,
}
}
pub fn commit_count(&self) -> usize {
self.commit_line_indices.len()
}
pub fn line_index_for_selection(&self, selection: usize) -> Option<usize> {
self.commit_line_indices.get(selection).copied()
}
pub fn change_id_for_selection(&self, selection: usize) -> Option<&str> {
let line_idx = self.line_index_for_selection(selection)?;
self.lines[line_idx].change_id.as_deref()
}
pub fn is_empty(&self) -> bool {
self.commit_line_indices.is_empty()
}
pub fn extend(&mut self, other: GraphLog) {
let offset = self.lines.len();
for mut line in other.lines {
line.line_index += offset;
self.lines.push(line);
}
for idx in other.commit_line_indices {
self.commit_line_indices.push(idx + offset);
}
}
}
fn strip_ansi(s: &str) -> String {
ANSI_STRIP_REGEX.replace_all(s, "").to_string()
}
#[allow(dead_code)]
fn extract_change_id(plain: &str) -> Option<String> {
CHANGE_ID_REGEX
.captures(plain)
.map(|cap| cap[1].to_string())
}
fn extract_commit_fields(plain: &str) -> (Option<String>, Option<String>) {
match COMMIT_LINE_REGEX.captures(plain) {
Some(cap) => {
let change_id = cap[1].to_string();
let description = cap.get(5).map(|m| m.as_str().to_string());
(Some(change_id), description)
}
None => (None, None),
}
}
pub fn fetch_graph_log(runner: &JjRunner, limit: Option<usize>) -> Result<GraphLog, XorcistError> {
let mut args = vec![
"log",
"--color",
"always",
"-T",
GRAPH_LOG_TEMPLATE,
"-r",
"::",
];
let limit_str;
if let Some(n) = limit {
limit_str = n.to_string();
args.push("-n");
args.push(&limit_str);
}
let output = runner.run_capture(&args)?;
Ok(GraphLog::from_output(&output))
}
pub fn fetch_graph_log_after(
runner: &JjRunner,
after_change_id: &str,
limit: usize,
) -> Result<GraphLog, XorcistError> {
let revset = format!("::{after_change_id}-");
let limit_str = limit.to_string();
let args = vec![
"log",
"--color",
"always",
"-T",
GRAPH_LOG_TEMPLATE,
"-r",
&revset,
"-n",
&limit_str,
];
let output = runner.run_capture(&args)?;
Ok(GraphLog::from_output(&output))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_strip_ansi() {
let input = "\x1b[1m\x1b[38;5;5mq\x1b[0m\x1b[38;5;8mzmtztvn\x1b[39m test";
let result = strip_ansi(input);
assert_eq!(result, "qzmtztvn test");
}
#[test]
fn test_extract_change_id_simple() {
let line = "@ qzmtztvn 1XD 11m feat: test";
assert_eq!(extract_change_id(line), Some("qzmtztvn".to_string()));
let line = "◆ rvzpxnov 1XD 12h refactor: something";
assert_eq!(extract_change_id(line), Some("rvzpxnov".to_string()));
let line = "○ abcdefgh Author 1d fix: bug";
assert_eq!(extract_change_id(line), Some("abcdefgh".to_string()));
}
#[test]
fn test_extract_change_id_with_graph_branches() {
let line = "├─╮";
assert_eq!(extract_change_id(line), None);
let line = "│ ◆ xyzwvuts 1XD 1h test";
assert_eq!(extract_change_id(line), Some("xyzwvuts".to_string()));
let line = "├─╯";
assert_eq!(extract_change_id(line), None);
}
#[test]
fn test_extract_change_id_edge_cases() {
assert_eq!(extract_change_id(""), None);
assert_eq!(extract_change_id("│ "), None);
assert_eq!(extract_change_id("@ abc 1XD 1h test"), None);
}
#[test]
fn test_change_id_contract_requires_exactly_8_characters() {
assert_eq!(extract_change_id("@ abcdefg Author 1h too short"), None);
assert_eq!(
extract_change_id("@ abcdefgh Author 1h exact"),
Some("abcdefgh".to_string())
);
assert_eq!(extract_change_id("@ abcdefghi Author 1h too long"), None);
let too_short = GraphLine::new("@ abcdefg Author 1h too short".to_string(), 0);
let exact = GraphLine::new("@ abcdefgh Author 1h exact".to_string(), 1);
let too_long = GraphLine::new("@ abcdefghi Author 1h too long".to_string(), 2);
assert!(!too_short.is_commit_line());
assert!(exact.is_commit_line());
assert!(!too_long.is_commit_line());
}
#[test]
fn test_graph_line_creation() {
let raw = "\x1b[1m@\x1b[0m \x1b[1m\x1b[38;5;5mq\x1b[0mzmtztvn 1XD 11m feat: test";
let line = GraphLine::new(raw.to_string(), 0);
assert!(line.is_commit_line());
assert_eq!(line.change_id, Some("qzmtztvn".to_string()));
assert_eq!(line.description, Some("feat: test".to_string()));
assert_eq!(line.line_index, 0);
}
#[test]
fn test_graph_line_author_name_with_spaces() {
let line = GraphLine::new("@ qzmtztvn Alice Example 11m feat: test".to_string(), 0);
assert!(line.is_commit_line());
assert_eq!(line.change_id, Some("qzmtztvn".to_string()));
assert_eq!(line.description, Some("feat: test".to_string()));
}
#[test]
fn test_graph_line_empty_description() {
let raw = "@ qzmtztvn Author 1h ";
let line = GraphLine::new(raw.to_string(), 0);
assert!(line.is_commit_line());
assert_eq!(line.change_id, Some("qzmtztvn".to_string()));
assert_eq!(line.description, Some("".to_string()));
}
#[test]
fn test_graph_line_no_description() {
let raw = "@ qzmtztvn Author 1h";
let line = GraphLine::new(raw.to_string(), 0);
assert!(line.is_commit_line());
assert_eq!(line.change_id, Some("qzmtztvn".to_string()));
assert_eq!(line.description, Some("".to_string()));
}
#[test]
fn test_extract_commit_fields() {
let (cid, desc) = extract_commit_fields("@ qzmtztvn Author 1h feat: add feature");
assert_eq!(cid, Some("qzmtztvn".to_string()));
assert_eq!(desc, Some("feat: add feature".to_string()));
let (cid, desc) = extract_commit_fields("@ qzmtztvn Author 1h ");
assert_eq!(cid, Some("qzmtztvn".to_string()));
assert_eq!(desc, Some("".to_string()));
let (cid, desc) = extract_commit_fields("├─╮");
assert_eq!(cid, None);
assert_eq!(desc, None);
}
#[test]
fn test_graph_log_from_output() {
let output = "@ qzmtztvn 1XD 11m feat: test
◆ rvzpxnov 1XD 12h refactor: something
├─╮
│ ◆ xyzwvuts 1XD 1h test
├─╯
◆ abcdefgh 1XD 1d init";
let log = GraphLog::from_output(output);
assert_eq!(log.lines.len(), 6);
assert_eq!(log.commit_count(), 4);
assert_eq!(log.commit_line_indices, vec![0, 1, 3, 5]);
assert_eq!(log.change_id_for_selection(0), Some("qzmtztvn"));
assert_eq!(log.change_id_for_selection(1), Some("rvzpxnov"));
assert_eq!(log.change_id_for_selection(2), Some("xyzwvuts"));
assert_eq!(log.change_id_for_selection(3), Some("abcdefgh"));
assert_eq!(log.change_id_for_selection(4), None);
}
#[test]
fn test_graph_only_lines_remain_visible_but_not_selectable() {
let output = "@ qzmtztvn 1XD 11m feat: test
│
├─╮
│ ◆ xyzwvuts 1XD 1h test
├─╯";
let log = GraphLog::from_output(output);
assert_eq!(log.lines.len(), 5);
assert_eq!(log.lines[1].plain, "│");
assert_eq!(log.lines[2].plain, "├─╮");
assert_eq!(log.lines[4].plain, "├─╯");
assert_eq!(log.commit_line_indices, vec![0, 3]);
assert_eq!(log.line_index_for_selection(0), Some(0));
assert_eq!(log.line_index_for_selection(1), Some(3));
assert_eq!(log.line_index_for_selection(2), None);
}
#[test]
fn test_graph_log_empty() {
let log = GraphLog::from_output("");
assert!(log.is_empty());
assert_eq!(log.commit_count(), 0);
}
}