use crate::error::XorcistError;
use crate::jj::runner::JjRunner;
#[derive(Debug, Clone)]
pub struct ShowOutput {
pub change_id: String,
pub change_id_prefix: String,
pub change_id_rest: String,
#[allow(dead_code)] pub commit_id: String,
pub commit_id_prefix: String,
pub commit_id_rest: String,
pub author: String,
pub timestamp: String,
pub description: String,
pub bookmarks: Vec<String>,
pub diff_summary: Vec<DiffEntry>,
}
#[derive(Debug, Clone)]
pub struct DiffEntry {
pub status: DiffStatus,
pub path: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffStatus {
Added,
Modified,
Deleted,
Renamed,
Copied,
}
const SHOW_TEMPLATE: &str = r#"change_id.shortest(4).prefix() ++ "\x00" ++ change_id.shortest(4).rest() ++ "\x00" ++ commit_id.shortest(4).prefix() ++ "\x00" ++ commit_id.shortest(4).rest() ++ "\x00" ++ author.name() ++ "\x00" ++ committer.timestamp().ago() ++ "\x00" ++ description ++ "\x00" ++ bookmarks.join(",") ++ "\n""#;
pub fn fetch_show(runner: &JjRunner, revision: &str) -> Result<ShowOutput, XorcistError> {
let meta_output =
runner.run_capture(&["log", "-r", revision, "--no-graph", "-T", SHOW_TEMPLATE])?;
let meta = parse_show_meta(&meta_output)?;
let diff_output = runner.run_capture(&["diff", "-r", revision, "--summary"])?;
let diff_summary = parse_diff_summary(&diff_output);
Ok(ShowOutput {
change_id: meta.change_id,
change_id_prefix: meta.change_id_prefix,
change_id_rest: meta.change_id_rest,
commit_id: meta.commit_id,
commit_id_prefix: meta.commit_id_prefix,
commit_id_rest: meta.commit_id_rest,
author: meta.author,
timestamp: meta.timestamp,
description: meta.description,
bookmarks: meta.bookmarks,
diff_summary,
})
}
pub fn fetch_diff_file(
runner: &JjRunner,
revision: &str,
path: &str,
) -> Result<String, XorcistError> {
runner.run_capture(&["diff", "-r", revision, "--color=never", "--git", "--", path])
}
#[derive(Debug)]
struct ShowMeta {
change_id: String,
change_id_prefix: String,
change_id_rest: String,
commit_id: String,
commit_id_prefix: String,
commit_id_rest: String,
author: String,
timestamp: String,
description: String,
bookmarks: Vec<String>,
}
fn parse_show_meta(output: &str) -> Result<ShowMeta, XorcistError> {
let output = output.trim_end_matches('\n');
let parts: Vec<&str> = output.split('\x00').collect();
if parts.len() != 8 {
return Err(XorcistError::JjError(format!(
"unexpected show output format: expected 8 fields, got {}",
parts.len()
)));
}
let bookmarks = super::parse_bookmarks_field(parts[7]);
let description = parts[6].trim_end_matches('\n').to_string();
let change_id_prefix = parts[0].to_string();
let change_id_rest = parts[1].to_string();
let commit_id_prefix = parts[2].to_string();
let commit_id_rest = parts[3].to_string();
Ok(ShowMeta {
change_id: format!("{change_id_prefix}{change_id_rest}"),
change_id_prefix,
change_id_rest,
commit_id: format!("{commit_id_prefix}{commit_id_rest}"),
commit_id_prefix,
commit_id_rest,
author: parts[4].to_string(),
timestamp: parts[5].to_string(),
description,
bookmarks,
})
}
pub(crate) fn parse_diff_summary(output: &str) -> Vec<DiffEntry> {
output
.lines()
.filter_map(|line| {
if line.is_empty() {
return None;
}
let (status_char, path) = line.split_once(' ')?;
let status = match status_char {
"A" => DiffStatus::Added,
"M" => DiffStatus::Modified,
"D" => DiffStatus::Deleted,
"R" => DiffStatus::Renamed,
"C" => DiffStatus::Copied,
_ => return None,
};
Some(DiffEntry {
status,
path: path.to_string(),
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_show_meta() {
let output =
"abc\x00123\x00def\x00456\x00Alice\x002 hours ago\x00Add feature\x00main,dev\n";
let result = parse_show_meta(output).unwrap();
assert_eq!(result.change_id_prefix, "abc");
assert_eq!(result.change_id_rest, "123");
assert_eq!(result.change_id, "abc123");
assert_eq!(result.commit_id_prefix, "def");
assert_eq!(result.commit_id_rest, "456");
assert_eq!(result.commit_id, "def456");
assert_eq!(result.author, "Alice");
assert_eq!(result.timestamp, "2 hours ago");
assert_eq!(result.description, "Add feature");
assert_eq!(result.bookmarks, vec!["main", "dev"]);
}
#[test]
fn test_parse_show_meta_field_order_contract() {
let output = "change-prefix\x00change-rest\x00commit-prefix\x00commit-rest\x00author-name\x00timestamp-value\x00description text\x00bookmark-a,bookmark-b\n";
let result = parse_show_meta(output).unwrap();
assert_eq!(result.change_id_prefix, "change-prefix");
assert_eq!(result.change_id_rest, "change-rest");
assert_eq!(result.change_id, "change-prefixchange-rest");
assert_eq!(result.commit_id_prefix, "commit-prefix");
assert_eq!(result.commit_id_rest, "commit-rest");
assert_eq!(result.commit_id, "commit-prefixcommit-rest");
assert_eq!(result.author, "author-name");
assert_eq!(result.timestamp, "timestamp-value");
assert_eq!(result.description, "description text");
assert_eq!(result.bookmarks, vec!["bookmark-a", "bookmark-b"]);
}
#[test]
fn test_parse_show_meta_no_bookmarks() {
let output = "abc\x00123\x00def\x00456\x00Alice\x002 hours ago\x00Add feature\x00\n";
let result = parse_show_meta(output).unwrap();
assert!(result.bookmarks.is_empty());
}
#[test]
fn test_parse_show_meta_multiline_description() {
let output =
"abc\x00123\x00def\x00456\x00Alice\x002 hours ago\x00First line\nSecond line\x00main\n";
let result = parse_show_meta(output).unwrap();
assert_eq!(result.description, "First line\nSecond line");
assert_eq!(result.bookmarks, vec!["main"]);
}
#[test]
fn test_parse_show_meta_description_with_trailing_newline() {
let output = "abc\x00123\x00def\x00456\x00Alice\x002 hours ago\x00Add feature\n\x00main\n";
let result = parse_show_meta(output).unwrap();
assert_eq!(result.description, "Add feature");
assert_eq!(result.bookmarks, vec!["main"]);
}
#[test]
fn test_parse_show_meta_empty_rest() {
let output = "abcd\x00\x00defg\x00\x00Alice\x00now\x00Test\x00\n";
let result = parse_show_meta(output).unwrap();
assert_eq!(result.change_id_prefix, "abcd");
assert!(result.change_id_rest.is_empty());
assert_eq!(result.change_id, "abcd");
assert_eq!(result.commit_id_prefix, "defg");
assert!(result.commit_id_rest.is_empty());
}
#[test]
fn test_parse_show_meta_errors_on_wrong_field_count() {
let too_few =
parse_show_meta("change\x00rest\x00commit\x00rest\x00author\x00time\x00description\n")
.unwrap_err();
assert!(
too_few
.to_string()
.contains("unexpected show output format: expected 8 fields, got 7")
);
let too_many = parse_show_meta(
"change\x00rest\x00commit\x00rest\x00author\x00time\x00description\x00bookmark\x00extra\n",
)
.unwrap_err();
assert!(
too_many
.to_string()
.contains("unexpected show output format: expected 8 fields, got 9")
);
}
#[test]
fn test_parse_diff_summary() {
let output = r#"A src/new_file.rs
M src/main.rs
D src/old_file.rs
"#;
let entries = parse_diff_summary(output);
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].status, DiffStatus::Added);
assert_eq!(entries[0].path, "src/new_file.rs");
assert_eq!(entries[1].status, DiffStatus::Modified);
assert_eq!(entries[1].path, "src/main.rs");
assert_eq!(entries[2].status, DiffStatus::Deleted);
assert_eq!(entries[2].path, "src/old_file.rs");
}
#[test]
fn test_parse_diff_summary_empty() {
let output = "";
let entries = parse_diff_summary(output);
assert!(entries.is_empty());
}
#[test]
fn test_parse_diff_summary_with_spaces_in_path() {
let output = "M path/with spaces/file.rs\n";
let entries = parse_diff_summary(output);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].path, "path/with spaces/file.rs");
}
#[test]
fn test_parse_diff_summary_preserves_edge_spaces_in_path() {
let output = "M leading and trailing .rs \n";
let entries = parse_diff_summary(output);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].path, " leading and trailing .rs ");
}
}