Skip to main content

vcs_runner/
parse_jj.rs

1use std::collections::HashSet;
2use std::path::PathBuf;
3
4use serde::Deserialize;
5
6use crate::types::{
7    Bookmark, ConflictState, ContentState, FileChange, FileChangeKind, GitRemote, LogEntry,
8    RemoteStatus, WorkingCopy,
9};
10
11/// jj template for `jj bookmark list` producing line-delimited JSON.
12///
13/// Use with [`parse_bookmark_output`] to get structured [`Bookmark`] values.
14/// jj's `escape_json()` includes surrounding quotes, so array elements
15/// use it directly with comma joins (no extra quote wrapping).
16pub const BOOKMARK_TEMPLATE: &str = concat!(
17    r#"'{"name":' ++ name.escape_json()"#,
18    r#" ++ ',"commitId":' ++ normal_target.commit_id().short().escape_json()"#,
19    r#" ++ ',"changeId":' ++ normal_target.change_id().short().escape_json()"#,
20    r#" ++ ',"localBookmarks":[' ++ normal_target.local_bookmarks().map(|b| b.name().escape_json()).join(',') ++ ']'"#,
21    r#" ++ ',"remoteBookmarks":[' ++ normal_target.remote_bookmarks().map(|b| stringify(b.name() ++ "@" ++ b.remote()).escape_json()).join(',') ++ ']'"#,
22    r#" ++ '}' ++ "\n""#,
23);
24
25/// jj template for `jj log` producing line-delimited JSON entries.
26///
27/// Use with [`parse_log_output`] to get structured [`LogEntry`] values.
28pub const LOG_TEMPLATE: &str = concat!(
29    r#"'{"commitId":' ++ commit_id.short().escape_json()"#,
30    r#" ++ ',"changeId":' ++ change_id.short().escape_json()"#,
31    r#" ++ ',"authorName":' ++ author.name().escape_json()"#,
32    r#" ++ ',"authorEmail":' ++ stringify(author.email()).escape_json()"#,
33    r#" ++ ',"description":' ++ description.escape_json()"#,
34    r#" ++ ',"parents":[' ++ parents.map(|p| p.commit_id().short().escape_json()).join(',') ++ ']'"#,
35    r#" ++ ',"localBookmarks":[' ++ local_bookmarks.map(|b| b.name().escape_json()).join(',') ++ ']'"#,
36    r#" ++ ',"remoteBookmarks":[' ++ remote_bookmarks.map(|b| stringify(b.name() ++ "@" ++ b.remote()).escape_json()).join(',') ++ ']'"#,
37    r#" ++ ',"isWorkingCopy":' ++ if(current_working_copy, '"true"', '"false"')"#,
38    r#" ++ ',"conflict":' ++ if(conflict, '"true"', '"false"')"#,
39    r#" ++ ',"empty":' ++ if(empty, '"true"', '"false"')"#,
40    r#" ++ '}' ++ "\n""#,
41);
42
43/// Result of parsing bookmark output, including any skipped entries.
44#[derive(Debug)]
45pub struct BookmarkParseResult {
46    pub bookmarks: Vec<Bookmark>,
47    /// Names of bookmarks that were skipped due to malformed JSON
48    /// (typically stale/conflicted bookmarks pointing at missing commits).
49    pub skipped: Vec<String>,
50}
51
52/// Result of parsing log output, including any skipped entries.
53#[derive(Debug)]
54pub struct LogParseResult {
55    pub entries: Vec<LogEntry>,
56    /// Lines that failed to parse.
57    pub skipped: Vec<String>,
58}
59
60#[derive(Debug, Deserialize)]
61#[serde(rename_all = "camelCase")]
62struct RawBookmark {
63    name: String,
64    commit_id: String,
65    change_id: String,
66    local_bookmarks: Vec<String>,
67    remote_bookmarks: Vec<String>,
68}
69
70#[derive(Debug, Deserialize)]
71#[serde(rename_all = "camelCase")]
72struct RawLogEntry {
73    commit_id: String,
74    change_id: String,
75    author_name: String,
76    author_email: String,
77    description: String,
78    parents: Vec<String>,
79    local_bookmarks: Vec<String>,
80    remote_bookmarks: Vec<String>,
81    is_working_copy: String,
82    conflict: String,
83    empty: String,
84}
85
86fn extract_name_from_malformed_json(line: &str) -> Option<String> {
87    let after_key = line.split(r#""name":"#).nth(1)?;
88    let after_quote = after_key.strip_prefix('"')?;
89    let end = after_quote.find('"')?;
90    Some(after_quote[..end].to_string())
91}
92
93fn resolve_remote_status(name: &str, remote_bookmarks: &[String]) -> RemoteStatus {
94    let non_git_remotes: Vec<&String> = remote_bookmarks
95        .iter()
96        .filter(|rb| !rb.is_empty() && !rb.ends_with("@git"))
97        .collect();
98
99    if non_git_remotes.is_empty() {
100        return RemoteStatus::Local;
101    }
102
103    let synced = non_git_remotes
104        .iter()
105        .any(|rb| rb.starts_with(&format!("{name}@")));
106
107    if synced { RemoteStatus::Synced } else { RemoteStatus::Unsynced }
108}
109
110/// Parse `jj bookmark list --template BOOKMARK_TEMPLATE` output.
111///
112/// Skips conflicted/stale bookmarks that produce unparseable JSON and
113/// filters out remote-only entries (empty `localBookmarks`).
114pub fn parse_bookmark_output(output: &str) -> BookmarkParseResult {
115    let mut bookmarks = Vec::new();
116    let mut skipped = Vec::new();
117    let mut warned_names: HashSet<String> = HashSet::new();
118
119    for line in output.lines() {
120        if line.trim().is_empty() {
121            continue;
122        }
123
124        let raw: RawBookmark = match serde_json::from_str(line) {
125            Ok(r) => r,
126            Err(_) => {
127                let name = extract_name_from_malformed_json(line)
128                    .unwrap_or_else(|| "<unknown>".to_string());
129                if warned_names.insert(name.clone()) {
130                    skipped.push(name);
131                }
132                continue;
133            }
134        };
135
136        if raw.local_bookmarks.is_empty() {
137            continue;
138        }
139
140        let remote = resolve_remote_status(&raw.name, &raw.remote_bookmarks);
141
142        bookmarks.push(Bookmark {
143            name: raw.name,
144            commit_id: raw.commit_id,
145            change_id: raw.change_id,
146            remote,
147        });
148    }
149
150    BookmarkParseResult { bookmarks, skipped }
151}
152
153/// Parse `jj log --template LOG_TEMPLATE` output.
154///
155/// Skips malformed lines rather than failing, returning them in
156/// `LogParseResult::skipped` for the caller to handle.
157pub fn parse_log_output(output: &str) -> LogParseResult {
158    let mut entries = Vec::new();
159    let mut skipped = Vec::new();
160
161    for line in output.lines() {
162        if line.trim().is_empty() {
163            continue;
164        }
165
166        let raw: RawLogEntry = match serde_json::from_str(line) {
167            Ok(r) => r,
168            Err(e) => {
169                skipped.push(format!("{e}: {line}"));
170                continue;
171            }
172        };
173
174        let working_copy = if raw.is_working_copy == "true" {
175            WorkingCopy::Current
176        } else {
177            WorkingCopy::Background
178        };
179        let conflict = if raw.conflict == "true" {
180            ConflictState::Conflicted
181        } else {
182            ConflictState::Clean
183        };
184        let content = if raw.empty == "true" {
185            ContentState::Empty
186        } else {
187            ContentState::HasContent
188        };
189
190        entries.push(LogEntry {
191            commit_id: raw.commit_id,
192            change_id: raw.change_id,
193            author_name: raw.author_name,
194            author_email: raw.author_email,
195            description: raw.description,
196            parents: raw.parents.into_iter().filter(|p| !p.is_empty()).collect(),
197            local_bookmarks: raw
198                .local_bookmarks
199                .into_iter()
200                .filter(|b| !b.is_empty())
201                .collect(),
202            remote_bookmarks: raw
203                .remote_bookmarks
204                .into_iter()
205                .filter(|b| !b.is_empty())
206                .collect(),
207            working_copy,
208            conflict,
209            content,
210        });
211    }
212
213    LogParseResult { entries, skipped }
214}
215
216/// Parse `jj git remote list` output into `GitRemote` values.
217pub fn parse_remote_list(output: &str) -> Vec<GitRemote> {
218    output
219        .lines()
220        .filter_map(|line| {
221            let mut parts = line.splitn(2, ' ');
222            let name = parts.next()?.trim().to_string();
223            let url = parts.next()?.trim().to_string();
224            if name.is_empty() {
225                return None;
226            }
227            Some(GitRemote { name, url })
228        })
229        .collect()
230}
231
232/// Parse `jj diff --summary` output into structured [`FileChange`] values.
233///
234/// jj produces lines like:
235/// ```text
236/// M path/to/file.rs
237/// A new_file.rs
238/// D removed.rs
239/// R old/path.rs -> new/path.rs
240/// C from.rs -> to.rs
241/// ```
242///
243/// Unknown status letters are skipped. Blank lines are skipped.
244pub fn parse_diff_summary(output: &str) -> Vec<FileChange> {
245    let mut changes = Vec::new();
246    for line in output.lines() {
247        let line = line.trim_end();
248        if line.is_empty() {
249            continue;
250        }
251        let Some((kind_str, rest)) = line.split_once(' ') else {
252            continue;
253        };
254        let kind = match kind_str {
255            "M" => FileChangeKind::Modified,
256            "A" => FileChangeKind::Added,
257            "D" => FileChangeKind::Deleted,
258            "R" => FileChangeKind::Renamed,
259            "C" => FileChangeKind::Copied,
260            _ => continue,
261        };
262
263        match kind {
264            FileChangeKind::Renamed | FileChangeKind::Copied => {
265                if let Some((from, to)) = rest.split_once(" -> ") {
266                    changes.push(FileChange {
267                        kind,
268                        path: PathBuf::from(to),
269                        from_path: Some(PathBuf::from(from)),
270                    });
271                }
272            }
273            _ => changes.push(FileChange {
274                kind,
275                path: PathBuf::from(rest),
276                from_path: None,
277            }),
278        }
279    }
280    changes
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    // --- extract_name_from_malformed_json ---
288
289    #[test]
290    fn extract_name_valid() {
291        let line = r#"{"name":"feat/stale","commitId":<Error: No Commit available>}"#;
292        assert_eq!(extract_name_from_malformed_json(line), Some("feat/stale".to_string()));
293    }
294
295    #[test]
296    fn extract_name_garbage() {
297        assert_eq!(extract_name_from_malformed_json("garbage"), None);
298    }
299
300    #[test]
301    fn extract_name_empty_string() {
302        assert_eq!(extract_name_from_malformed_json(""), None);
303    }
304
305    #[test]
306    fn extract_name_no_value() {
307        assert_eq!(extract_name_from_malformed_json(r#"{"name":}"#), None);
308    }
309
310    #[test]
311    fn extract_name_with_slash() {
312        let line = r#"{"name":"feat/deep/nested","commitId":"abc"}"#;
313        assert_eq!(extract_name_from_malformed_json(line), Some("feat/deep/nested".to_string()));
314    }
315
316    // --- resolve_remote_status ---
317
318    #[test]
319    fn remote_status_no_remotes() {
320        assert_eq!(resolve_remote_status("feat", &[]), RemoteStatus::Local);
321    }
322
323    #[test]
324    fn remote_status_git_only() {
325        assert_eq!(resolve_remote_status("feat", &["feat@git".into()]), RemoteStatus::Local);
326    }
327
328    #[test]
329    fn remote_status_empty_strings() {
330        assert_eq!(resolve_remote_status("feat", &["".into(), "".into()]), RemoteStatus::Local);
331    }
332
333    #[test]
334    fn remote_status_synced() {
335        assert_eq!(resolve_remote_status("feat", &["feat@origin".into()]), RemoteStatus::Synced);
336    }
337
338    #[test]
339    fn remote_status_synced_multiple() {
340        let remotes = vec!["feat@origin".into(), "feat@upstream".into()];
341        assert_eq!(resolve_remote_status("feat", &remotes), RemoteStatus::Synced);
342    }
343
344    #[test]
345    fn remote_status_unsynced() {
346        assert_eq!(resolve_remote_status("feat", &["other@origin".into()]), RemoteStatus::Unsynced);
347    }
348
349    #[test]
350    fn remote_status_git_ignored_for_sync() {
351        let remotes = vec!["feat@git".into(), "other@origin".into()];
352        assert_eq!(resolve_remote_status("feat", &remotes), RemoteStatus::Unsynced);
353    }
354
355    // --- parse_bookmark_output ---
356
357    #[test]
358    fn bookmark_empty_output() {
359        let result = parse_bookmark_output("");
360        assert!(result.bookmarks.is_empty());
361        assert!(result.skipped.is_empty());
362    }
363
364    #[test]
365    fn bookmark_no_remote() {
366        let output = r#"{"name":"feature","commitId":"abc123","changeId":"xyz789","localBookmarks":["feature"],"remoteBookmarks":[]}"#;
367        let result = parse_bookmark_output(output);
368        assert_eq!(result.bookmarks.len(), 1);
369        assert_eq!(result.bookmarks[0].name, "feature");
370        assert_eq!(result.bookmarks[0].remote, RemoteStatus::Local);
371    }
372
373    #[test]
374    fn bookmark_with_synced_remote() {
375        let output = r#"{"name":"feature","commitId":"abc123","changeId":"xyz789","localBookmarks":["feature"],"remoteBookmarks":["feature@origin"]}"#;
376        let result = parse_bookmark_output(output);
377        assert_eq!(result.bookmarks[0].remote, RemoteStatus::Synced);
378    }
379
380    #[test]
381    fn bookmark_with_unsynced_remote() {
382        let output = r#"{"name":"feature","commitId":"abc123","changeId":"xyz789","localBookmarks":["feature"],"remoteBookmarks":["other@origin"]}"#;
383        let result = parse_bookmark_output(output);
384        assert_eq!(result.bookmarks[0].remote, RemoteStatus::Unsynced);
385    }
386
387    #[test]
388    fn bookmark_git_remote_excluded() {
389        let output = r#"{"name":"feature","commitId":"abc123","changeId":"xyz789","localBookmarks":["feature"],"remoteBookmarks":["feature@git"]}"#;
390        let result = parse_bookmark_output(output);
391        assert_eq!(result.bookmarks[0].remote, RemoteStatus::Local);
392    }
393
394    #[test]
395    fn bookmark_conflicted_skipped_with_name() {
396        let output = concat!(
397            r#"{"name":"feat/stale","commitId":<Error: No Commit available>,"changeId":<Error: No Commit available>,"localBookmarks":[<Error: No Commit available>],"remoteBookmarks":[<Error: No Commit available>]}"#,
398            "\n",
399            r#"{"name":"feat/good","commitId":"abc123","changeId":"xyz789","localBookmarks":["feat/good"],"remoteBookmarks":["feat/good@origin"]}"#,
400            "\n",
401        );
402        let result = parse_bookmark_output(output);
403        assert_eq!(result.bookmarks.len(), 1);
404        assert_eq!(result.bookmarks[0].name, "feat/good");
405        assert_eq!(result.skipped, vec!["feat/stale"]);
406    }
407
408    #[test]
409    fn bookmark_completely_unparseable() {
410        let result = parse_bookmark_output("not json at all");
411        assert!(result.bookmarks.is_empty());
412        assert_eq!(result.skipped, vec!["<unknown>"]);
413    }
414
415    // --- parse_log_output ---
416
417    #[test]
418    fn log_empty_output() {
419        let result = parse_log_output("");
420        assert!(result.entries.is_empty());
421        assert!(result.skipped.is_empty());
422    }
423
424    #[test]
425    fn log_basic_entry() {
426        let output = r#"{"commitId":"abc123","changeId":"xyz789","authorName":"Alice","authorEmail":"alice@example.com","description":"Add feature\n\nDetailed","parents":["def456"],"localBookmarks":["feature"],"remoteBookmarks":[],"isWorkingCopy":"false","conflict":"false","empty":"false"}"#;
427        let result = parse_log_output(output);
428        assert_eq!(result.entries.len(), 1);
429        let entry = &result.entries[0];
430        assert_eq!(entry.commit_id, "abc123");
431        assert_eq!(entry.summary(), "Add feature");
432        assert_eq!(entry.parents, vec!["def456"]);
433        assert_eq!(entry.working_copy, WorkingCopy::Background);
434        assert_eq!(entry.conflict, ConflictState::Clean);
435        assert_eq!(entry.content, ContentState::HasContent);
436    }
437
438    #[test]
439    fn log_empty_commit() {
440        let output = r#"{"commitId":"abc","changeId":"xyz","authorName":"A","authorEmail":"a@b","description":"empty","parents":["p1"],"localBookmarks":[],"remoteBookmarks":[],"isWorkingCopy":"false","conflict":"false","empty":"true"}"#;
441        let result = parse_log_output(output);
442        assert!(result.entries[0].content.is_empty());
443        assert!(!result.entries[0].conflict.is_conflicted());
444    }
445
446    #[test]
447    fn log_conflicted_commit() {
448        let output = r#"{"commitId":"abc","changeId":"xyz","authorName":"A","authorEmail":"a@b","description":"conflict","parents":["p1"],"localBookmarks":[],"remoteBookmarks":[],"isWorkingCopy":"false","conflict":"true","empty":"false"}"#;
449        let result = parse_log_output(output);
450        assert!(result.entries[0].conflict.is_conflicted());
451        assert!(!result.entries[0].content.is_empty());
452    }
453
454    #[test]
455    fn log_conflicted_and_empty() {
456        let output = r#"{"commitId":"abc","changeId":"xyz","authorName":"A","authorEmail":"a@b","description":"both","parents":["p1"],"localBookmarks":[],"remoteBookmarks":[],"isWorkingCopy":"false","conflict":"true","empty":"true"}"#;
457        let result = parse_log_output(output);
458        assert!(result.entries[0].conflict.is_conflicted());
459        assert!(result.entries[0].content.is_empty());
460    }
461
462    #[test]
463    fn log_working_copy() {
464        let output = r#"{"commitId":"abc","changeId":"xyz","authorName":"A","authorEmail":"a@b","description":"wip","parents":["p1"],"localBookmarks":[],"remoteBookmarks":[],"isWorkingCopy":"true","conflict":"false","empty":"false"}"#;
465        let result = parse_log_output(output);
466        assert_eq!(result.entries[0].working_copy, WorkingCopy::Current);
467    }
468
469    #[test]
470    fn log_malformed_line_skipped() {
471        let output = concat!(
472            "not json\n",
473            r#"{"commitId":"abc","changeId":"xyz","authorName":"A","authorEmail":"a@b","description":"ok","parents":[],"localBookmarks":[],"remoteBookmarks":[],"isWorkingCopy":"false","conflict":"false","empty":"false"}"#,
474            "\n",
475        );
476        let result = parse_log_output(output);
477        assert_eq!(result.entries.len(), 1);
478        assert_eq!(result.skipped.len(), 1);
479    }
480
481    #[test]
482    fn log_summary_method() {
483        let output = r#"{"commitId":"abc","changeId":"xyz","authorName":"A","authorEmail":"a@b","description":"line1\nline2\nline3","parents":[],"localBookmarks":[],"remoteBookmarks":[],"isWorkingCopy":"false","conflict":"false","empty":"false"}"#;
484        let result = parse_log_output(output);
485        assert_eq!(result.entries[0].summary(), "line1");
486        assert_eq!(result.entries[0].description, "line1\nline2\nline3");
487    }
488
489    // --- parse_remote_list ---
490
491    #[test]
492    fn remote_list_empty() {
493        assert!(parse_remote_list("").is_empty());
494    }
495
496    #[test]
497    fn remote_list_single() {
498        let remotes = parse_remote_list("origin https://github.com/user/repo.git");
499        assert_eq!(remotes.len(), 1);
500        assert_eq!(remotes[0].name, "origin");
501        assert_eq!(remotes[0].url, "https://github.com/user/repo.git");
502    }
503
504    #[test]
505    fn remote_list_multiple() {
506        let remotes = parse_remote_list("origin https://a.com\nupstream https://b.com");
507        assert_eq!(remotes.len(), 2);
508    }
509
510    #[test]
511    fn remote_list_skips_empty_lines() {
512        let remotes = parse_remote_list("\norigin https://example.com\n\n");
513        assert_eq!(remotes.len(), 1);
514    }
515
516    // --- template constants ---
517
518    #[test]
519    fn bookmark_template_contains_required_fields() {
520        assert!(BOOKMARK_TEMPLATE.contains("name"));
521        assert!(BOOKMARK_TEMPLATE.contains("commitId"));
522        assert!(BOOKMARK_TEMPLATE.contains("escape_json"));
523    }
524
525    #[test]
526    fn log_template_contains_required_fields() {
527        assert!(LOG_TEMPLATE.contains("commitId"));
528        assert!(LOG_TEMPLATE.contains("description"));
529        assert!(LOG_TEMPLATE.contains("conflict"));
530    }
531
532    // --- parse_diff_summary ---
533
534    #[test]
535    fn diff_summary_empty() {
536        assert!(parse_diff_summary("").is_empty());
537    }
538
539    #[test]
540    fn diff_summary_modified() {
541        let changes = parse_diff_summary("M src/lib.rs");
542        assert_eq!(changes.len(), 1);
543        assert_eq!(changes[0].kind, FileChangeKind::Modified);
544        assert_eq!(changes[0].path, PathBuf::from("src/lib.rs"));
545        assert_eq!(changes[0].from_path, None);
546    }
547
548    #[test]
549    fn diff_summary_added() {
550        let changes = parse_diff_summary("A new_file.rs");
551        assert_eq!(changes[0].kind, FileChangeKind::Added);
552        assert_eq!(changes[0].path, PathBuf::from("new_file.rs"));
553    }
554
555    #[test]
556    fn diff_summary_deleted() {
557        let changes = parse_diff_summary("D old.rs");
558        assert_eq!(changes[0].kind, FileChangeKind::Deleted);
559        assert_eq!(changes[0].path, PathBuf::from("old.rs"));
560    }
561
562    #[test]
563    fn diff_summary_renamed() {
564        let changes = parse_diff_summary("R old/path.rs -> new/path.rs");
565        assert_eq!(changes[0].kind, FileChangeKind::Renamed);
566        assert_eq!(changes[0].path, PathBuf::from("new/path.rs"));
567        assert_eq!(changes[0].from_path, Some(PathBuf::from("old/path.rs")));
568    }
569
570    #[test]
571    fn diff_summary_copied() {
572        let changes = parse_diff_summary("C src/a.rs -> src/b.rs");
573        assert_eq!(changes[0].kind, FileChangeKind::Copied);
574        assert_eq!(changes[0].path, PathBuf::from("src/b.rs"));
575        assert_eq!(changes[0].from_path, Some(PathBuf::from("src/a.rs")));
576    }
577
578    #[test]
579    fn diff_summary_multiple() {
580        let output = "M a.rs\nA b.rs\nD c.rs\nR old.rs -> new.rs";
581        let changes = parse_diff_summary(output);
582        assert_eq!(changes.len(), 4);
583        assert_eq!(changes[0].kind, FileChangeKind::Modified);
584        assert_eq!(changes[1].kind, FileChangeKind::Added);
585        assert_eq!(changes[2].kind, FileChangeKind::Deleted);
586        assert_eq!(changes[3].kind, FileChangeKind::Renamed);
587    }
588
589    #[test]
590    fn diff_summary_skips_blank_lines() {
591        let output = "\nM a.rs\n\nA b.rs\n\n";
592        let changes = parse_diff_summary(output);
593        assert_eq!(changes.len(), 2);
594    }
595
596    #[test]
597    fn diff_summary_skips_unknown_status() {
598        let output = "X mysterious.rs\nM known.rs";
599        let changes = parse_diff_summary(output);
600        assert_eq!(changes.len(), 1);
601        assert_eq!(changes[0].path, PathBuf::from("known.rs"));
602    }
603
604    #[test]
605    fn diff_summary_path_with_spaces() {
606        let changes = parse_diff_summary("M path with spaces.rs");
607        assert_eq!(changes[0].path, PathBuf::from("path with spaces.rs"));
608    }
609
610    #[test]
611    fn diff_summary_rename_without_arrow_skipped() {
612        // "R" without " -> " is malformed and skipped
613        let changes = parse_diff_summary("R just_one_path.rs");
614        assert!(changes.is_empty());
615    }
616}