Skip to main content

vcs_jj/
parse.rs

1//! Pure parsers for jj output. No process execution, so these tests are
2//! hermetic and run on CI.
3//!
4//! The git-format unified-diff model + parser and the version type live in the
5//! shared [`vcs_diff`] crate (`jj diff --git` and `git diff` are byte-identical);
6//! this module keeps only the jj-specific parsers (changes, bookmarks, op log, …).
7
8use vcs_diff::DiffStat;
9
10/// A jj change, parsed from a `\t`-delimited template row.
11#[derive(Debug, Clone, PartialEq, Eq)]
12#[non_exhaustive]
13pub struct Change {
14    /// Short change id (`change_id.short()`).
15    pub change_id: String,
16    /// Short commit id (`commit_id.short()`).
17    pub commit_id: String,
18    /// `true` when the change makes no file modifications.
19    pub empty: bool,
20    /// First line of the description (empty for an undescribed change).
21    pub description: String,
22}
23
24/// A jj bookmark, parsed from `jj bookmark list` output.
25#[derive(Debug, Clone, PartialEq, Eq)]
26#[non_exhaustive]
27pub struct Bookmark {
28    /// Bookmark name.
29    pub name: String,
30    /// Short id of the commit it points at.
31    pub target: String,
32}
33
34/// A bookmark from `jj bookmark list -a` — local *or* remote-tracking.
35#[derive(Debug, Clone, PartialEq, Eq)]
36#[non_exhaustive]
37pub struct BookmarkRef {
38    /// Bookmark name.
39    pub name: String,
40    /// The remote it lives on (e.g. `origin`/`git`); `None` for a local bookmark.
41    pub remote: Option<String>,
42    /// Short id of the commit it points at (empty for a conflicted bookmark).
43    pub target: String,
44    /// Whether this remote-tracking bookmark is tracked (`false` for locals).
45    pub tracked: bool,
46}
47
48/// A workspace from `jj workspace list` (rendered with `WORKSPACE_TEMPLATE`).
49#[derive(Debug, Clone, PartialEq, Eq)]
50#[non_exhaustive]
51pub struct Workspace {
52    /// Workspace name (`default` for the main one).
53    pub name: String,
54    /// Short commit id of the workspace's working-copy commit.
55    pub commit: String,
56    /// Local bookmarks pointing at that commit (empty when none).
57    pub bookmarks: Vec<String>,
58}
59
60/// One entry from `jj diff --summary`: a single-letter status (`M`/`A`/`D`/…)
61/// and the (forward-slash-normalised) path it applies to — the *new* path for a
62/// rename/copy, with the original on [`old_path`](ChangedPath::old_path).
63#[derive(Debug, Clone, PartialEq, Eq)]
64#[non_exhaustive]
65pub struct ChangedPath {
66    /// Status letter (`M` modified, `A` added, `D` deleted, `R` renamed,
67    /// `C` copied).
68    pub status: char,
69    /// The path the status applies to — the *new* path for a rename/copy.
70    pub path: String,
71    /// For a rename (`R`) or copy (`C`), the original path; `None` otherwise.
72    pub old_path: Option<String>,
73}
74
75/// Template used by the change commands: tab-separated, one change per line.
76pub(crate) const CHANGE_TEMPLATE: &str = "change_id.short() ++ \"\\t\" ++ commit_id.short() ++ \"\\t\" ++ if(empty, \"true\", \"false\") ++ \"\\t\" ++ description.first_line() ++ \"\\n\"";
77
78/// `jj workspace list -T` template: `name\t<commit>\t<bookmarks,comma-joined>`.
79pub(crate) const WORKSPACE_TEMPLATE: &str = "name ++ \"\\t\" ++ target.commit_id().short() ++ \"\\t\" ++ target.local_bookmarks().map(|b| b.name()).join(\",\") ++ \"\\n\"";
80
81/// `jj log -T` template rendering a commit's local bookmark names, comma-joined.
82/// Drives `current_bookmark`/`trunk`.
83pub(crate) const BOOKMARKS_TEMPLATE: &str = "local_bookmarks.map(|b| b.name()).join(\",\")";
84
85/// `jj bookmark list -a -T` template: `name\t<remote>\t<tracked 1/0>\t<commit>`,
86/// one row per local *and* remote-tracking bookmark.
87pub(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
89/// `jj log -T` template: `"1"` when the commit has a conflict, else `"0"`.
90pub(crate) const CONFLICT_TEMPLATE: &str = "if(conflict, \"1\", \"0\")";
91
92/// `jj log -T` template emitting one short commit id per line — for counting a
93/// revset.
94pub(crate) const COUNT_TEMPLATE: &str = "commit_id.short() ++ \"\\n\"";
95
96/// `jj log -T` template for [`reachable_bookmarks`](crate::JjApi::reachable_bookmarks):
97/// the commit's local bookmark names (space-joined; jj names can't contain spaces)
98/// then a tab then the short commit id.
99pub(crate) const REACHABLE_BOOKMARKS_TEMPLATE: &str =
100    "local_bookmarks.map(|b| b.name()).join(\" \") ++ \"\\t\" ++ commit_id.short() ++ \"\\n\"";
101
102/// Parse `jj --version` output (`jj 0.38.0`) into the shared
103/// [`vcs_diff::Version`]: the first dotted-numeric token wins; non-numeric
104/// trailers (`-dev`, build hashes) are ignored; a missing patch reads as `0`.
105pub(crate) fn parse_jj_version(raw: &str) -> Option<vcs_diff::Version> {
106    vcs_diff::parse_dotted_version(raw)
107}
108
109/// `jj evolog -T` template. Evolog renders in a *commit* context where the
110/// bare keywords (`change_id`, …) don't exist — the `commit.` method form is
111/// required. Columns mirror [`CHANGE_TEMPLATE`], so [`parse_changes`] reads it.
112pub(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
114/// `jj op log -T` template: `id\tuser\tstart-time\tdescription`, one row per
115/// operation.
116pub(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
118/// `jj file annotate -T` template: `change-id\tcontent`. Annotate emits one row
119/// per source line and separates them itself — no trailing `\n` here, or every
120/// row would be double-spaced.
121pub(crate) const ANNOTATE_TEMPLATE: &str = "commit.change_id().short() ++ \"\\t\" ++ content";
122
123/// One entry of `jj op log` (an operation-log row).
124#[derive(Debug, Clone, PartialEq, Eq)]
125#[non_exhaustive]
126pub struct Operation {
127    /// Short operation id — what `op restore`/`op undo` take.
128    pub id: String,
129    /// The OS-level `user@host` that ran the operation (not the configured
130    /// jj author).
131    pub user: String,
132    /// Start timestamp, ISO 8601 with offset.
133    pub time: String,
134    /// First line of the operation description, e.g. `new empty commit`.
135    pub description: String,
136}
137
138/// One line of `jj file annotate` output: which change last touched it.
139#[derive(Debug, Clone, PartialEq, Eq)]
140#[non_exhaustive]
141pub struct AnnotationLine {
142    /// Short change id of the change that introduced the line.
143    pub change_id: String,
144    /// Line number in the annotated file (1-based).
145    pub line: u32,
146    /// The line's content (without the trailing newline).
147    pub content: String,
148}
149
150/// Parse rows produced by [`OP_TEMPLATE`].
151pub(crate) fn parse_operations(output: &str) -> Vec<Operation> {
152    output
153        .lines()
154        .filter(|line| !line.is_empty())
155        .filter_map(|line| {
156            // `splitn(4)` keeps literal tabs inside the description.
157            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
168/// Parse rows produced by [`ANNOTATE_TEMPLATE`]: one row per source line, the
169/// 1-based line number is the row index.
170pub(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
185/// Parse rows produced by [`CHANGE_TEMPLATE`].
186pub(crate) fn parse_changes(output: &str) -> Vec<Change> {
187    output
188        .lines()
189        .filter(|line| !line.is_empty())
190        .filter_map(|line| {
191            // `splitn(4)` so the trailing description keeps any literal tabs it
192            // contains rather than being truncated at the first one.
193            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
208/// Parse `jj bookmark list` default output. Local bookmark lines look like
209/// `name: <change_id> <commit_id> <description>`; remote-tracking lines are
210/// indented and skipped.
211pub(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            // Tokens after the name are `<change_id> <commit_id> …`; take the
218            // commit id (2nd), falling back to whatever is present.
219            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
233/// Parse rows produced by [`BOOKMARK_ALL_TEMPLATE`]:
234/// `name\t<remote>\t<tracked 1/0>\t<commit>` per local/remote bookmark.
235pub(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
255/// Parse rows produced by [`REACHABLE_BOOKMARKS_TEMPLATE`]:
256/// `<name>[ <name>…]\t<commit>`. A commit with several bookmarks yields one
257/// [`Bookmark`] per name, all sharing that commit as the target. A row with no
258/// bookmark names (empty first field) contributes nothing.
259pub(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
275/// Parse `jj resolve --list` output: each line is a conflicted path left-aligned
276/// in a column, then a run of spaces, then a human conflict description. Take the
277/// path (the text before the first 2-space gap), forward-slash normalised (jj
278/// emits the OS-native separator here, like `--summary`).
279pub(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
289/// Parse rows produced by [`WORKSPACE_TEMPLATE`]: `name\t<commit>\t<bookmarks>`,
290/// where bookmarks are comma-joined (and may be empty).
291pub(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
315/// Parse `jj diff --summary`: each line is `<status-letter> <path>`. For a rename
316/// (`R`) or copy (`C`) jj renders the path as `prefix{old => new}suffix` rather than
317/// a plain path, so those are expanded into the real new path (and the old path is
318/// captured on [`ChangedPath::old_path`]). Paths are forward-slash normalised —
319/// jj's `--summary` uses the OS-native separator, unlike its `--git` diff (and git
320/// itself), so this keeps the unified DTO consistent across backends/platforms.
321pub(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            // Require the single separating space; the remainder is the raw path.
330            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
349/// Expand jj's rename/copy path form `prefix{left => right}suffix` into
350/// `(old, new)` full paths. Falls back to `(raw, raw)` when the brace/arrow form
351/// isn't present, so a plain path is returned unchanged.
352fn expand_rename(raw: &str) -> (String, String) {
353    let plain = || (raw.to_string(), raw.to_string());
354    // `{`, `}`, and ` => ` are ASCII, so these byte offsets land on char
355    // boundaries even when the surrounding path is non-ASCII.
356    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
376/// Parse the summary footer of `jj diff --stat`, e.g. `4 files changed, 157
377/// insertions(+), 137 deletions(-)` (same shape as git's `--shortstat`). The
378/// footer is the last line mentioning "changed"; no such line → all zeros.
379pub(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        // Ordering drives the supported-floor gate.
417        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        // A literal tab in the description survives (splitn keeps the tail).
432        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    // EVOLOG_TEMPLATE renders the same columns as CHANGE_TEMPLATE, so the rows
449    // flow through parse_changes unchanged.
450    #[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        // Undescribed, empty change.
474        assert!(got[1].empty);
475        assert_eq!(got[1].description, "");
476    }
477
478    // A literal tab inside the (first-line) description must not truncate it:
479    // `splitn(4)` keeps the remainder intact.
480    #[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    // A commit carrying several bookmarks fans out to one entry each, all sharing
488    // the commit; a bookmark-less row contributes nothing.
489    #[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        // OS-native backslash separators (Windows) are normalised to `/`.
515        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        // No bookmarks → empty vec, not [""].
554        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    // jj renders a rename/copy path as `prefix{old => new}suffix` (verified against
568    // jj 0.38); it must be expanded into the real new path with the old path
569    // captured — not stored raw. A plain `M`/`A`/`D` path is left untouched.
570    #[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        // A literal `{...}` in a non-rename path (no ` => `) is not mis-expanded.
580        assert_eq!(got[2].path, "lit{eral}.rs");
581        assert!(got[2].old_path.is_none());
582    }
583
584    // jj `--summary` emits OS-native separators (backslashes on Windows); paths are
585    // normalised to forward slashes to match the `--git` diff and the git backend.
586    #[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// Property-based fuzzing: pure parsers over arbitrary jj output must never
605// panic, with special attention to `expand_rename` (byte-offset arithmetic on
606// `{old => new}` braces) and the templated tab-row parsers.
607#[cfg(test)]
608mod proptests {
609    use super::*;
610    use proptest::prelude::*;
611
612    /// jj's structural vocabulary: `diff --summary` letters, brace renames
613    /// (incl. multibyte around the braces), template tab-rows, and diff text.
614    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", // rename braces + multibyte
620            "[a-zé]{0,8}\t[a-zé]{0,8}\t(true|false)\t[a-zé\t]{0,10}\n", // change row
621            "[a-zé]{0,8}\t[a-zé@]{0,8}\t[01]\t[a-zé]{0,8}\n", // bookmark row
622            "[-+ ]?[a-zé]{0,10}\n",                     // diff body
623        ]
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        // expand_rename returns the raw verbatim for a non-brace input (its
655        // documented identity for the no-rename case).
656        #[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}