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 for
6//! ASCII paths — they differ only in non-ASCII filename rendering, which the shared
7//! parser decodes); this module keeps only the jj-specific parsers (changes,
8//! bookmarks, op log, …).
9
10use std::path::PathBuf;
11
12use vcs_diff::{DiffStat, path_from_bytes};
13
14/// A jj change, parsed from a `\t`-delimited template row.
15#[derive(Debug, Clone, PartialEq, Eq)]
16#[non_exhaustive]
17pub struct Change {
18    /// Short change id (`change_id.short()`).
19    pub change_id: String,
20    /// Short commit id (`commit_id.short()`).
21    pub commit_id: String,
22    /// `true` when the change makes no file modifications.
23    pub empty: bool,
24    /// First line of the description (empty for an undescribed change).
25    pub description: String,
26}
27
28/// A jj bookmark, parsed from `jj bookmark list` output.
29#[derive(Debug, Clone, PartialEq, Eq)]
30#[non_exhaustive]
31pub struct Bookmark {
32    /// Bookmark name.
33    pub name: String,
34    /// **Full** commit id the bookmark points at — a stable identifier that can
35    /// be cross-referenced against a `RepoSnapshot.head` / git oid, not a
36    /// display-truncated prefix (T-041). Empty when the bookmark has no single
37    /// normal target (a conflicted bookmark, which is still *present*).
38    pub target: String,
39}
40
41/// A bookmark from `jj bookmark list -a` — local *or* remote-tracking.
42#[derive(Debug, Clone, PartialEq, Eq)]
43#[non_exhaustive]
44pub struct BookmarkRef {
45    /// Bookmark name.
46    pub name: String,
47    /// The remote it lives on (e.g. `origin`/`git`); `None` for a local bookmark.
48    pub remote: Option<String>,
49    /// **Full** commit id it points at (empty for a conflicted bookmark) — a
50    /// stable cross-referenceable id, not a display prefix (T-041).
51    pub target: String,
52    /// Whether this remote-tracking bookmark is tracked (`false` for locals).
53    pub tracked: bool,
54}
55
56/// A workspace from `jj workspace list` (rendered with `WORKSPACE_TEMPLATE`).
57#[derive(Debug, Clone, PartialEq, Eq)]
58#[non_exhaustive]
59pub struct Workspace {
60    /// Workspace name (`default` for the main one).
61    pub name: String,
62    /// **Full** commit id of the workspace's working-copy commit — the identity
63    /// the facade's `WorktreeInfo.commit` carries so it can be compared against a
64    /// `RepoSnapshot.head`; not a display-truncated prefix (T-041).
65    pub commit: String,
66    /// Local bookmarks pointing at that commit (empty when none).
67    pub bookmarks: Vec<String>,
68}
69
70/// One entry from `jj diff --summary`: a single-letter status (`M`/`A`/`D`/…)
71/// and the (forward-slash-normalised) path it applies to — the *new* path for a
72/// rename/copy, with the original on [`old_path`](ChangedPath::old_path).
73#[derive(Debug, Clone, PartialEq, Eq)]
74#[non_exhaustive]
75pub struct ChangedPath {
76    /// Status letter (`M` modified, `A` added, `D` deleted, `R` renamed,
77    /// `C` copied).
78    pub status: char,
79    /// The path the status applies to — the *new* path for a rename/copy. A
80    /// [`PathBuf`] built from the raw `jj diff --summary` bytes, so a non-UTF-8
81    /// filename (legal on Unix) survives losslessly instead of being flattened to
82    /// `U+FFFD` — the same platform-correct type `vcs_git::StatusEntry::path` uses.
83    pub path: PathBuf,
84    /// For a rename (`R`) or copy (`C`), the original path; `None` otherwise.
85    pub old_path: Option<PathBuf>,
86}
87
88// ---------------------------------------------------------------------------
89// Machine-template framing/escaping contract (T-041)
90// ---------------------------------------------------------------------------
91//
92// jj templates render into a byte stream we parse back into typed rows, so the
93// framing has to be *unambiguous* even for exotic names/descriptions (spaces,
94// commas, tabs, quotes, newlines — all of which jj permits somewhere: a git
95// bookmark name can carry a comma, a workspace name a tab/newline, a description
96// a tab). The single contract every machine template below obeys:
97//
98//   * **Rows** are separated by a literal `\n`; **fields** within a row by a
99//     literal `\t`.
100//   * A field that can hold arbitrary user text (a description, a bookmark or
101//     workspace *name*, an op-log user) is rendered through jj's `.escape_json()`
102//     — a standard JSON string literal (`"…"` with `\t`/`\n`/`\r`/`\"`/`\\`/`\uXXXX`
103//     escapes; raw UTF-8 otherwise, verified on jj 0.42). An escaped field can
104//     therefore never contain a literal `\t` or `\n`, so the tab/newline framing
105//     stays unambiguous, and [`decode_json_field`] recovers the exact original.
106//   * A **list** field (a commit's/workspace's local bookmark names) is the
107//     `.escape_json()` of each element joined by a single space. Bookmark names
108//     can never contain a space (a git-ref rule jj enforces), so the space-joined
109//     JSON strings split back apart cleanly ([`decode_name_list`]).
110//   * Structurally-constrained fields — hex ids, `0`/`1` and `true`/`false`
111//     flags, a `%:z` RFC-3339 timestamp, a remote name (no whitespace by git-ref
112//     rule) — are rendered raw; they cannot contain a separator.
113//
114// The lone documented exception is [`ANNOTATE_TEMPLATE`]: it streams raw file
115// *content* (one source line per row) as the sole trailing field, which cannot
116// contain a `\n` (rows are line-split) and whose interior tabs are preserved by a
117// single `split_once('\t')` — escaping every source line would be wasteful and
118// buys nothing there.
119//
120// **Full vs short ids.** Identity/cross-reference fields carry the *full* commit
121// id ([`Bookmark::target`], [`BookmarkRef::target`], [`Workspace::commit`], and
122// the snapshot head) so they can be matched against a git oid / `RepoSnapshot.head`
123// without a short-prefix collision. The one deliberately *short* surface is the
124// history-display [`Change`] (`jj log`'s own abbreviation), which is never used as
125// a cross-reference key.
126
127/// Template used by the change commands: tab-separated, one change per line. The
128/// change/commit ids stay `.short()` — [`Change`] is the history-display row, not
129/// an identity key — while the free-text description is `.escape_json()`-framed so
130/// a tab/quote in it round-trips (see the framing contract above).
131pub(crate) const CHANGE_TEMPLATE: &str = "change_id.short() ++ \"\\t\" ++ commit_id.short() ++ \"\\t\" ++ if(empty, \"true\", \"false\") ++ \"\\t\" ++ description.first_line().escape_json() ++ \"\\n\"";
132
133/// `jj workspace list -T` template: `"<name>"\t<full-commit>\t<bookmarks>`, where
134/// the name is `.escape_json()`-framed (a workspace name may hold a tab/newline),
135/// the commit is the **full** id (identity, see the contract), and the bookmarks
136/// are the space-joined `.escape_json()` of each local bookmark name.
137pub(crate) const WORKSPACE_TEMPLATE: &str = "name.escape_json() ++ \"\\t\" ++ target.commit_id() ++ \"\\t\" ++ target.local_bookmarks().map(|b| b.name().escape_json()).join(\" \") ++ \"\\n\"";
138
139/// `jj log -T` template rendering a commit's local bookmark names as space-joined
140/// `.escape_json()` strings (so a name with a comma survives — the old comma-join
141/// mangled it). Drives `current_bookmark`/`trunk` via [`first_bookmark_name`].
142pub(crate) const BOOKMARKS_TEMPLATE: &str =
143    "local_bookmarks.map(|b| b.name().escape_json()).join(\" \")";
144
145/// `jj bookmark list -a -T` template:
146/// `<present 1/0>\t"<name>"\t<remote>\t<tracked 1/0>\t<full-commit>`, one row per
147/// local *and* remote-tracking bookmark. `present` gates out a locally-deleted
148/// **tombstone** (a `bookmark delete` still shown because a remote tracks it), and
149/// the name is `.escape_json()`-framed. `remote` is raw (a remote name carries no
150/// whitespace).
151pub(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\"";
152
153/// `jj bookmark list -T` template (no `-a`):
154/// `<present 1/0>\t<remote>\t"<name>"\t<full-commit>`, one row per bookmark ref.
155/// Machine-parsed in place of jj's human-readable default, which interleaves the
156/// change id, description, and indented remote-tracking lines that drift with jj's
157/// display format. `present` + an empty `remote` let [`parse_bookmarks`] keep only
158/// *live local* bookmarks: a locally-deleted **tombstone** renders a `present=0`
159/// local row (dropped) plus a `present=1` `remote=<r>` row (dropped as non-local),
160/// so it never masquerades as an existing branch — while a *conflicted* bookmark,
161/// which is `present=1` with an empty target, is correctly kept (T-041).
162pub(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\"";
163
164/// `jj log -T` template: `"1"` when the commit has a conflict, else `"0"`.
165pub(crate) const CONFLICT_TEMPLATE: &str = "if(conflict, \"1\", \"0\")";
166
167/// `jj log -T` template emitting one short commit id per line — for counting a
168/// revset.
169pub(crate) const COUNT_TEMPLATE: &str = "commit_id.short() ++ \"\\n\"";
170
171/// `jj log -T` template for [`reachable_bookmarks`](crate::JjApi::reachable_bookmarks):
172/// the commit's local bookmark names as space-joined `.escape_json()` strings
173/// (so a comma/quote in a name round-trips), then a tab, then the **full** commit
174/// id (identity — see the framing contract).
175pub(crate) const REACHABLE_BOOKMARKS_TEMPLATE: &str = "local_bookmarks.map(|b| b.name().escape_json()).join(\" \") ++ \"\\t\" ++ commit_id ++ \"\\n\"";
176
177/// Parse `jj --version` output (`jj 0.38.0`) into the shared
178/// [`vcs_diff::Version`]: the first dotted-numeric token wins; non-numeric
179/// trailers (`-dev`, build hashes) are ignored; a missing patch reads as `0`.
180pub(crate) fn parse_jj_version(raw: &str) -> Option<vcs_diff::Version> {
181    vcs_diff::parse_dotted_version(raw)
182}
183
184/// `jj evolog -T` template. Evolog renders in a *commit* context where the
185/// bare keywords (`change_id`, …) don't exist — the `commit.` method form is
186/// required. Columns mirror [`CHANGE_TEMPLATE`] (`.escape_json()`-framed
187/// description included), so [`parse_changes`] reads it.
188pub(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\"";
189
190/// `jj op log -T` template: `id\t"<user>"\t<start-time>\t"<description>"`, one row
191/// per operation. The user and description are `.escape_json()`-framed (either can
192/// hold a tab); the id is short (what `op restore`/`op undo` accept) and the
193/// timestamp is a separator-free `%:z` RFC-3339.
194pub(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\"";
195
196/// `jj op log -T` template for the rollback **divergence probe**: `id\tparent-count`,
197/// one row per operation, newest first. A parent count `>= 2` marks a "reconcile
198/// divergent operations" merge — the fingerprint jj records when a *concurrent* jj
199/// process advanced the operation log, so a rollback walking this can refuse to
200/// revert that foreign work (see `Jj::rollback_to`). Kept minimal (no user/time)
201/// because the probe only needs the ancestry shape.
202pub(crate) const OP_PARENTS_TEMPLATE: &str = "id.short() ++ \"\\t\" ++ parents.len() ++ \"\\n\"";
203
204/// `jj file annotate -T` template: `change-id\tcontent`. Annotate emits one row
205/// per source line and separates them itself — no trailing `\n` here, or every
206/// row would be double-spaced. `content` is the framing contract's one documented
207/// raw (un-escaped) field: it is the sole trailing column, a source line can't
208/// hold a `\n`, and an interior tab is preserved by [`parse_annotate`]'s single
209/// `split_once('\t')`.
210pub(crate) const ANNOTATE_TEMPLATE: &str = "commit.change_id().short() ++ \"\\t\" ++ content";
211
212/// One entry of `jj op log` (an operation-log row).
213#[derive(Debug, Clone, PartialEq, Eq)]
214#[non_exhaustive]
215pub struct Operation {
216    /// Short operation id — what `op restore`/`op undo` take.
217    pub id: String,
218    /// The OS-level `user@host` that ran the operation (not the configured
219    /// jj author).
220    pub user: String,
221    /// Start timestamp, RFC 3339 (`%Y-%m-%dT%H:%M:%S` with a **colon** offset, e.g.
222    /// `2026-06-05T10:00:00+02:00`) — parseable by a strict RFC-3339 reader, matching
223    /// `vcs-git`'s `%aI` dates (jj's `%z` would emit `+0200`, which strict parsers
224    /// reject).
225    pub time: String,
226    /// First line of the operation description, e.g. `new empty commit`.
227    pub description: String,
228}
229
230/// One line of `jj file annotate` output: which change last touched it.
231#[derive(Debug, Clone, PartialEq, Eq)]
232#[non_exhaustive]
233pub struct AnnotationLine {
234    /// Short change id of the change that introduced the line.
235    pub change_id: String,
236    /// Line number in the annotated file (1-based).
237    pub line: u32,
238    /// The line's content (the raw bytes jj reports for the line, with only
239    /// the `\n` row separator removed; a trailing `\r` from a CRLF-terminated
240    /// source file is preserved, not stripped).
241    pub content: String,
242}
243
244/// Decode a single JSON string literal as emitted by a jj template's
245/// `.escape_json()` — e.g. `"a\tb"` → `a⇥b`, `"co,mma"` → `co,mma`. This is the
246/// inverse of the framing contract's per-field escaping.
247///
248/// Lenient by design (these parsers must never panic on unexpected jj output): a
249/// field that is *not* a `"…"` literal is returned verbatim (so a hex id, flag, or
250/// legacy raw field passes through unchanged), and a truncated or malformed escape
251/// simply stops decoding rather than erroring. Only the escapes jj's `escape_json`
252/// actually emits are recognised (`\" \\ \/ \b \f \n \r \t \uXXXX`); any other
253/// backslash pair is passed through as its second char.
254fn decode_json_field(field: &str) -> String {
255    let mut chars = field.chars();
256    // A JSON string starts with a quote; anything else is returned as-is.
257    if chars.next() != Some('"') {
258        return field.to_string();
259    }
260    let mut out = String::new();
261    while let Some(c) = chars.next() {
262        match c {
263            '"' => break, // closing quote — ignore any trailing bytes
264            '\\' => match chars.next() {
265                Some('"') => out.push('"'),
266                Some('\\') => out.push('\\'),
267                Some('/') => out.push('/'),
268                Some('b') => out.push('\u{0008}'),
269                Some('f') => out.push('\u{000C}'),
270                Some('n') => out.push('\n'),
271                Some('r') => out.push('\r'),
272                Some('t') => out.push('\t'),
273                Some('u') => {
274                    // `\uXXXX` — up to four hex digits (jj only escapes control
275                    // chars this way, so the BMP scalar always builds a `char`).
276                    let mut code: u32 = 0;
277                    for _ in 0..4 {
278                        match chars.next().and_then(|h| h.to_digit(16)) {
279                            Some(d) => code = code * 16 + d,
280                            None => break,
281                        }
282                    }
283                    if let Some(ch) = char::from_u32(code) {
284                        out.push(ch);
285                    }
286                }
287                Some(other) => out.push(other),
288                None => break,
289            },
290            other => out.push(other),
291        }
292    }
293    out
294}
295
296/// Decode a space-joined list of `.escape_json()` names (the framing contract's
297/// list field) back into the individual names. Splitting on the space is exact
298/// because a bookmark name can never contain one (a git-ref rule jj enforces), so
299/// each token is one whole JSON string literal.
300fn decode_name_list(field: &str) -> Vec<String> {
301    field
302        .split(' ')
303        .filter(|tok| !tok.is_empty())
304        .map(decode_json_field)
305        .collect()
306}
307
308/// The first name of a [`BOOKMARKS_TEMPLATE`] render (space-joined `.escape_json()`
309/// names), decoded; `None` when the commit carries no local bookmark. Drives
310/// `current_bookmark`/`trunk`.
311pub(crate) fn first_bookmark_name(rendered: &str) -> Option<String> {
312    decode_name_list(rendered.trim()).into_iter().next()
313}
314
315/// Parse rows produced by [`OP_TEMPLATE`].
316pub(crate) fn parse_operations(output: &str) -> Vec<Operation> {
317    output
318        .lines()
319        .filter(|line| !line.is_empty())
320        .filter_map(|line| {
321            // The user and description are `.escape_json()`-framed (no literal tab
322            // inside), so the four columns split cleanly; `splitn(4)` is belt-and-
323            // braces should a future column ever carry one.
324            let mut fields = line.splitn(4, '\t');
325            let id = fields.next()?.to_string();
326            let user = decode_json_field(fields.next()?);
327            let time = fields.next()?.to_string();
328            let description = decode_json_field(fields.next().unwrap_or(""));
329            Some(Operation {
330                id,
331                user,
332                time,
333                description,
334            })
335        })
336        .collect()
337}
338
339/// Parse rows produced by [`OP_PARENTS_TEMPLATE`] into `(op-id, parent-count)`
340/// pairs, newest first — the input to the rollback divergence walk. A row whose
341/// parent-count is missing or unparsable is read as `0` parents (it cannot be the
342/// divergence merge the probe looks for, so a malformed row never spuriously trips
343/// the "foreign concurrency" signal); the id is always kept so the walk can still
344/// locate the captured pre-operation.
345pub(crate) fn parse_op_parents(output: &str) -> Vec<(String, usize)> {
346    output
347        .lines()
348        .filter(|line| !line.is_empty())
349        .map(|line| {
350            let mut fields = line.splitn(2, '\t');
351            let id = fields.next().unwrap_or("").to_string();
352            let parents = fields
353                .next()
354                .and_then(|s| s.trim().parse::<usize>().ok())
355                .unwrap_or(0);
356            (id, parents)
357        })
358        .collect()
359}
360
361/// Parse rows produced by [`ANNOTATE_TEMPLATE`]: one row per source line, the
362/// 1-based line number is the row index.
363///
364/// Splits on `\n` (not [`str::lines`]) so a trailing `\r` belonging to a
365/// CRLF-terminated source line stays in the content instead of being stripped.
366/// The empty final segment left by a trailing newline carries no tab, so the
367/// `split_once('\t')?` filter drops it and the line numbering stays exact.
368pub(crate) fn parse_annotate(output: &str) -> Vec<AnnotationLine> {
369    output
370        .split('\n')
371        .enumerate()
372        .filter_map(|(idx, line)| {
373            let (change_id, content) = line.split_once('\t')?;
374            Some(AnnotationLine {
375                change_id: change_id.to_string(),
376                // Saturating: a >4 billion-line file would silently wrap a raw
377                // `as u32`. Such input is not realistic, but truncation never is.
378                line: u32::try_from(idx + 1).unwrap_or(u32::MAX),
379                content: content.to_string(),
380            })
381        })
382        .collect()
383}
384
385/// Parse rows produced by [`CHANGE_TEMPLATE`].
386pub(crate) fn parse_changes(output: &str) -> Vec<Change> {
387    output
388        .lines()
389        .filter(|line| !line.is_empty())
390        .filter_map(|line| {
391            // The description is `.escape_json()`-framed, so it holds no literal
392            // tab; `splitn(4)` still isolates it as the trailing column, then
393            // `decode_json_field` restores any tab/quote/backslash it carried.
394            let mut fields = line.splitn(4, '\t');
395            let change_id = fields.next()?.to_string();
396            let commit_id = fields.next()?.to_string();
397            let empty = fields.next()? == "true";
398            let description = decode_json_field(fields.next().unwrap_or(""));
399            Some(Change {
400                change_id,
401                commit_id,
402                empty,
403                description,
404            })
405        })
406        .collect()
407}
408
409/// Parse rows produced by [`BOOKMARK_LIST_TEMPLATE`]:
410/// `<present 1/0>\t<remote>\t"<name>"\t<full-commit>`. Yields only **live local**
411/// bookmarks — a locally-deleted *tombstone* (`present=0`, or the `present=1`
412/// remote-tracking row that surfaces beside it) is filtered out, so a deleted
413/// bookmark no longer masquerades as an existing branch in `local_branches` /
414/// `branch_exists` (T-041). A *conflicted* bookmark (`present=1`, empty target) is
415/// kept — it is present, just without a single normal target. A row with an empty
416/// name contributes nothing.
417pub(crate) fn parse_bookmarks(output: &str) -> Vec<Bookmark> {
418    output
419        .lines()
420        .filter(|line| !line.is_empty())
421        .filter_map(|line| {
422            let mut fields = line.split('\t');
423            let present = fields.next()? == "1";
424            let remote = fields.next().unwrap_or("");
425            let name = decode_json_field(fields.next().unwrap_or(""));
426            let target = fields.next().unwrap_or("").to_string();
427            // A tombstone (`present=0`) or a remote-tracking row (non-empty
428            // `remote`) is not an existing local branch; drop both. An empty name
429            // never yields a bookmark.
430            if !present || !remote.is_empty() || name.is_empty() {
431                return None;
432            }
433            Some(Bookmark { name, target })
434        })
435        .collect()
436}
437
438/// Parse rows produced by [`BOOKMARK_ALL_TEMPLATE`]:
439/// `<present 1/0>\t"<name>"\t<remote>\t<tracked 1/0>\t<full-commit>` per
440/// local/remote bookmark. A locally-deleted **tombstone** (`present=0`) row is
441/// dropped so it can't look like a live local bookmark; its remote-tracking
442/// counterpart (`present=1`) is still reported. A row whose name field is empty
443/// contributes nothing (mirrors [`parse_bookmarks`]).
444pub(crate) fn parse_bookmarks_all(output: &str) -> Vec<BookmarkRef> {
445    output
446        .lines()
447        .filter(|line| !line.is_empty())
448        .filter_map(|line| {
449            let mut fields = line.split('\t');
450            let present = fields.next()? == "1";
451            let name = decode_json_field(fields.next().unwrap_or(""));
452            let remote = fields.next().unwrap_or("");
453            let tracked = fields.next() == Some("1");
454            let target = fields.next().unwrap_or("").to_string();
455            if !present || name.is_empty() {
456                return None;
457            }
458            Some(BookmarkRef {
459                name,
460                remote: (!remote.is_empty()).then(|| remote.to_string()),
461                target,
462                tracked,
463            })
464        })
465        .collect()
466}
467
468/// Parse rows produced by [`REACHABLE_BOOKMARKS_TEMPLATE`]:
469/// `"<name>"[ "<name>"…]\t<full-commit>` (names `.escape_json()`-framed). A commit
470/// with several bookmarks yields one [`Bookmark`] per name, all sharing that
471/// commit as the target. A row with no bookmark names (empty first field)
472/// contributes nothing.
473pub(crate) fn parse_reachable_bookmarks(output: &str) -> Vec<Bookmark> {
474    let mut out = Vec::new();
475    for line in output.lines().filter(|l| !l.is_empty()) {
476        let mut fields = line.splitn(2, '\t');
477        let names = fields.next().unwrap_or("");
478        let target = fields.next().unwrap_or("");
479        for name in decode_name_list(names) {
480            out.push(Bookmark {
481                name,
482                target: target.to_string(),
483            });
484        }
485    }
486    out
487}
488
489/// Parse `jj resolve --list` output: each line is a conflicted path left-aligned
490/// in a column, then a run of spaces, then a human conflict description. Take the
491/// path (the text before the first 2-space gap), forward-slash normalised (jj
492/// emits the OS-native separator here, like `--summary`).
493///
494/// Consumes **raw bytes** and yields [`PathBuf`]s (via [`path_from_bytes`]) so a
495/// non-UTF-8 conflicted path survives losslessly, mirroring the git backend's
496/// `conflicted_files`.
497pub(crate) fn parse_resolve_list(output: &[u8]) -> Vec<PathBuf> {
498    output
499        .split(|&b| b == b'\n')
500        .filter_map(|line| {
501            // The path is the bytes before the first 2-space column gap.
502            let cut = find_subslice(line, b"  ").unwrap_or(line.len());
503            let path = line[..cut].trim_ascii();
504            if path.is_empty() {
505                return None;
506            }
507            Some(path_from_bytes(&normalize_slashes(path)))
508        })
509        .collect()
510}
511
512/// Build a workspace-root [`PathBuf`] from the raw stdout of `jj workspace root`.
513///
514/// Reads the path from **raw bytes** (not a lossily-decoded `String`) so a
515/// workspace root that is not valid UTF-8 (legal on Unix) survives byte-for-byte
516/// instead of collapsing to `U+FFFD` — matching the byte-faithful status/diff
517/// surface, and what the facade's `WorktreeInfo.path` forwards. jj prints the
518/// absolute root path followed by a single line terminator (`\n`, or `\r\n` on
519/// Windows, where the path is UTF-8 anyway); strip **only** that terminator — not
520/// arbitrary trailing whitespace like `str::trim_end` — so a root path that
521/// legitimately ends in a space/tab on Unix is preserved.
522pub(crate) fn workspace_root_from_bytes(stdout: &[u8]) -> PathBuf {
523    let end = stdout
524        .iter()
525        .rposition(|&b| b != b'\n' && b != b'\r')
526        .map_or(0, |i| i + 1);
527    path_from_bytes(&stdout[..end])
528}
529
530/// Normalise `\` path separators to `/` on raw bytes — jj's `--summary` /
531/// `resolve --list` emit the OS-native separator (backslashes on Windows), which
532/// the unified DTO reports forward-slash across backends/platforms.
533fn normalize_slashes(path: &[u8]) -> Vec<u8> {
534    path.iter()
535        .map(|&b| if b == b'\\' { b'/' } else { b })
536        .collect()
537}
538
539/// Byte-slice `find`: the index of the first occurrence of `needle` in `hay`.
540fn find_subslice(hay: &[u8], needle: &[u8]) -> Option<usize> {
541    if needle.is_empty() || needle.len() > hay.len() {
542        return None;
543    }
544    hay.windows(needle.len()).position(|w| w == needle)
545}
546
547/// Parse rows produced by [`WORKSPACE_TEMPLATE`]:
548/// `"<name>"\t<full-commit>\t<bookmarks>`, where the name is `.escape_json()`-framed
549/// and the bookmarks are space-joined `.escape_json()` names (and may be empty).
550pub(crate) fn parse_workspaces(output: &str) -> Vec<Workspace> {
551    output
552        .lines()
553        .filter(|line| !line.is_empty())
554        .filter_map(|line| {
555            // The name is `.escape_json()`-framed (no literal tab even when it
556            // holds one), so the three columns split cleanly.
557            let mut fields = line.split('\t');
558            let name = decode_json_field(fields.next()?);
559            let commit = fields.next().unwrap_or("").to_string();
560            let bookmarks = decode_name_list(fields.next().unwrap_or(""));
561            Some(Workspace {
562                name,
563                commit,
564                bookmarks,
565            })
566        })
567        .collect()
568}
569
570/// Parse `jj diff --summary`: each line is `<status-letter> <path>`. For a rename
571/// (`R`) or copy (`C`) jj renders the path as `prefix{old => new}suffix` rather than
572/// a plain path, so those are expanded into the real new path (and the old path is
573/// captured on [`ChangedPath::old_path`]). Paths are forward-slash normalised —
574/// jj's `--summary` uses the OS-native separator, unlike its `--git` diff (and git
575/// itself), so this keeps the unified DTO consistent across backends/platforms.
576/// Consumes **raw bytes** (not a lossily-decoded `&str`): the path is part of the
577/// payload and, on Unix, need not be valid UTF-8 — the status letter and the
578/// `{old => new}` rename framing are ASCII, so they parse byte-wise while the path
579/// bytes are carried losslessly (via [`path_from_bytes`]).
580pub(crate) fn parse_diff_summary(output: &[u8]) -> Vec<ChangedPath> {
581    output
582        .split(|&b| b == b'\n')
583        .filter(|line| !line.is_empty())
584        .filter_map(|line| {
585            // The status letter is a single ASCII byte, followed by the separating
586            // space; the remainder is the raw path bytes.
587            let status = *line.first()? as char;
588            if line.get(1) != Some(&b' ') {
589                return None;
590            }
591            let raw = &line[2..];
592            if raw.is_empty() {
593                return None;
594            }
595            let (old_path, path) = if matches!(status, 'R' | 'C') {
596                let (old, new) = expand_rename(raw);
597                let (old, new) = (normalize_slashes(&old), normalize_slashes(&new));
598                // A non-brace `R`/`C` path (malformed — jj always renders renames
599                // with the `{old => new}` form) expands to `old == new`; don't
600                // report that as a self-rename, so `old_path != path` stays a
601                // reliable "is this a real rename?" test for consumers.
602                (
603                    (old != new).then(|| path_from_bytes(&old)),
604                    path_from_bytes(&new),
605                )
606            } else {
607                (None, path_from_bytes(&normalize_slashes(raw)))
608            };
609            Some(ChangedPath {
610                status,
611                path,
612                old_path,
613            })
614        })
615        .collect()
616}
617
618/// Expand jj's rename/copy path form `prefix{left => right}suffix` into
619/// `(old, new)` full byte paths. Falls back to `(raw, raw)` when the brace/arrow
620/// form isn't present, so a plain path is returned unchanged. `{`, `}`, and ` => `
621/// are ASCII, so the byte offsets are exact even for a non-UTF-8 surrounding path.
622fn expand_rename(raw: &[u8]) -> (Vec<u8>, Vec<u8>) {
623    let plain = || (raw.to_vec(), raw.to_vec());
624    let (Some(open), Some(close)) = (
625        raw.iter().position(|&b| b == b'{'),
626        raw.iter().position(|&b| b == b'}'),
627    ) else {
628        return plain();
629    };
630    if open >= close {
631        return plain();
632    }
633    let Some(rel) = find_subslice(&raw[open..close], b" => ") else {
634        return plain();
635    };
636    let arrow = open + rel;
637    let prefix = &raw[..open];
638    let left = &raw[open + 1..arrow];
639    let right = &raw[arrow + 4..close];
640    let suffix = &raw[close + 1..];
641    (
642        [prefix, left, suffix].concat(),
643        [prefix, right, suffix].concat(),
644    )
645}
646
647/// Parse the summary footer of `jj diff --stat`, e.g. `4 files changed, 157
648/// insertions(+), 137 deletions(-)` (same shape as git's `--shortstat`). The
649/// footer is the last line mentioning "changed"; no such line → all zeros.
650/// Preprocessing (line selection) is the only jj-specific part — the clause
651/// parse itself delegates to the shared [`DiffStat::parse`] (also used by
652/// `vcs_git::parse::parse_shortstat`).
653pub(crate) fn parse_diff_stat(output: &str) -> DiffStat {
654    let summary = output
655        .lines()
656        .rev()
657        .find(|line| line.contains("changed"))
658        .unwrap_or("");
659    DiffStat::parse(summary)
660}
661
662#[cfg(test)]
663mod tests {
664    use super::*;
665
666    #[test]
667    fn jj_version_parses_real_world_shapes() {
668        let v = parse_jj_version("jj 0.38.0").unwrap();
669        assert_eq!((v.major, v.minor, v.patch), (0, 38, 0));
670        let v = parse_jj_version("jj 0.39.0-dev+abc123").unwrap();
671        assert_eq!((v.major, v.minor, v.patch), (0, 39, 0));
672        let v = parse_jj_version("jj 1.2").unwrap();
673        assert_eq!(v.patch, 0, "missing patch defaults to 0");
674        // Ordering drives the supported-floor gate.
675        assert!(parse_jj_version("jj 0.37.9").unwrap() < parse_jj_version("jj 0.38.0").unwrap());
676        assert!(parse_jj_version("jj").is_none());
677    }
678
679    #[test]
680    fn operations_split_tab_fields() {
681        // RFC-3339 colon offset (`%:z`), user + description `.escape_json()`-framed.
682        let out = "abc123\t\"user@host\"\t2026-06-05T10:00:00+02:00\t\"new empty commit\"\n\
683                   def456\t\"user@host\"\t2026-06-05T09:59:00+02:00\t\"describe commit\\twith tab\"\n";
684        let ops = parse_operations(out);
685        assert_eq!(ops.len(), 2);
686        assert_eq!(ops[0].id, "abc123");
687        assert_eq!(ops[0].user, "user@host");
688        assert_eq!(ops[0].time, "2026-06-05T10:00:00+02:00");
689        assert_eq!(ops[0].description, "new empty commit");
690        // A literal tab in the description survives (splitn keeps the tail).
691        assert_eq!(ops[1].description, "describe commit\twith tab");
692    }
693
694    #[test]
695    fn op_parents_reads_id_and_parent_count() {
696        // Newest first: a 2-parent reconcile merge, then two single-parent ops.
697        let out = "merge9\t2\nmine01\t1\npre000\t1\n";
698        let rows = parse_op_parents(out);
699        assert_eq!(
700            rows,
701            vec![
702                ("merge9".to_string(), 2),
703                ("mine01".to_string(), 1),
704                ("pre000".to_string(), 1),
705            ]
706        );
707        // A short/malformed row (no parent-count column) keeps its id and reads as
708        // 0 parents, so it can never spuriously look like the divergence merge.
709        let short = parse_op_parents("abc123\n");
710        assert_eq!(short, vec![("abc123".to_string(), 0)]);
711        assert!(parse_op_parents("").is_empty());
712    }
713
714    #[test]
715    fn annotate_rows_carry_line_numbers() {
716        let out = "kxoyzabc\tfn main() {\nkxoyzabc\t}\nqlmnopqr\t// added later";
717        let lines = parse_annotate(out);
718        assert_eq!(lines.len(), 3);
719        assert_eq!(lines[0].change_id, "kxoyzabc");
720        assert_eq!(lines[0].line, 1);
721        assert_eq!(lines[0].content, "fn main() {");
722        assert_eq!(lines[2].change_id, "qlmnopqr");
723        assert_eq!(lines[2].line, 3);
724        assert!(parse_annotate("").is_empty());
725    }
726
727    // A CRLF-terminated source line keeps its `\r` in the content (the old
728    // `.lines()` split silently stripped it), and a trailing newline does not
729    // add a phantom row or perturb the 1-based line numbering.
730    #[test]
731    fn annotate_preserves_cr_and_ignores_trailing_newline() {
732        let out = "kxoyzabc\tfn main() {\r\nkxoyzabc\t}\r\n";
733        let lines = parse_annotate(out);
734        assert_eq!(lines.len(), 2, "no phantom row from the trailing newline");
735        assert_eq!(lines[0].content, "fn main() {\r", "CR preserved");
736        assert_eq!((lines[1].line, lines[1].content.as_str()), (2, "}\r"));
737    }
738
739    // EVOLOG_TEMPLATE renders the same columns as CHANGE_TEMPLATE, so the rows
740    // flow through parse_changes unchanged.
741    #[test]
742    fn evolog_rows_parse_as_changes() {
743        let out = "kz\t38\tfalse\t\"feat: parser\"\nkz\t12\ttrue\t\"\"\n";
744        let changes = parse_changes(out);
745        assert_eq!(changes.len(), 2);
746        assert_eq!(changes[0].description, "feat: parser");
747        assert!(changes[1].empty);
748    }
749
750    #[test]
751    fn changes_split_tab_fields() {
752        let input = "kztuxlro\t38e00654\tfalse\t\"feat: stuff\"\nqpvuntsm\t6ecf997f\ttrue\t\"\"\n";
753        let got = parse_changes(input);
754        assert_eq!(got.len(), 2);
755        assert_eq!(
756            got[0],
757            Change {
758                change_id: "kztuxlro".into(),
759                commit_id: "38e00654".into(),
760                empty: false,
761                description: "feat: stuff".into(),
762            }
763        );
764        // Undescribed, empty change.
765        assert!(got[1].empty);
766        assert_eq!(got[1].description, "");
767    }
768
769    // A literal tab inside the (first-line) description round-trips: the template
770    // `.escape_json()`-frames it as `\t` inside the quoted field, and
771    // `decode_json_field` restores the real tab.
772    #[test]
773    fn changes_keep_tab_in_description() {
774        let got = parse_changes("kztuxlro\t38e00654\tfalse\t\"col1\\tcol2\"\n");
775        assert_eq!(got.len(), 1);
776        assert_eq!(got[0].description, "col1\tcol2");
777    }
778
779    // A commit carrying several bookmarks fans out to one entry each, all sharing
780    // the commit; a bookmark-less row contributes nothing.
781    #[test]
782    fn reachable_bookmarks_fan_out_per_name() {
783        let got = parse_reachable_bookmarks("\"main\" \"feat\"\tabc123\n\tdef456\n");
784        assert_eq!(
785            got,
786            vec![
787                Bookmark {
788                    name: "main".into(),
789                    target: "abc123".into()
790                },
791                Bookmark {
792                    name: "feat".into(),
793                    target: "abc123".into()
794                },
795            ]
796        );
797    }
798
799    // The JSON-string decoder inverts every escape jj's `escape_json` emits, and
800    // passes a non-quoted field through verbatim (defensive for hex/flag columns
801    // and any legacy raw output).
802    #[test]
803    fn decode_json_field_reverses_escapes() {
804        assert_eq!(decode_json_field("\"plain\""), "plain");
805        assert_eq!(decode_json_field("\"co,mma\""), "co,mma");
806        assert_eq!(decode_json_field("\"a\\tb\""), "a\tb");
807        assert_eq!(decode_json_field("\"line\\ntwo\""), "line\ntwo");
808        assert_eq!(decode_json_field("\"q\\\"q\""), "q\"q");
809        assert_eq!(decode_json_field("\"back\\\\slash\""), "back\\slash");
810        assert_eq!(decode_json_field("\"\\u0009tab\""), "\ttab"); // \uXXXX control
811        assert_eq!(decode_json_field("\"caf\u{00e9}\""), "caf\u{00e9}"); // raw UTF-8
812        assert_eq!(decode_json_field("\"\""), ""); // empty
813        // A non-quoted field is returned as-is (a hex id, a flag, or a truncated row).
814        assert_eq!(decode_json_field("f5d07685"), "f5d07685");
815        assert_eq!(decode_json_field(""), "");
816    }
817
818    // The space-joined name list splits back exactly (bookmark names never hold a
819    // space), decoding each element; an empty field is an empty list.
820    #[test]
821    fn decode_name_list_splits_and_decodes() {
822        assert_eq!(decode_name_list("\"main\" \"feat\""), vec!["main", "feat"]);
823        assert_eq!(decode_name_list("\"co,mma\""), vec!["co,mma"]);
824        assert!(decode_name_list("").is_empty());
825        // `first_bookmark_name` takes the decoded head, or `None` when absent.
826        assert_eq!(
827            first_bookmark_name("\"co,mma\" \"main\""),
828            Some("co,mma".to_string())
829        );
830        assert_eq!(first_bookmark_name(""), None);
831        assert_eq!(first_bookmark_name("\n"), None);
832    }
833
834    // Exotic workspace names — jj permits a tab or newline in a workspace name,
835    // and the template `.escape_json()`-frames it so the row still splits on the
836    // literal tab and the name round-trips (the old raw `name` stored the escaped
837    // form verbatim). A comma-carrying bookmark name likewise survives (the old
838    // comma-join mangled it).
839    #[test]
840    fn workspaces_round_trip_exotic_names() {
841        // `"ta\tb"` = a workspace name holding a real tab; the framed field's only
842        // literal tabs are the two column separators.
843        let input = "\"ta\\tb\"\tc0ffee\t\"co,mma\" \"pl/ain\"\n";
844        let got = parse_workspaces(input);
845        assert_eq!(got.len(), 1);
846        assert_eq!(
847            got[0].name, "ta\tb",
848            "the interior tab is decoded, not split on"
849        );
850        assert_eq!(got[0].commit, "c0ffee");
851        assert_eq!(
852            got[0].bookmarks,
853            vec!["co,mma".to_string(), "pl/ain".to_string()]
854        );
855    }
856
857    // Identity ids are the FULL commit id, so two commits that share a short prefix
858    // stay distinct — a short-prefix key would collide and cross-reference wrongly.
859    #[test]
860    fn full_ids_disambiguate_a_shared_short_prefix() {
861        let a = "abcdef0123456789abcdef0123456789abcdef01";
862        let b = "abcdef0123456789ffffffffffffffffffffffff"; // same 16-char prefix
863        let bms = parse_bookmarks(&format!("1\t\t\"one\"\t{a}\n1\t\t\"two\"\t{b}\n"));
864        assert_eq!(bms[0].target, a);
865        assert_eq!(bms[1].target, b);
866        assert_ne!(bms[0].target, bms[1].target, "full ids must not collide");
867        // The same holds for the workspace commit (the WorktreeInfo.commit source).
868        let ws = parse_workspaces(&format!("\"w1\"\t{a}\t\n\"w2\"\t{b}\t\n"));
869        assert_ne!(ws[0].commit, ws[1].commit);
870    }
871
872    #[test]
873    fn resolve_list_extracts_paths_before_description() {
874        let got = parse_resolve_list(
875            b"src/a.rs    2-sided conflict\nb.txt    2-sided conflict including 1 deletion\n",
876        );
877        assert_eq!(got, vec![PathBuf::from("src/a.rs"), PathBuf::from("b.txt")]);
878        assert!(parse_resolve_list(b"").is_empty());
879        // OS-native backslash separators (Windows) are normalised to `/`.
880        assert_eq!(
881            parse_resolve_list(b"sub\\c.txt    2-sided conflict\n"),
882            vec![PathBuf::from("sub/c.txt")]
883        );
884    }
885
886    // A non-UTF-8 conflicted path (legal on Unix) survives byte-for-byte.
887    #[cfg(unix)]
888    #[test]
889    fn resolve_list_preserves_non_utf8_path_bytes() {
890        use std::os::unix::ffi::OsStrExt;
891        let got = parse_resolve_list(b"caf\xff.txt    2-sided conflict\n");
892        assert_eq!(got.len(), 1);
893        assert_eq!(got[0].as_os_str().as_bytes(), b"caf\xff.txt");
894    }
895
896    #[test]
897    fn workspace_root_strips_only_the_trailing_line_terminator() {
898        // jj prints the root path then one `\n` (a `\r\n` on Windows).
899        assert_eq!(
900            workspace_root_from_bytes(b"/repo/ws\n"),
901            PathBuf::from("/repo/ws")
902        );
903        assert_eq!(
904            workspace_root_from_bytes(b"/repo/ws\r\n"),
905            PathBuf::from("/repo/ws")
906        );
907        // No terminator at all is fine, and all-empty yields an empty path.
908        assert_eq!(
909            workspace_root_from_bytes(b"/repo/ws"),
910            PathBuf::from("/repo/ws")
911        );
912        assert_eq!(workspace_root_from_bytes(b"\n"), PathBuf::new());
913    }
914
915    // A workspace root whose bytes are not valid UTF-8 (legal on Unix) survives
916    // byte-for-byte, so the facade's `WorktreeInfo.path` names the SAME directory;
917    // a trailing space (a legal path byte) is kept — only the `\n` is stripped.
918    #[cfg(unix)]
919    #[test]
920    fn workspace_root_preserves_non_utf8_and_trailing_space() {
921        use std::os::unix::ffi::OsStrExt;
922        let got = workspace_root_from_bytes(b"/repo/ws-caf\xff \n");
923        assert_eq!(got.as_os_str().as_bytes(), b"/repo/ws-caf\xff ");
924    }
925
926    #[test]
927    fn bookmarks_parse_name_and_commit_from_template() {
928        // Rows produced by BOOKMARK_LIST_TEMPLATE:
929        // `<present>\t<remote>\t"<name>"\t<full-commit>`. Two live local bookmarks.
930        let input = "1\t\t\"main\"\tf5d07685\n1\t\t\"feature\"\tdeadbeef\n";
931        let got = parse_bookmarks(input);
932        assert_eq!(
933            got,
934            vec![
935                Bookmark {
936                    name: "main".into(),
937                    target: "f5d07685".into()
938                },
939                Bookmark {
940                    name: "feature".into(),
941                    target: "deadbeef".into()
942                },
943            ]
944        );
945    }
946
947    // The tombstone fix (T-041): a locally-deleted bookmark that a remote still
948    // tracks renders a `present=0` local row PLUS a `present=1` remote-tracking
949    // row — neither may be reported as a live local branch. A *conflicted*
950    // bookmark (`present=1`, empty target) IS live and must be kept; an empty name
951    // contributes nothing. An exotic name with a comma round-trips via escaping.
952    #[test]
953    fn bookmarks_filter_tombstones_but_keep_conflicted() {
954        let input = concat!(
955            "1\t\t\"live\"\tf5d07685\n",       // live local → kept
956            "0\t\t\"tomb\"\t\n",               // deleted local tombstone → dropped
957            "1\torigin\t\"tomb\"\tdeadbeef\n", // its remote-tracking row → dropped
958            "1\t\t\"conflicted\"\t\n",         // present, no single target → kept
959            "1\t\t\"co,mma\"\tcafef00d\n",     // comma in name → decoded intact
960            "1\t\t\"\"\t\n",                   // empty name → dropped
961        );
962        let got = parse_bookmarks(input);
963        assert_eq!(
964            got,
965            vec![
966                Bookmark {
967                    name: "live".into(),
968                    target: "f5d07685".into()
969                },
970                Bookmark {
971                    name: "conflicted".into(),
972                    target: String::new()
973                },
974                Bookmark {
975                    name: "co,mma".into(),
976                    target: "cafef00d".into()
977                },
978            ],
979            "only live LOCAL bookmarks survive; the tombstone never looks alive"
980        );
981    }
982
983    // `parse_bookmarks_all` drops a row whose name field is empty and a
984    // locally-deleted `present=0` tombstone, matching `parse_bookmarks` — no
985    // phantom `BookmarkRef { name: "" }` or ghost-local leaks through. Rows:
986    // `<present>\t"<name>"\t<remote>\t<tracked>\t<full-commit>`.
987    #[test]
988    fn bookmarks_all_drops_empty_name_and_tombstone_rows() {
989        let input = concat!(
990            "1\t\"main\"\t\t1\tf5d07685\n",       // live local
991            "1\t\"\"\torigin\t1\tdeadbeef\n",     // empty name → dropped
992            "1\t\"feat\"\torigin\t0\tcafef00d\n", // remote-tracking
993            "0\t\"gone\"\t\t0\t\n",               // deleted local tombstone → dropped
994        );
995        let got = parse_bookmarks_all(input);
996        assert_eq!(
997            got,
998            vec![
999                BookmarkRef {
1000                    name: "main".into(),
1001                    remote: None,
1002                    target: "f5d07685".into(),
1003                    tracked: true,
1004                },
1005                BookmarkRef {
1006                    name: "feat".into(),
1007                    remote: Some("origin".into()),
1008                    target: "cafef00d".into(),
1009                    tracked: false,
1010                },
1011            ],
1012            "the empty-name and tombstone rows must contribute nothing"
1013        );
1014    }
1015
1016    #[test]
1017    fn workspaces_split_tab_fields_and_bookmarks() {
1018        let input = "\"default\"\te2aa3420\t\"main\" \"feature\"\n\"ws1\"\t12345678\t\n";
1019        let got = parse_workspaces(input);
1020        assert_eq!(got.len(), 2);
1021        assert_eq!(
1022            got[0],
1023            Workspace {
1024                name: "default".into(),
1025                commit: "e2aa3420".into(),
1026                bookmarks: vec!["main".into(), "feature".into()],
1027            }
1028        );
1029        // No bookmarks → empty vec, not [""].
1030        assert!(got[1].bookmarks.is_empty());
1031    }
1032
1033    #[test]
1034    fn diff_summary_splits_status_and_path() {
1035        let got = parse_diff_summary(b"M src/lib.rs\nA new file.txt\nD gone.rs\n");
1036        assert_eq!(got.len(), 3);
1037        assert_eq!(got[0].status, 'M');
1038        assert_eq!(got[1].path, PathBuf::from("new file.txt"));
1039        assert!(got[1].old_path.is_none());
1040        assert_eq!(got[2].status, 'D');
1041    }
1042
1043    // A non-UTF-8 summary path (legal on Unix) survives byte-for-byte.
1044    #[cfg(unix)]
1045    #[test]
1046    fn diff_summary_preserves_non_utf8_path_bytes() {
1047        use std::os::unix::ffi::OsStrExt;
1048        let got = parse_diff_summary(b"M caf\xff.txt\n");
1049        assert_eq!(got.len(), 1);
1050        assert_eq!(got[0].path.as_os_str().as_bytes(), b"caf\xff.txt");
1051    }
1052
1053    // jj renders a rename/copy path as `prefix{old => new}suffix` (verified against
1054    // jj 0.38); it must be expanded into the real new path with the old path
1055    // captured — not stored raw. A plain `M`/`A`/`D` path is left untouched.
1056    #[test]
1057    fn diff_summary_expands_rename_and_copy() {
1058        let got =
1059            parse_diff_summary(b"R {old.rs => new.rs}\nC sub/{a.rs => b.rs}\nM lit{eral}.rs\n");
1060        assert_eq!(got[0].status, 'R');
1061        assert_eq!(got[0].path, PathBuf::from("new.rs"));
1062        assert_eq!(
1063            got[0].old_path.as_deref(),
1064            Some(PathBuf::from("old.rs").as_path())
1065        );
1066        assert_eq!(got[1].path, PathBuf::from("sub/b.rs"));
1067        assert_eq!(
1068            got[1].old_path.as_deref(),
1069            Some(PathBuf::from("sub/a.rs").as_path())
1070        );
1071        // A literal `{...}` in a non-rename path (no ` => `) is not mis-expanded.
1072        assert_eq!(got[2].path, PathBuf::from("lit{eral}.rs"));
1073        assert!(got[2].old_path.is_none());
1074    }
1075
1076    // jj `--summary` emits OS-native separators (backslashes on Windows); paths are
1077    // normalised to forward slashes to match the `--git` diff and the git backend.
1078    #[test]
1079    fn diff_summary_normalises_backslash_separators() {
1080        let got = parse_diff_summary(b"M deep\\nested\\f.rs\nR win\\{a.rs => b.rs}\n");
1081        assert_eq!(got[0].path, PathBuf::from("deep/nested/f.rs"));
1082        assert_eq!(got[1].path, PathBuf::from("win/b.rs"));
1083        assert_eq!(
1084            got[1].old_path.as_deref(),
1085            Some(PathBuf::from("win/a.rs").as_path())
1086        );
1087    }
1088
1089    #[test]
1090    fn diff_stat_parses_footer_among_per_file_lines() {
1091        let input = "README.md | 10 +++---\n\
1092                     src/lib.rs | 4 +-\n\
1093                     4 files changed, 157 insertions(+), 137 deletions(-)\n";
1094        assert_eq!(parse_diff_stat(input), DiffStat::new(4, 157, 137));
1095        assert_eq!(parse_diff_stat(""), DiffStat::default());
1096    }
1097}
1098
1099// Property-based fuzzing: pure parsers over arbitrary jj output must never
1100// panic, with special attention to `expand_rename` (byte-offset arithmetic on
1101// `{old => new}` braces) and the templated tab-row parsers.
1102#[cfg(test)]
1103mod proptests {
1104    use super::*;
1105    use proptest::prelude::*;
1106
1107    /// jj's structural vocabulary: `diff --summary` letters, brace renames
1108    /// (incl. multibyte around the braces), template tab-rows, and diff text.
1109    fn structured_line() -> impl Strategy<Value = String> {
1110        prop_oneof![
1111            Just("M src/a.rs\n".to_string()),
1112            Just("R sub\\{old.rs => new.rs}\n".to_string()),
1113            Just("C {a => b}.rs\n".to_string()),
1114            "[A-Z] \\{[a-zé]{0,6} => [a-zé]{0,6}\\}\n", // rename braces + multibyte
1115            "[a-zé]{0,8}\t[a-zé]{0,8}\t(true|false)\t[a-zé\t]{0,10}\n", // change row
1116            "[a-zé]{0,8}\t[a-zé@]{0,8}\t[01]\t[a-zé]{0,8}\n", // bookmark row
1117            "[-+ ]?[a-zé]{0,10}\n",                     // diff body
1118        ]
1119    }
1120
1121    fn structured_doc() -> impl Strategy<Value = String> {
1122        prop::collection::vec(structured_line(), 0..40).prop_map(|lines| lines.concat())
1123    }
1124
1125    /// A standard JSON string encoder — the reference for what jj's `escape_json`
1126    /// emits (verified byte-for-byte against jj 0.42). Round-tripping arbitrary
1127    /// text through this and back through [`decode_json_field`] proves the framing
1128    /// decoder inverts the real template output for names/descriptions with any
1129    /// mix of spaces, commas, tabs, quotes, backslashes, and newlines.
1130    fn json_encode(s: &str) -> String {
1131        let mut out = String::from("\"");
1132        for c in s.chars() {
1133            match c {
1134                '"' => out.push_str("\\\""),
1135                '\\' => out.push_str("\\\\"),
1136                '\n' => out.push_str("\\n"),
1137                '\r' => out.push_str("\\r"),
1138                '\t' => out.push_str("\\t"),
1139                '\u{0008}' => out.push_str("\\b"),
1140                '\u{000C}' => out.push_str("\\f"),
1141                c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
1142                c => out.push(c),
1143            }
1144        }
1145        out.push('"');
1146        out
1147    }
1148
1149    proptest! {
1150        // The framing decoder inverts a standard JSON-string encode for ANY text —
1151        // the round-trip the machine templates rely on for names/descriptions.
1152        #[test]
1153        fn json_field_round_trips(s in any::<String>()) {
1154            prop_assert_eq!(decode_json_field(&json_encode(&s)), s);
1155        }
1156
1157        // A full change row (id/flag columns raw, description `.escape_json()`-framed)
1158        // round-trips through `parse_changes`: the description recovers exactly even
1159        // with tabs/quotes/backslashes, and the structural columns are untouched.
1160        #[test]
1161        fn change_row_round_trips(desc in any::<String>()) {
1162            // jj's `first_line()` yields a single line; mirror that for a realistic
1163            // fixture (the framing still handles an embedded newline, but a real row
1164            // never carries one here).
1165            let first: String = desc.split(['\n', '\r']).next().unwrap_or("").to_string();
1166            let row = format!("chg12345678\tcmt87654321\tfalse\t{}\n", json_encode(&first));
1167            let got = parse_changes(&row);
1168            prop_assert_eq!(got.len(), 1);
1169            prop_assert_eq!(got[0].change_id.as_str(), "chg12345678");
1170            prop_assert_eq!(got[0].commit_id.as_str(), "cmt87654321");
1171            prop_assert!(!got[0].empty);
1172            prop_assert_eq!(&got[0].description, &first);
1173        }
1174
1175        // A space-joined list of escaped bookmark names round-trips (names never
1176        // contain a space, so the join is reversible). Uses a comma/slash/dot
1177        // alphabet — the exotic-but-space-free shapes a git-imported name can take.
1178        #[test]
1179        fn name_list_round_trips(names in prop::collection::vec("[a-z,./-]{1,8}", 0..6)) {
1180            let field = names.iter().map(|n| json_encode(n)).collect::<Vec<_>>().join(" ");
1181            prop_assert_eq!(decode_name_list(&field), names);
1182        }
1183
1184        #[test]
1185        fn parsers_never_panic_on_arbitrary_text(s in any::<String>()) {
1186            let _ = parse_changes(&s);
1187            let _ = parse_operations(&s);
1188            let _ = parse_annotate(&s);
1189            let _ = parse_bookmarks(&s);
1190            let _ = parse_bookmarks_all(&s);
1191            let _ = parse_reachable_bookmarks(&s);
1192            let _ = parse_resolve_list(s.as_bytes());
1193            let _ = parse_workspaces(&s);
1194            let _ = parse_diff_summary(s.as_bytes());
1195            let _ = parse_diff_stat(&s);
1196            let _ = parse_jj_version(&s);
1197            let _ = expand_rename(s.as_bytes());
1198        }
1199
1200        // The byte parsers must also never panic on *arbitrary bytes* — the actual
1201        // shape of jj machine output carrying a non-UTF-8 path.
1202        #[test]
1203        fn byte_parsers_never_panic_on_arbitrary_bytes(b in any::<Vec<u8>>()) {
1204            let _ = parse_resolve_list(&b);
1205            let _ = parse_diff_summary(&b);
1206            let _ = expand_rename(&b);
1207            let _ = workspace_root_from_bytes(&b);
1208        }
1209
1210        #[test]
1211        fn parsers_never_panic_on_structured_text(s in structured_doc()) {
1212            let _ = parse_diff_summary(s.as_bytes());
1213            let _ = parse_changes(&s);
1214            let _ = parse_bookmarks_all(&s);
1215        }
1216
1217        // expand_rename returns the raw verbatim for a non-brace input (its
1218        // documented identity for the no-rename case).
1219        #[test]
1220        fn expand_rename_is_identity_without_braces(s in "[a-zé/ ]{0,20}") {
1221            prop_assume!(!s.contains('{') && !s.contains('}'));
1222            let bytes = s.into_bytes();
1223            prop_assert_eq!(expand_rename(&bytes), (bytes.clone(), bytes));
1224        }
1225    }
1226}