use std::path::PathBuf;
use vcs_diff::{DiffStat, path_from_bytes};
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Change {
pub change_id: String,
pub commit_id: String,
pub empty: bool,
pub description: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Bookmark {
pub name: String,
pub target: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct BookmarkRef {
pub name: String,
pub remote: Option<String>,
pub target: String,
pub tracked: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Workspace {
pub name: String,
pub commit: String,
pub bookmarks: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct ChangedPath {
pub status: char,
pub path: PathBuf,
pub old_path: Option<PathBuf>,
}
pub(crate) const CHANGE_TEMPLATE: &str = "change_id.short() ++ \"\\t\" ++ commit_id.short() ++ \"\\t\" ++ if(empty, \"true\", \"false\") ++ \"\\t\" ++ description.first_line().escape_json() ++ \"\\n\"";
pub(crate) const WORKSPACE_TEMPLATE: &str = "name.escape_json() ++ \"\\t\" ++ target.commit_id() ++ \"\\t\" ++ target.local_bookmarks().map(|b| b.name().escape_json()).join(\" \") ++ \"\\n\"";
pub(crate) const BOOKMARKS_TEMPLATE: &str =
"local_bookmarks.map(|b| b.name().escape_json()).join(\" \")";
pub(crate) const BOOKMARK_ALL_TEMPLATE: &str = "if(present, \"1\", \"0\") ++ \"\\t\" ++ name.escape_json() ++ \"\\t\" ++ remote ++ \"\\t\" ++ if(tracked, \"1\", \"0\") ++ \"\\t\" ++ if(normal_target, normal_target.commit_id(), \"\") ++ \"\\n\"";
pub(crate) const BOOKMARK_LIST_TEMPLATE: &str = "if(present, \"1\", \"0\") ++ \"\\t\" ++ remote ++ \"\\t\" ++ name.escape_json() ++ \"\\t\" ++ if(normal_target, normal_target.commit_id(), \"\") ++ \"\\n\"";
pub(crate) const CONFLICT_TEMPLATE: &str = "if(conflict, \"1\", \"0\")";
pub(crate) const COUNT_TEMPLATE: &str = "commit_id.short() ++ \"\\n\"";
pub(crate) const REACHABLE_BOOKMARKS_TEMPLATE: &str = "local_bookmarks.map(|b| b.name().escape_json()).join(\" \") ++ \"\\t\" ++ commit_id ++ \"\\n\"";
pub(crate) fn parse_jj_version(raw: &str) -> Option<vcs_diff::Version> {
vcs_diff::parse_dotted_version(raw)
}
pub(crate) const EVOLOG_TEMPLATE: &str = "commit.change_id().short() ++ \"\\t\" ++ commit.commit_id().short() ++ \"\\t\" ++ if(commit.empty(), \"true\", \"false\") ++ \"\\t\" ++ commit.description().first_line().escape_json() ++ \"\\n\"";
pub(crate) const OP_TEMPLATE: &str = "id.short() ++ \"\\t\" ++ user.escape_json() ++ \"\\t\" ++ time.start().format(\"%Y-%m-%dT%H:%M:%S%:z\") ++ \"\\t\" ++ description.first_line().escape_json() ++ \"\\n\"";
pub(crate) const OP_PARENTS_TEMPLATE: &str = "id.short() ++ \"\\t\" ++ parents.len() ++ \"\\n\"";
pub(crate) const ANNOTATE_TEMPLATE: &str = "commit.change_id().short() ++ \"\\t\" ++ content";
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Operation {
pub id: String,
pub user: String,
pub time: String,
pub description: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct AnnotationLine {
pub change_id: String,
pub line: u32,
pub content: String,
}
fn decode_json_field(field: &str) -> String {
let mut chars = field.chars();
if chars.next() != Some('"') {
return field.to_string();
}
let mut out = String::new();
while let Some(c) = chars.next() {
match c {
'"' => break, '\\' => match chars.next() {
Some('"') => out.push('"'),
Some('\\') => out.push('\\'),
Some('/') => out.push('/'),
Some('b') => out.push('\u{0008}'),
Some('f') => out.push('\u{000C}'),
Some('n') => out.push('\n'),
Some('r') => out.push('\r'),
Some('t') => out.push('\t'),
Some('u') => {
let mut code: u32 = 0;
for _ in 0..4 {
match chars.next().and_then(|h| h.to_digit(16)) {
Some(d) => code = code * 16 + d,
None => break,
}
}
if let Some(ch) = char::from_u32(code) {
out.push(ch);
}
}
Some(other) => out.push(other),
None => break,
},
other => out.push(other),
}
}
out
}
fn decode_name_list(field: &str) -> Vec<String> {
field
.split(' ')
.filter(|tok| !tok.is_empty())
.map(decode_json_field)
.collect()
}
pub(crate) fn first_bookmark_name(rendered: &str) -> Option<String> {
decode_name_list(rendered.trim()).into_iter().next()
}
pub(crate) fn parse_operations(output: &str) -> Vec<Operation> {
output
.lines()
.filter(|line| !line.is_empty())
.filter_map(|line| {
let mut fields = line.splitn(4, '\t');
let id = fields.next()?.to_string();
let user = decode_json_field(fields.next()?);
let time = fields.next()?.to_string();
let description = decode_json_field(fields.next().unwrap_or(""));
Some(Operation {
id,
user,
time,
description,
})
})
.collect()
}
pub(crate) fn parse_op_parents(output: &str) -> Vec<(String, usize)> {
output
.lines()
.filter(|line| !line.is_empty())
.map(|line| {
let mut fields = line.splitn(2, '\t');
let id = fields.next().unwrap_or("").to_string();
let parents = fields
.next()
.and_then(|s| s.trim().parse::<usize>().ok())
.unwrap_or(0);
(id, parents)
})
.collect()
}
pub(crate) fn parse_annotate(output: &str) -> Vec<AnnotationLine> {
output
.split('\n')
.enumerate()
.filter_map(|(idx, line)| {
let (change_id, content) = line.split_once('\t')?;
Some(AnnotationLine {
change_id: change_id.to_string(),
line: u32::try_from(idx + 1).unwrap_or(u32::MAX),
content: content.to_string(),
})
})
.collect()
}
pub(crate) fn parse_changes(output: &str) -> Vec<Change> {
output
.lines()
.filter(|line| !line.is_empty())
.filter_map(|line| {
let mut fields = line.splitn(4, '\t');
let change_id = fields.next()?.to_string();
let commit_id = fields.next()?.to_string();
let empty = fields.next()? == "true";
let description = decode_json_field(fields.next().unwrap_or(""));
Some(Change {
change_id,
commit_id,
empty,
description,
})
})
.collect()
}
pub(crate) fn parse_bookmarks(output: &str) -> Vec<Bookmark> {
output
.lines()
.filter(|line| !line.is_empty())
.filter_map(|line| {
let mut fields = line.split('\t');
let present = fields.next()? == "1";
let remote = fields.next().unwrap_or("");
let name = decode_json_field(fields.next().unwrap_or(""));
let target = fields.next().unwrap_or("").to_string();
if !present || !remote.is_empty() || name.is_empty() {
return None;
}
Some(Bookmark { name, target })
})
.collect()
}
pub(crate) fn parse_bookmarks_all(output: &str) -> Vec<BookmarkRef> {
output
.lines()
.filter(|line| !line.is_empty())
.filter_map(|line| {
let mut fields = line.split('\t');
let present = fields.next()? == "1";
let name = decode_json_field(fields.next().unwrap_or(""));
let remote = fields.next().unwrap_or("");
let tracked = fields.next() == Some("1");
let target = fields.next().unwrap_or("").to_string();
if !present || name.is_empty() {
return None;
}
Some(BookmarkRef {
name,
remote: (!remote.is_empty()).then(|| remote.to_string()),
target,
tracked,
})
})
.collect()
}
pub(crate) fn parse_reachable_bookmarks(output: &str) -> Vec<Bookmark> {
let mut out = Vec::new();
for line in output.lines().filter(|l| !l.is_empty()) {
let mut fields = line.splitn(2, '\t');
let names = fields.next().unwrap_or("");
let target = fields.next().unwrap_or("");
for name in decode_name_list(names) {
out.push(Bookmark {
name,
target: target.to_string(),
});
}
}
out
}
pub(crate) fn parse_resolve_list(output: &[u8]) -> Vec<PathBuf> {
output
.split(|&b| b == b'\n')
.filter_map(|line| {
let cut = find_subslice(line, b" ").unwrap_or(line.len());
let path = line[..cut].trim_ascii();
if path.is_empty() {
return None;
}
Some(path_from_bytes(&normalize_slashes(path)))
})
.collect()
}
pub(crate) fn workspace_root_from_bytes(stdout: &[u8]) -> PathBuf {
let end = stdout
.iter()
.rposition(|&b| b != b'\n' && b != b'\r')
.map_or(0, |i| i + 1);
path_from_bytes(&stdout[..end])
}
fn normalize_slashes(path: &[u8]) -> Vec<u8> {
path.iter()
.map(|&b| if b == b'\\' { b'/' } else { b })
.collect()
}
fn find_subslice(hay: &[u8], needle: &[u8]) -> Option<usize> {
if needle.is_empty() || needle.len() > hay.len() {
return None;
}
hay.windows(needle.len()).position(|w| w == needle)
}
pub(crate) fn parse_workspaces(output: &str) -> Vec<Workspace> {
output
.lines()
.filter(|line| !line.is_empty())
.filter_map(|line| {
let mut fields = line.split('\t');
let name = decode_json_field(fields.next()?);
let commit = fields.next().unwrap_or("").to_string();
let bookmarks = decode_name_list(fields.next().unwrap_or(""));
Some(Workspace {
name,
commit,
bookmarks,
})
})
.collect()
}
pub(crate) fn parse_diff_summary(output: &[u8]) -> Vec<ChangedPath> {
output
.split(|&b| b == b'\n')
.filter(|line| !line.is_empty())
.filter_map(|line| {
let status = *line.first()? as char;
if line.get(1) != Some(&b' ') {
return None;
}
let raw = &line[2..];
if raw.is_empty() {
return None;
}
let (old_path, path) = if matches!(status, 'R' | 'C') {
let (old, new) = expand_rename(raw);
let (old, new) = (normalize_slashes(&old), normalize_slashes(&new));
(
(old != new).then(|| path_from_bytes(&old)),
path_from_bytes(&new),
)
} else {
(None, path_from_bytes(&normalize_slashes(raw)))
};
Some(ChangedPath {
status,
path,
old_path,
})
})
.collect()
}
fn expand_rename(raw: &[u8]) -> (Vec<u8>, Vec<u8>) {
let plain = || (raw.to_vec(), raw.to_vec());
let (Some(open), Some(close)) = (
raw.iter().position(|&b| b == b'{'),
raw.iter().position(|&b| b == b'}'),
) else {
return plain();
};
if open >= close {
return plain();
}
let Some(rel) = find_subslice(&raw[open..close], b" => ") else {
return plain();
};
let arrow = open + rel;
let prefix = &raw[..open];
let left = &raw[open + 1..arrow];
let right = &raw[arrow + 4..close];
let suffix = &raw[close + 1..];
(
[prefix, left, suffix].concat(),
[prefix, right, suffix].concat(),
)
}
pub(crate) fn parse_diff_stat(output: &str) -> DiffStat {
let summary = output
.lines()
.rev()
.find(|line| line.contains("changed"))
.unwrap_or("");
DiffStat::parse(summary)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn jj_version_parses_real_world_shapes() {
let v = parse_jj_version("jj 0.38.0").unwrap();
assert_eq!((v.major, v.minor, v.patch), (0, 38, 0));
let v = parse_jj_version("jj 0.39.0-dev+abc123").unwrap();
assert_eq!((v.major, v.minor, v.patch), (0, 39, 0));
let v = parse_jj_version("jj 1.2").unwrap();
assert_eq!(v.patch, 0, "missing patch defaults to 0");
assert!(parse_jj_version("jj 0.37.9").unwrap() < parse_jj_version("jj 0.38.0").unwrap());
assert!(parse_jj_version("jj").is_none());
}
#[test]
fn operations_split_tab_fields() {
let out = "abc123\t\"user@host\"\t2026-06-05T10:00:00+02:00\t\"new empty commit\"\n\
def456\t\"user@host\"\t2026-06-05T09:59:00+02:00\t\"describe commit\\twith tab\"\n";
let ops = parse_operations(out);
assert_eq!(ops.len(), 2);
assert_eq!(ops[0].id, "abc123");
assert_eq!(ops[0].user, "user@host");
assert_eq!(ops[0].time, "2026-06-05T10:00:00+02:00");
assert_eq!(ops[0].description, "new empty commit");
assert_eq!(ops[1].description, "describe commit\twith tab");
}
#[test]
fn op_parents_reads_id_and_parent_count() {
let out = "merge9\t2\nmine01\t1\npre000\t1\n";
let rows = parse_op_parents(out);
assert_eq!(
rows,
vec![
("merge9".to_string(), 2),
("mine01".to_string(), 1),
("pre000".to_string(), 1),
]
);
let short = parse_op_parents("abc123\n");
assert_eq!(short, vec![("abc123".to_string(), 0)]);
assert!(parse_op_parents("").is_empty());
}
#[test]
fn annotate_rows_carry_line_numbers() {
let out = "kxoyzabc\tfn main() {\nkxoyzabc\t}\nqlmnopqr\t// added later";
let lines = parse_annotate(out);
assert_eq!(lines.len(), 3);
assert_eq!(lines[0].change_id, "kxoyzabc");
assert_eq!(lines[0].line, 1);
assert_eq!(lines[0].content, "fn main() {");
assert_eq!(lines[2].change_id, "qlmnopqr");
assert_eq!(lines[2].line, 3);
assert!(parse_annotate("").is_empty());
}
#[test]
fn annotate_preserves_cr_and_ignores_trailing_newline() {
let out = "kxoyzabc\tfn main() {\r\nkxoyzabc\t}\r\n";
let lines = parse_annotate(out);
assert_eq!(lines.len(), 2, "no phantom row from the trailing newline");
assert_eq!(lines[0].content, "fn main() {\r", "CR preserved");
assert_eq!((lines[1].line, lines[1].content.as_str()), (2, "}\r"));
}
#[test]
fn evolog_rows_parse_as_changes() {
let out = "kz\t38\tfalse\t\"feat: parser\"\nkz\t12\ttrue\t\"\"\n";
let changes = parse_changes(out);
assert_eq!(changes.len(), 2);
assert_eq!(changes[0].description, "feat: parser");
assert!(changes[1].empty);
}
#[test]
fn changes_split_tab_fields() {
let input = "kztuxlro\t38e00654\tfalse\t\"feat: stuff\"\nqpvuntsm\t6ecf997f\ttrue\t\"\"\n";
let got = parse_changes(input);
assert_eq!(got.len(), 2);
assert_eq!(
got[0],
Change {
change_id: "kztuxlro".into(),
commit_id: "38e00654".into(),
empty: false,
description: "feat: stuff".into(),
}
);
assert!(got[1].empty);
assert_eq!(got[1].description, "");
}
#[test]
fn changes_keep_tab_in_description() {
let got = parse_changes("kztuxlro\t38e00654\tfalse\t\"col1\\tcol2\"\n");
assert_eq!(got.len(), 1);
assert_eq!(got[0].description, "col1\tcol2");
}
#[test]
fn reachable_bookmarks_fan_out_per_name() {
let got = parse_reachable_bookmarks("\"main\" \"feat\"\tabc123\n\tdef456\n");
assert_eq!(
got,
vec![
Bookmark {
name: "main".into(),
target: "abc123".into()
},
Bookmark {
name: "feat".into(),
target: "abc123".into()
},
]
);
}
#[test]
fn decode_json_field_reverses_escapes() {
assert_eq!(decode_json_field("\"plain\""), "plain");
assert_eq!(decode_json_field("\"co,mma\""), "co,mma");
assert_eq!(decode_json_field("\"a\\tb\""), "a\tb");
assert_eq!(decode_json_field("\"line\\ntwo\""), "line\ntwo");
assert_eq!(decode_json_field("\"q\\\"q\""), "q\"q");
assert_eq!(decode_json_field("\"back\\\\slash\""), "back\\slash");
assert_eq!(decode_json_field("\"\\u0009tab\""), "\ttab"); assert_eq!(decode_json_field("\"caf\u{00e9}\""), "caf\u{00e9}"); assert_eq!(decode_json_field("\"\""), ""); assert_eq!(decode_json_field("f5d07685"), "f5d07685");
assert_eq!(decode_json_field(""), "");
}
#[test]
fn decode_name_list_splits_and_decodes() {
assert_eq!(decode_name_list("\"main\" \"feat\""), vec!["main", "feat"]);
assert_eq!(decode_name_list("\"co,mma\""), vec!["co,mma"]);
assert!(decode_name_list("").is_empty());
assert_eq!(
first_bookmark_name("\"co,mma\" \"main\""),
Some("co,mma".to_string())
);
assert_eq!(first_bookmark_name(""), None);
assert_eq!(first_bookmark_name("\n"), None);
}
#[test]
fn workspaces_round_trip_exotic_names() {
let input = "\"ta\\tb\"\tc0ffee\t\"co,mma\" \"pl/ain\"\n";
let got = parse_workspaces(input);
assert_eq!(got.len(), 1);
assert_eq!(
got[0].name, "ta\tb",
"the interior tab is decoded, not split on"
);
assert_eq!(got[0].commit, "c0ffee");
assert_eq!(
got[0].bookmarks,
vec!["co,mma".to_string(), "pl/ain".to_string()]
);
}
#[test]
fn full_ids_disambiguate_a_shared_short_prefix() {
let a = "abcdef0123456789abcdef0123456789abcdef01";
let b = "abcdef0123456789ffffffffffffffffffffffff"; let bms = parse_bookmarks(&format!("1\t\t\"one\"\t{a}\n1\t\t\"two\"\t{b}\n"));
assert_eq!(bms[0].target, a);
assert_eq!(bms[1].target, b);
assert_ne!(bms[0].target, bms[1].target, "full ids must not collide");
let ws = parse_workspaces(&format!("\"w1\"\t{a}\t\n\"w2\"\t{b}\t\n"));
assert_ne!(ws[0].commit, ws[1].commit);
}
#[test]
fn resolve_list_extracts_paths_before_description() {
let got = parse_resolve_list(
b"src/a.rs 2-sided conflict\nb.txt 2-sided conflict including 1 deletion\n",
);
assert_eq!(got, vec![PathBuf::from("src/a.rs"), PathBuf::from("b.txt")]);
assert!(parse_resolve_list(b"").is_empty());
assert_eq!(
parse_resolve_list(b"sub\\c.txt 2-sided conflict\n"),
vec![PathBuf::from("sub/c.txt")]
);
}
#[cfg(unix)]
#[test]
fn resolve_list_preserves_non_utf8_path_bytes() {
use std::os::unix::ffi::OsStrExt;
let got = parse_resolve_list(b"caf\xff.txt 2-sided conflict\n");
assert_eq!(got.len(), 1);
assert_eq!(got[0].as_os_str().as_bytes(), b"caf\xff.txt");
}
#[test]
fn workspace_root_strips_only_the_trailing_line_terminator() {
assert_eq!(
workspace_root_from_bytes(b"/repo/ws\n"),
PathBuf::from("/repo/ws")
);
assert_eq!(
workspace_root_from_bytes(b"/repo/ws\r\n"),
PathBuf::from("/repo/ws")
);
assert_eq!(
workspace_root_from_bytes(b"/repo/ws"),
PathBuf::from("/repo/ws")
);
assert_eq!(workspace_root_from_bytes(b"\n"), PathBuf::new());
}
#[cfg(unix)]
#[test]
fn workspace_root_preserves_non_utf8_and_trailing_space() {
use std::os::unix::ffi::OsStrExt;
let got = workspace_root_from_bytes(b"/repo/ws-caf\xff \n");
assert_eq!(got.as_os_str().as_bytes(), b"/repo/ws-caf\xff ");
}
#[test]
fn bookmarks_parse_name_and_commit_from_template() {
let input = "1\t\t\"main\"\tf5d07685\n1\t\t\"feature\"\tdeadbeef\n";
let got = parse_bookmarks(input);
assert_eq!(
got,
vec![
Bookmark {
name: "main".into(),
target: "f5d07685".into()
},
Bookmark {
name: "feature".into(),
target: "deadbeef".into()
},
]
);
}
#[test]
fn bookmarks_filter_tombstones_but_keep_conflicted() {
let input = concat!(
"1\t\t\"live\"\tf5d07685\n", "0\t\t\"tomb\"\t\n", "1\torigin\t\"tomb\"\tdeadbeef\n", "1\t\t\"conflicted\"\t\n", "1\t\t\"co,mma\"\tcafef00d\n", "1\t\t\"\"\t\n", );
let got = parse_bookmarks(input);
assert_eq!(
got,
vec![
Bookmark {
name: "live".into(),
target: "f5d07685".into()
},
Bookmark {
name: "conflicted".into(),
target: String::new()
},
Bookmark {
name: "co,mma".into(),
target: "cafef00d".into()
},
],
"only live LOCAL bookmarks survive; the tombstone never looks alive"
);
}
#[test]
fn bookmarks_all_drops_empty_name_and_tombstone_rows() {
let input = concat!(
"1\t\"main\"\t\t1\tf5d07685\n", "1\t\"\"\torigin\t1\tdeadbeef\n", "1\t\"feat\"\torigin\t0\tcafef00d\n", "0\t\"gone\"\t\t0\t\n", );
let got = parse_bookmarks_all(input);
assert_eq!(
got,
vec![
BookmarkRef {
name: "main".into(),
remote: None,
target: "f5d07685".into(),
tracked: true,
},
BookmarkRef {
name: "feat".into(),
remote: Some("origin".into()),
target: "cafef00d".into(),
tracked: false,
},
],
"the empty-name and tombstone rows must contribute nothing"
);
}
#[test]
fn workspaces_split_tab_fields_and_bookmarks() {
let input = "\"default\"\te2aa3420\t\"main\" \"feature\"\n\"ws1\"\t12345678\t\n";
let got = parse_workspaces(input);
assert_eq!(got.len(), 2);
assert_eq!(
got[0],
Workspace {
name: "default".into(),
commit: "e2aa3420".into(),
bookmarks: vec!["main".into(), "feature".into()],
}
);
assert!(got[1].bookmarks.is_empty());
}
#[test]
fn diff_summary_splits_status_and_path() {
let got = parse_diff_summary(b"M src/lib.rs\nA new file.txt\nD gone.rs\n");
assert_eq!(got.len(), 3);
assert_eq!(got[0].status, 'M');
assert_eq!(got[1].path, PathBuf::from("new file.txt"));
assert!(got[1].old_path.is_none());
assert_eq!(got[2].status, 'D');
}
#[cfg(unix)]
#[test]
fn diff_summary_preserves_non_utf8_path_bytes() {
use std::os::unix::ffi::OsStrExt;
let got = parse_diff_summary(b"M caf\xff.txt\n");
assert_eq!(got.len(), 1);
assert_eq!(got[0].path.as_os_str().as_bytes(), b"caf\xff.txt");
}
#[test]
fn diff_summary_expands_rename_and_copy() {
let got =
parse_diff_summary(b"R {old.rs => new.rs}\nC sub/{a.rs => b.rs}\nM lit{eral}.rs\n");
assert_eq!(got[0].status, 'R');
assert_eq!(got[0].path, PathBuf::from("new.rs"));
assert_eq!(
got[0].old_path.as_deref(),
Some(PathBuf::from("old.rs").as_path())
);
assert_eq!(got[1].path, PathBuf::from("sub/b.rs"));
assert_eq!(
got[1].old_path.as_deref(),
Some(PathBuf::from("sub/a.rs").as_path())
);
assert_eq!(got[2].path, PathBuf::from("lit{eral}.rs"));
assert!(got[2].old_path.is_none());
}
#[test]
fn diff_summary_normalises_backslash_separators() {
let got = parse_diff_summary(b"M deep\\nested\\f.rs\nR win\\{a.rs => b.rs}\n");
assert_eq!(got[0].path, PathBuf::from("deep/nested/f.rs"));
assert_eq!(got[1].path, PathBuf::from("win/b.rs"));
assert_eq!(
got[1].old_path.as_deref(),
Some(PathBuf::from("win/a.rs").as_path())
);
}
#[test]
fn diff_stat_parses_footer_among_per_file_lines() {
let input = "README.md | 10 +++---\n\
src/lib.rs | 4 +-\n\
4 files changed, 157 insertions(+), 137 deletions(-)\n";
assert_eq!(parse_diff_stat(input), DiffStat::new(4, 157, 137));
assert_eq!(parse_diff_stat(""), DiffStat::default());
}
}
#[cfg(test)]
mod proptests {
use super::*;
use proptest::prelude::*;
fn structured_line() -> impl Strategy<Value = String> {
prop_oneof![
Just("M src/a.rs\n".to_string()),
Just("R sub\\{old.rs => new.rs}\n".to_string()),
Just("C {a => b}.rs\n".to_string()),
"[A-Z] \\{[a-zé]{0,6} => [a-zé]{0,6}\\}\n", "[a-zé]{0,8}\t[a-zé]{0,8}\t(true|false)\t[a-zé\t]{0,10}\n", "[a-zé]{0,8}\t[a-zé@]{0,8}\t[01]\t[a-zé]{0,8}\n", "[-+ ]?[a-zé]{0,10}\n", ]
}
fn structured_doc() -> impl Strategy<Value = String> {
prop::collection::vec(structured_line(), 0..40).prop_map(|lines| lines.concat())
}
fn json_encode(s: &str) -> String {
let mut out = String::from("\"");
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
'\u{0008}' => out.push_str("\\b"),
'\u{000C}' => out.push_str("\\f"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out.push('"');
out
}
proptest! {
#[test]
fn json_field_round_trips(s in any::<String>()) {
prop_assert_eq!(decode_json_field(&json_encode(&s)), s);
}
#[test]
fn change_row_round_trips(desc in any::<String>()) {
let first: String = desc.split(['\n', '\r']).next().unwrap_or("").to_string();
let row = format!("chg12345678\tcmt87654321\tfalse\t{}\n", json_encode(&first));
let got = parse_changes(&row);
prop_assert_eq!(got.len(), 1);
prop_assert_eq!(got[0].change_id.as_str(), "chg12345678");
prop_assert_eq!(got[0].commit_id.as_str(), "cmt87654321");
prop_assert!(!got[0].empty);
prop_assert_eq!(&got[0].description, &first);
}
#[test]
fn name_list_round_trips(names in prop::collection::vec("[a-z,./-]{1,8}", 0..6)) {
let field = names.iter().map(|n| json_encode(n)).collect::<Vec<_>>().join(" ");
prop_assert_eq!(decode_name_list(&field), names);
}
#[test]
fn parsers_never_panic_on_arbitrary_text(s in any::<String>()) {
let _ = parse_changes(&s);
let _ = parse_operations(&s);
let _ = parse_annotate(&s);
let _ = parse_bookmarks(&s);
let _ = parse_bookmarks_all(&s);
let _ = parse_reachable_bookmarks(&s);
let _ = parse_resolve_list(s.as_bytes());
let _ = parse_workspaces(&s);
let _ = parse_diff_summary(s.as_bytes());
let _ = parse_diff_stat(&s);
let _ = parse_jj_version(&s);
let _ = expand_rename(s.as_bytes());
}
#[test]
fn byte_parsers_never_panic_on_arbitrary_bytes(b in any::<Vec<u8>>()) {
let _ = parse_resolve_list(&b);
let _ = parse_diff_summary(&b);
let _ = expand_rename(&b);
let _ = workspace_root_from_bytes(&b);
}
#[test]
fn parsers_never_panic_on_structured_text(s in structured_doc()) {
let _ = parse_diff_summary(s.as_bytes());
let _ = parse_changes(&s);
let _ = parse_bookmarks_all(&s);
}
#[test]
fn expand_rename_is_identity_without_braces(s in "[a-zé/ ]{0,20}") {
prop_assume!(!s.contains('{') && !s.contains('}'));
let bytes = s.into_bytes();
prop_assert_eq!(expand_rename(&bytes), (bytes.clone(), bytes));
}
}
}