1use vcs_diff::DiffStat;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12#[non_exhaustive]
13pub struct Change {
14 pub change_id: String,
16 pub commit_id: String,
18 pub empty: bool,
20 pub description: String,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
26#[non_exhaustive]
27pub struct Bookmark {
28 pub name: String,
30 pub target: String,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
36#[non_exhaustive]
37pub struct BookmarkRef {
38 pub name: String,
40 pub remote: Option<String>,
42 pub target: String,
44 pub tracked: bool,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
50#[non_exhaustive]
51pub struct Workspace {
52 pub name: String,
54 pub commit: String,
56 pub bookmarks: Vec<String>,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
64#[non_exhaustive]
65pub struct ChangedPath {
66 pub status: char,
69 pub path: String,
71 pub old_path: Option<String>,
73}
74
75pub(crate) const CHANGE_TEMPLATE: &str = "change_id.short() ++ \"\\t\" ++ commit_id.short() ++ \"\\t\" ++ if(empty, \"true\", \"false\") ++ \"\\t\" ++ description.first_line() ++ \"\\n\"";
77
78pub(crate) const WORKSPACE_TEMPLATE: &str = "name ++ \"\\t\" ++ target.commit_id().short() ++ \"\\t\" ++ target.local_bookmarks().map(|b| b.name()).join(\",\") ++ \"\\n\"";
80
81pub(crate) const BOOKMARKS_TEMPLATE: &str = "local_bookmarks.map(|b| b.name()).join(\",\")";
84
85pub(crate) const BOOKMARK_ALL_TEMPLATE: &str = "name ++ \"\\t\" ++ remote ++ \"\\t\" ++ if(tracked, \"1\", \"0\") ++ \"\\t\" ++ if(normal_target, normal_target.commit_id().short(), \"\") ++ \"\\n\"";
88
89pub(crate) const CONFLICT_TEMPLATE: &str = "if(conflict, \"1\", \"0\")";
91
92pub(crate) const COUNT_TEMPLATE: &str = "commit_id.short() ++ \"\\n\"";
95
96pub(crate) const REACHABLE_BOOKMARKS_TEMPLATE: &str =
100 "local_bookmarks.map(|b| b.name()).join(\" \") ++ \"\\t\" ++ commit_id.short() ++ \"\\n\"";
101
102pub(crate) fn parse_jj_version(raw: &str) -> Option<vcs_diff::Version> {
106 vcs_diff::parse_dotted_version(raw)
107}
108
109pub(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() ++ \"\\n\"";
113
114pub(crate) const OP_TEMPLATE: &str = "id.short() ++ \"\\t\" ++ user ++ \"\\t\" ++ time.start().format(\"%Y-%m-%dT%H:%M:%S%z\") ++ \"\\t\" ++ description.first_line() ++ \"\\n\"";
117
118pub(crate) const ANNOTATE_TEMPLATE: &str = "commit.change_id().short() ++ \"\\t\" ++ content";
122
123#[derive(Debug, Clone, PartialEq, Eq)]
125#[non_exhaustive]
126pub struct Operation {
127 pub id: String,
129 pub user: String,
132 pub time: String,
134 pub description: String,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq)]
140#[non_exhaustive]
141pub struct AnnotationLine {
142 pub change_id: String,
144 pub line: u32,
146 pub content: String,
148}
149
150pub(crate) fn parse_operations(output: &str) -> Vec<Operation> {
152 output
153 .lines()
154 .filter(|line| !line.is_empty())
155 .filter_map(|line| {
156 let mut fields = line.splitn(4, '\t');
158 Some(Operation {
159 id: fields.next()?.to_string(),
160 user: fields.next()?.to_string(),
161 time: fields.next()?.to_string(),
162 description: fields.next().unwrap_or("").to_string(),
163 })
164 })
165 .collect()
166}
167
168pub(crate) fn parse_annotate(output: &str) -> Vec<AnnotationLine> {
171 output
172 .lines()
173 .enumerate()
174 .filter_map(|(idx, line)| {
175 let (change_id, content) = line.split_once('\t')?;
176 Some(AnnotationLine {
177 change_id: change_id.to_string(),
178 line: (idx + 1) as u32,
179 content: content.to_string(),
180 })
181 })
182 .collect()
183}
184
185pub(crate) fn parse_changes(output: &str) -> Vec<Change> {
187 output
188 .lines()
189 .filter(|line| !line.is_empty())
190 .filter_map(|line| {
191 let mut fields = line.splitn(4, '\t');
194 let change_id = fields.next()?.to_string();
195 let commit_id = fields.next()?.to_string();
196 let empty = fields.next()? == "true";
197 let description = fields.next().unwrap_or("").to_string();
198 Some(Change {
199 change_id,
200 commit_id,
201 empty,
202 description,
203 })
204 })
205 .collect()
206}
207
208pub(crate) fn parse_bookmarks(output: &str) -> Vec<Bookmark> {
212 output
213 .lines()
214 .filter(|line| !line.is_empty() && !line.starts_with(char::is_whitespace))
215 .filter_map(|line| {
216 let (name, rest) = line.split_once(':')?;
217 let mut tokens = rest.split_whitespace();
220 let target = tokens
221 .nth(1)
222 .or_else(|| rest.split_whitespace().next())
223 .unwrap_or("")
224 .to_string();
225 Some(Bookmark {
226 name: name.trim().to_string(),
227 target,
228 })
229 })
230 .collect()
231}
232
233pub(crate) fn parse_bookmarks_all(output: &str) -> Vec<BookmarkRef> {
236 output
237 .lines()
238 .filter(|line| !line.is_empty())
239 .filter_map(|line| {
240 let mut fields = line.split('\t');
241 let name = fields.next()?.to_string();
242 let remote = fields.next().unwrap_or("");
243 let tracked = fields.next() == Some("1");
244 let target = fields.next().unwrap_or("").to_string();
245 Some(BookmarkRef {
246 name,
247 remote: (!remote.is_empty()).then(|| remote.to_string()),
248 target,
249 tracked,
250 })
251 })
252 .collect()
253}
254
255pub(crate) fn parse_reachable_bookmarks(output: &str) -> Vec<Bookmark> {
260 let mut out = Vec::new();
261 for line in output.lines().filter(|l| !l.is_empty()) {
262 let mut fields = line.splitn(2, '\t');
263 let names = fields.next().unwrap_or("");
264 let target = fields.next().unwrap_or("");
265 for name in names.split_whitespace() {
266 out.push(Bookmark {
267 name: name.to_string(),
268 target: target.to_string(),
269 });
270 }
271 }
272 out
273}
274
275pub(crate) fn parse_resolve_list(output: &str) -> Vec<String> {
280 output
281 .lines()
282 .filter_map(|line| {
283 let path = line.split(" ").next().unwrap_or(line).trim();
284 (!path.is_empty()).then(|| path.replace('\\', "/"))
285 })
286 .collect()
287}
288
289pub(crate) fn parse_workspaces(output: &str) -> Vec<Workspace> {
292 output
293 .lines()
294 .filter(|line| !line.is_empty())
295 .filter_map(|line| {
296 let mut fields = line.split('\t');
297 let name = fields.next()?.to_string();
298 let commit = fields.next().unwrap_or("").to_string();
299 let bookmarks = fields
300 .next()
301 .unwrap_or("")
302 .split(',')
303 .filter(|s| !s.is_empty())
304 .map(str::to_string)
305 .collect();
306 Some(Workspace {
307 name,
308 commit,
309 bookmarks,
310 })
311 })
312 .collect()
313}
314
315pub(crate) fn parse_diff_summary(output: &str) -> Vec<ChangedPath> {
322 let normalize = |p: String| p.replace('\\', "/");
323 output
324 .lines()
325 .filter(|line| !line.is_empty())
326 .filter_map(|line| {
327 let mut chars = line.chars();
328 let status = chars.next()?;
329 let raw = chars.as_str().strip_prefix(' ')?;
331 if raw.is_empty() {
332 return None;
333 }
334 let (old_path, path) = if matches!(status, 'R' | 'C') {
335 let (old, new) = expand_rename(raw);
336 (Some(normalize(old)), normalize(new))
337 } else {
338 (None, normalize(raw.to_string()))
339 };
340 Some(ChangedPath {
341 status,
342 path,
343 old_path,
344 })
345 })
346 .collect()
347}
348
349fn expand_rename(raw: &str) -> (String, String) {
353 let plain = || (raw.to_string(), raw.to_string());
354 let (Some(open), Some(close)) = (raw.find('{'), raw.find('}')) else {
357 return plain();
358 };
359 if open >= close {
360 return plain();
361 }
362 let Some(rel) = raw[open..close].find(" => ") else {
363 return plain();
364 };
365 let arrow = open + rel;
366 let prefix = &raw[..open];
367 let left = &raw[open + 1..arrow];
368 let right = &raw[arrow + 4..close];
369 let suffix = &raw[close + 1..];
370 (
371 format!("{prefix}{left}{suffix}"),
372 format!("{prefix}{right}{suffix}"),
373 )
374}
375
376pub(crate) fn parse_diff_stat(output: &str) -> DiffStat {
380 let summary = output
381 .lines()
382 .rev()
383 .find(|line| line.contains("changed"))
384 .unwrap_or("");
385 let mut stat = DiffStat::default();
386 for part in summary.split(',') {
387 let part = part.trim();
388 let n = part
389 .split_whitespace()
390 .next()
391 .and_then(|tok| tok.parse().ok())
392 .unwrap_or(0);
393 if part.contains("file") {
394 stat.files_changed = n;
395 } else if part.contains("insertion") {
396 stat.insertions = n;
397 } else if part.contains("deletion") {
398 stat.deletions = n;
399 }
400 }
401 stat
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407
408 #[test]
409 fn jj_version_parses_real_world_shapes() {
410 let v = parse_jj_version("jj 0.38.0").unwrap();
411 assert_eq!((v.major, v.minor, v.patch), (0, 38, 0));
412 let v = parse_jj_version("jj 0.39.0-dev+abc123").unwrap();
413 assert_eq!((v.major, v.minor, v.patch), (0, 39, 0));
414 let v = parse_jj_version("jj 1.2").unwrap();
415 assert_eq!(v.patch, 0, "missing patch defaults to 0");
416 assert!(parse_jj_version("jj 0.37.9").unwrap() < parse_jj_version("jj 0.38.0").unwrap());
418 assert!(parse_jj_version("jj").is_none());
419 }
420
421 #[test]
422 fn operations_split_tab_fields() {
423 let out = "abc123\tuser@host\t2026-06-05T10:00:00+0200\tnew empty commit\n\
424 def456\tuser@host\t2026-06-05T09:59:00+0200\tdescribe commit\twith tab\n";
425 let ops = parse_operations(out);
426 assert_eq!(ops.len(), 2);
427 assert_eq!(ops[0].id, "abc123");
428 assert_eq!(ops[0].user, "user@host");
429 assert_eq!(ops[0].time, "2026-06-05T10:00:00+0200");
430 assert_eq!(ops[0].description, "new empty commit");
431 assert_eq!(ops[1].description, "describe commit\twith tab");
433 }
434
435 #[test]
436 fn annotate_rows_carry_line_numbers() {
437 let out = "kxoyzabc\tfn main() {\nkxoyzabc\t}\nqlmnopqr\t// added later";
438 let lines = parse_annotate(out);
439 assert_eq!(lines.len(), 3);
440 assert_eq!(lines[0].change_id, "kxoyzabc");
441 assert_eq!(lines[0].line, 1);
442 assert_eq!(lines[0].content, "fn main() {");
443 assert_eq!(lines[2].change_id, "qlmnopqr");
444 assert_eq!(lines[2].line, 3);
445 assert!(parse_annotate("").is_empty());
446 }
447
448 #[test]
451 fn evolog_rows_parse_as_changes() {
452 let out = "kz\t38\tfalse\tfeat: parser\nkz\t12\ttrue\t\n";
453 let changes = parse_changes(out);
454 assert_eq!(changes.len(), 2);
455 assert_eq!(changes[0].description, "feat: parser");
456 assert!(changes[1].empty);
457 }
458
459 #[test]
460 fn changes_split_tab_fields() {
461 let input = "kztuxlro\t38e00654\tfalse\tfeat: stuff\nqpvuntsm\t6ecf997f\ttrue\t\n";
462 let got = parse_changes(input);
463 assert_eq!(got.len(), 2);
464 assert_eq!(
465 got[0],
466 Change {
467 change_id: "kztuxlro".into(),
468 commit_id: "38e00654".into(),
469 empty: false,
470 description: "feat: stuff".into(),
471 }
472 );
473 assert!(got[1].empty);
475 assert_eq!(got[1].description, "");
476 }
477
478 #[test]
481 fn changes_keep_tab_in_description() {
482 let got = parse_changes("kztuxlro\t38e00654\tfalse\tcol1\tcol2\n");
483 assert_eq!(got.len(), 1);
484 assert_eq!(got[0].description, "col1\tcol2");
485 }
486
487 #[test]
490 fn reachable_bookmarks_fan_out_per_name() {
491 let got = parse_reachable_bookmarks("main feat\tabc123\n\tdef456\n");
492 assert_eq!(
493 got,
494 vec![
495 Bookmark {
496 name: "main".into(),
497 target: "abc123".into()
498 },
499 Bookmark {
500 name: "feat".into(),
501 target: "abc123".into()
502 },
503 ]
504 );
505 }
506
507 #[test]
508 fn resolve_list_extracts_paths_before_description() {
509 let got = parse_resolve_list(
510 "src/a.rs 2-sided conflict\nb.txt 2-sided conflict including 1 deletion\n",
511 );
512 assert_eq!(got, vec!["src/a.rs".to_string(), "b.txt".to_string()]);
513 assert!(parse_resolve_list("").is_empty());
514 assert_eq!(
516 parse_resolve_list("sub\\c.txt 2-sided conflict\n"),
517 vec!["sub/c.txt".to_string()]
518 );
519 }
520
521 #[test]
522 fn bookmarks_parse_name_and_commit_and_skip_remotes() {
523 let input = "main: pzlznprr f5d07685 feat(process): job-backed spawn\n @origin: pzlznprr f5d07685 feat(process)\nfeature: abcd1234 deadbeef wip\n";
524 let got = parse_bookmarks(input);
525 assert_eq!(
526 got,
527 vec![
528 Bookmark {
529 name: "main".into(),
530 target: "f5d07685".into()
531 },
532 Bookmark {
533 name: "feature".into(),
534 target: "deadbeef".into()
535 },
536 ]
537 );
538 }
539
540 #[test]
541 fn workspaces_split_tab_fields_and_bookmarks() {
542 let input = "default\te2aa3420\tmain,feature\nws1\t12345678\t\n";
543 let got = parse_workspaces(input);
544 assert_eq!(got.len(), 2);
545 assert_eq!(
546 got[0],
547 Workspace {
548 name: "default".into(),
549 commit: "e2aa3420".into(),
550 bookmarks: vec!["main".into(), "feature".into()],
551 }
552 );
553 assert!(got[1].bookmarks.is_empty());
555 }
556
557 #[test]
558 fn diff_summary_splits_status_and_path() {
559 let got = parse_diff_summary("M src/lib.rs\nA new file.txt\nD gone.rs\n");
560 assert_eq!(got.len(), 3);
561 assert_eq!(got[0].status, 'M');
562 assert_eq!(got[1].path, "new file.txt");
563 assert!(got[1].old_path.is_none());
564 assert_eq!(got[2].status, 'D');
565 }
566
567 #[test]
571 fn diff_summary_expands_rename_and_copy() {
572 let got =
573 parse_diff_summary("R {old.rs => new.rs}\nC sub/{a.rs => b.rs}\nM lit{eral}.rs\n");
574 assert_eq!(got[0].status, 'R');
575 assert_eq!(got[0].path, "new.rs");
576 assert_eq!(got[0].old_path.as_deref(), Some("old.rs"));
577 assert_eq!(got[1].path, "sub/b.rs");
578 assert_eq!(got[1].old_path.as_deref(), Some("sub/a.rs"));
579 assert_eq!(got[2].path, "lit{eral}.rs");
581 assert!(got[2].old_path.is_none());
582 }
583
584 #[test]
587 fn diff_summary_normalises_backslash_separators() {
588 let got = parse_diff_summary("M deep\\nested\\f.rs\nR win\\{a.rs => b.rs}\n");
589 assert_eq!(got[0].path, "deep/nested/f.rs");
590 assert_eq!(got[1].path, "win/b.rs");
591 assert_eq!(got[1].old_path.as_deref(), Some("win/a.rs"));
592 }
593
594 #[test]
595 fn diff_stat_parses_footer_among_per_file_lines() {
596 let input = "README.md | 10 +++---\n\
597 src/lib.rs | 4 +-\n\
598 4 files changed, 157 insertions(+), 137 deletions(-)\n";
599 assert_eq!(parse_diff_stat(input), DiffStat::new(4, 157, 137));
600 assert_eq!(parse_diff_stat(""), DiffStat::default());
601 }
602}
603
604#[cfg(test)]
608mod proptests {
609 use super::*;
610 use proptest::prelude::*;
611
612 fn structured_line() -> impl Strategy<Value = String> {
615 prop_oneof![
616 Just("M src/a.rs\n".to_string()),
617 Just("R sub\\{old.rs => new.rs}\n".to_string()),
618 Just("C {a => b}.rs\n".to_string()),
619 "[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", ]
624 }
625
626 fn structured_doc() -> impl Strategy<Value = String> {
627 prop::collection::vec(structured_line(), 0..40).prop_map(|lines| lines.concat())
628 }
629
630 proptest! {
631 #[test]
632 fn parsers_never_panic_on_arbitrary_text(s in any::<String>()) {
633 let _ = parse_changes(&s);
634 let _ = parse_operations(&s);
635 let _ = parse_annotate(&s);
636 let _ = parse_bookmarks(&s);
637 let _ = parse_bookmarks_all(&s);
638 let _ = parse_reachable_bookmarks(&s);
639 let _ = parse_resolve_list(&s);
640 let _ = parse_workspaces(&s);
641 let _ = parse_diff_summary(&s);
642 let _ = parse_diff_stat(&s);
643 let _ = parse_jj_version(&s);
644 let _ = expand_rename(&s);
645 }
646
647 #[test]
648 fn parsers_never_panic_on_structured_text(s in structured_doc()) {
649 let _ = parse_diff_summary(&s);
650 let _ = parse_changes(&s);
651 let _ = parse_bookmarks_all(&s);
652 }
653
654 #[test]
657 fn expand_rename_is_identity_without_braces(s in "[a-zé/ ]{0,20}") {
658 prop_assume!(!s.contains('{') && !s.contains('}'));
659 prop_assert_eq!(expand_rename(&s), (s.clone(), s));
660 }
661 }
662}