Skip to main content

vcs_runner/
parse.rs

1use std::collections::HashSet;
2
3use serde::Deserialize;
4
5use crate::types::{
6    Bookmark, ConflictState, ContentState, GitRemote, LogEntry, RemoteStatus, WorkingCopy,
7};
8
9/// jj template for `jj bookmark list` producing line-delimited JSON.
10///
11/// Use with [`parse_bookmark_output`] to get structured [`Bookmark`] values.
12/// jj's `escape_json()` includes surrounding quotes, so array elements
13/// use it directly with comma joins (no extra quote wrapping).
14pub const BOOKMARK_TEMPLATE: &str = concat!(
15    r#"'{"name":' ++ name.escape_json()"#,
16    r#" ++ ',"commitId":' ++ normal_target.commit_id().short().escape_json()"#,
17    r#" ++ ',"changeId":' ++ normal_target.change_id().short().escape_json()"#,
18    r#" ++ ',"localBookmarks":[' ++ normal_target.local_bookmarks().map(|b| b.name().escape_json()).join(',') ++ ']'"#,
19    r#" ++ ',"remoteBookmarks":[' ++ normal_target.remote_bookmarks().map(|b| stringify(b.name() ++ "@" ++ b.remote()).escape_json()).join(',') ++ ']'"#,
20    r#" ++ '}' ++ "\n""#,
21);
22
23/// jj template for `jj log` producing line-delimited JSON entries.
24///
25/// Use with [`parse_log_output`] to get structured [`LogEntry`] values.
26pub const LOG_TEMPLATE: &str = concat!(
27    r#"'{"commitId":' ++ commit_id.short().escape_json()"#,
28    r#" ++ ',"changeId":' ++ change_id.short().escape_json()"#,
29    r#" ++ ',"authorName":' ++ author.name().escape_json()"#,
30    r#" ++ ',"authorEmail":' ++ stringify(author.email()).escape_json()"#,
31    r#" ++ ',"description":' ++ description.escape_json()"#,
32    r#" ++ ',"parents":[' ++ parents.map(|p| p.commit_id().short().escape_json()).join(',') ++ ']'"#,
33    r#" ++ ',"localBookmarks":[' ++ local_bookmarks.map(|b| b.name().escape_json()).join(',') ++ ']'"#,
34    r#" ++ ',"remoteBookmarks":[' ++ remote_bookmarks.map(|b| stringify(b.name() ++ "@" ++ b.remote()).escape_json()).join(',') ++ ']'"#,
35    r#" ++ ',"isWorkingCopy":' ++ if(current_working_copy, '"true"', '"false"')"#,
36    r#" ++ ',"conflict":' ++ if(conflict, '"true"', '"false"')"#,
37    r#" ++ ',"empty":' ++ if(empty, '"true"', '"false"')"#,
38    r#" ++ '}' ++ "\n""#,
39);
40
41/// Result of parsing bookmark output, including any skipped entries.
42#[derive(Debug)]
43pub struct BookmarkParseResult {
44    pub bookmarks: Vec<Bookmark>,
45    /// Names of bookmarks that were skipped due to malformed JSON
46    /// (typically stale/conflicted bookmarks pointing at missing commits).
47    pub skipped: Vec<String>,
48}
49
50/// Result of parsing log output, including any skipped entries.
51#[derive(Debug)]
52pub struct LogParseResult {
53    pub entries: Vec<LogEntry>,
54    /// Lines that failed to parse.
55    pub skipped: Vec<String>,
56}
57
58#[derive(Debug, Deserialize)]
59#[serde(rename_all = "camelCase")]
60struct RawBookmark {
61    name: String,
62    commit_id: String,
63    change_id: String,
64    local_bookmarks: Vec<String>,
65    remote_bookmarks: Vec<String>,
66}
67
68#[derive(Debug, Deserialize)]
69#[serde(rename_all = "camelCase")]
70struct RawLogEntry {
71    commit_id: String,
72    change_id: String,
73    author_name: String,
74    author_email: String,
75    description: String,
76    parents: Vec<String>,
77    local_bookmarks: Vec<String>,
78    remote_bookmarks: Vec<String>,
79    is_working_copy: String,
80    conflict: String,
81    empty: String,
82}
83
84fn extract_name_from_malformed_json(line: &str) -> Option<String> {
85    let after_key = line.split(r#""name":"#).nth(1)?;
86    let after_quote = after_key.strip_prefix('"')?;
87    let end = after_quote.find('"')?;
88    Some(after_quote[..end].to_string())
89}
90
91fn resolve_remote_status(name: &str, remote_bookmarks: &[String]) -> RemoteStatus {
92    let non_git_remotes: Vec<&String> = remote_bookmarks
93        .iter()
94        .filter(|rb| !rb.is_empty() && !rb.ends_with("@git"))
95        .collect();
96
97    if non_git_remotes.is_empty() {
98        return RemoteStatus::Local;
99    }
100
101    let synced = non_git_remotes
102        .iter()
103        .any(|rb| rb.starts_with(&format!("{name}@")));
104
105    if synced { RemoteStatus::Synced } else { RemoteStatus::Unsynced }
106}
107
108/// Parse `jj bookmark list --template BOOKMARK_TEMPLATE` output.
109///
110/// Skips conflicted/stale bookmarks that produce unparseable JSON and
111/// filters out remote-only entries (empty `localBookmarks`).
112pub fn parse_bookmark_output(output: &str) -> BookmarkParseResult {
113    let mut bookmarks = Vec::new();
114    let mut skipped = Vec::new();
115    let mut warned_names: HashSet<String> = HashSet::new();
116
117    for line in output.lines() {
118        if line.trim().is_empty() {
119            continue;
120        }
121
122        let raw: RawBookmark = match serde_json::from_str(line) {
123            Ok(r) => r,
124            Err(_) => {
125                let name = extract_name_from_malformed_json(line)
126                    .unwrap_or_else(|| "<unknown>".to_string());
127                if warned_names.insert(name.clone()) {
128                    skipped.push(name);
129                }
130                continue;
131            }
132        };
133
134        if raw.local_bookmarks.is_empty() {
135            continue;
136        }
137
138        let remote = resolve_remote_status(&raw.name, &raw.remote_bookmarks);
139
140        bookmarks.push(Bookmark {
141            name: raw.name,
142            commit_id: raw.commit_id,
143            change_id: raw.change_id,
144            remote,
145        });
146    }
147
148    BookmarkParseResult { bookmarks, skipped }
149}
150
151/// Parse `jj log --template LOG_TEMPLATE` output.
152///
153/// Skips malformed lines rather than failing, returning them in
154/// `LogParseResult::skipped` for the caller to handle.
155pub fn parse_log_output(output: &str) -> LogParseResult {
156    let mut entries = Vec::new();
157    let mut skipped = Vec::new();
158
159    for line in output.lines() {
160        if line.trim().is_empty() {
161            continue;
162        }
163
164        let raw: RawLogEntry = match serde_json::from_str(line) {
165            Ok(r) => r,
166            Err(e) => {
167                skipped.push(format!("{e}: {line}"));
168                continue;
169            }
170        };
171
172        let working_copy = if raw.is_working_copy == "true" {
173            WorkingCopy::Current
174        } else {
175            WorkingCopy::Background
176        };
177        let conflict = if raw.conflict == "true" {
178            ConflictState::Conflicted
179        } else {
180            ConflictState::Clean
181        };
182        let content = if raw.empty == "true" {
183            ContentState::Empty
184        } else {
185            ContentState::HasContent
186        };
187
188        entries.push(LogEntry {
189            commit_id: raw.commit_id,
190            change_id: raw.change_id,
191            author_name: raw.author_name,
192            author_email: raw.author_email,
193            description: raw.description,
194            parents: raw.parents.into_iter().filter(|p| !p.is_empty()).collect(),
195            local_bookmarks: raw
196                .local_bookmarks
197                .into_iter()
198                .filter(|b| !b.is_empty())
199                .collect(),
200            remote_bookmarks: raw
201                .remote_bookmarks
202                .into_iter()
203                .filter(|b| !b.is_empty())
204                .collect(),
205            working_copy,
206            conflict,
207            content,
208        });
209    }
210
211    LogParseResult { entries, skipped }
212}
213
214/// Parse `jj git remote list` output into `GitRemote` values.
215pub fn parse_remote_list(output: &str) -> Vec<GitRemote> {
216    output
217        .lines()
218        .filter_map(|line| {
219            let mut parts = line.splitn(2, ' ');
220            let name = parts.next()?.trim().to_string();
221            let url = parts.next()?.trim().to_string();
222            if name.is_empty() {
223                return None;
224            }
225            Some(GitRemote { name, url })
226        })
227        .collect()
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    // --- extract_name_from_malformed_json ---
235
236    #[test]
237    fn extract_name_valid() {
238        let line = r#"{"name":"feat/stale","commitId":<Error: No Commit available>}"#;
239        assert_eq!(extract_name_from_malformed_json(line), Some("feat/stale".to_string()));
240    }
241
242    #[test]
243    fn extract_name_garbage() {
244        assert_eq!(extract_name_from_malformed_json("garbage"), None);
245    }
246
247    #[test]
248    fn extract_name_empty_string() {
249        assert_eq!(extract_name_from_malformed_json(""), None);
250    }
251
252    #[test]
253    fn extract_name_no_value() {
254        assert_eq!(extract_name_from_malformed_json(r#"{"name":}"#), None);
255    }
256
257    #[test]
258    fn extract_name_with_slash() {
259        let line = r#"{"name":"feat/deep/nested","commitId":"abc"}"#;
260        assert_eq!(extract_name_from_malformed_json(line), Some("feat/deep/nested".to_string()));
261    }
262
263    // --- resolve_remote_status ---
264
265    #[test]
266    fn remote_status_no_remotes() {
267        assert_eq!(resolve_remote_status("feat", &[]), RemoteStatus::Local);
268    }
269
270    #[test]
271    fn remote_status_git_only() {
272        assert_eq!(resolve_remote_status("feat", &["feat@git".into()]), RemoteStatus::Local);
273    }
274
275    #[test]
276    fn remote_status_empty_strings() {
277        assert_eq!(resolve_remote_status("feat", &["".into(), "".into()]), RemoteStatus::Local);
278    }
279
280    #[test]
281    fn remote_status_synced() {
282        assert_eq!(resolve_remote_status("feat", &["feat@origin".into()]), RemoteStatus::Synced);
283    }
284
285    #[test]
286    fn remote_status_synced_multiple() {
287        let remotes = vec!["feat@origin".into(), "feat@upstream".into()];
288        assert_eq!(resolve_remote_status("feat", &remotes), RemoteStatus::Synced);
289    }
290
291    #[test]
292    fn remote_status_unsynced() {
293        assert_eq!(resolve_remote_status("feat", &["other@origin".into()]), RemoteStatus::Unsynced);
294    }
295
296    #[test]
297    fn remote_status_git_ignored_for_sync() {
298        let remotes = vec!["feat@git".into(), "other@origin".into()];
299        assert_eq!(resolve_remote_status("feat", &remotes), RemoteStatus::Unsynced);
300    }
301
302    // --- parse_bookmark_output ---
303
304    #[test]
305    fn bookmark_empty_output() {
306        let result = parse_bookmark_output("");
307        assert!(result.bookmarks.is_empty());
308        assert!(result.skipped.is_empty());
309    }
310
311    #[test]
312    fn bookmark_no_remote() {
313        let output = r#"{"name":"feature","commitId":"abc123","changeId":"xyz789","localBookmarks":["feature"],"remoteBookmarks":[]}"#;
314        let result = parse_bookmark_output(output);
315        assert_eq!(result.bookmarks.len(), 1);
316        assert_eq!(result.bookmarks[0].name, "feature");
317        assert_eq!(result.bookmarks[0].remote, RemoteStatus::Local);
318    }
319
320    #[test]
321    fn bookmark_with_synced_remote() {
322        let output = r#"{"name":"feature","commitId":"abc123","changeId":"xyz789","localBookmarks":["feature"],"remoteBookmarks":["feature@origin"]}"#;
323        let result = parse_bookmark_output(output);
324        assert_eq!(result.bookmarks[0].remote, RemoteStatus::Synced);
325    }
326
327    #[test]
328    fn bookmark_with_unsynced_remote() {
329        let output = r#"{"name":"feature","commitId":"abc123","changeId":"xyz789","localBookmarks":["feature"],"remoteBookmarks":["other@origin"]}"#;
330        let result = parse_bookmark_output(output);
331        assert_eq!(result.bookmarks[0].remote, RemoteStatus::Unsynced);
332    }
333
334    #[test]
335    fn bookmark_git_remote_excluded() {
336        let output = r#"{"name":"feature","commitId":"abc123","changeId":"xyz789","localBookmarks":["feature"],"remoteBookmarks":["feature@git"]}"#;
337        let result = parse_bookmark_output(output);
338        assert_eq!(result.bookmarks[0].remote, RemoteStatus::Local);
339    }
340
341    #[test]
342    fn bookmark_conflicted_skipped_with_name() {
343        let output = concat!(
344            r#"{"name":"feat/stale","commitId":<Error: No Commit available>,"changeId":<Error: No Commit available>,"localBookmarks":[<Error: No Commit available>],"remoteBookmarks":[<Error: No Commit available>]}"#,
345            "\n",
346            r#"{"name":"feat/good","commitId":"abc123","changeId":"xyz789","localBookmarks":["feat/good"],"remoteBookmarks":["feat/good@origin"]}"#,
347            "\n",
348        );
349        let result = parse_bookmark_output(output);
350        assert_eq!(result.bookmarks.len(), 1);
351        assert_eq!(result.bookmarks[0].name, "feat/good");
352        assert_eq!(result.skipped, vec!["feat/stale"]);
353    }
354
355    #[test]
356    fn bookmark_completely_unparseable() {
357        let result = parse_bookmark_output("not json at all");
358        assert!(result.bookmarks.is_empty());
359        assert_eq!(result.skipped, vec!["<unknown>"]);
360    }
361
362    // --- parse_log_output ---
363
364    #[test]
365    fn log_empty_output() {
366        let result = parse_log_output("");
367        assert!(result.entries.is_empty());
368        assert!(result.skipped.is_empty());
369    }
370
371    #[test]
372    fn log_basic_entry() {
373        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"}"#;
374        let result = parse_log_output(output);
375        assert_eq!(result.entries.len(), 1);
376        let entry = &result.entries[0];
377        assert_eq!(entry.commit_id, "abc123");
378        assert_eq!(entry.summary(), "Add feature");
379        assert_eq!(entry.parents, vec!["def456"]);
380        assert_eq!(entry.working_copy, WorkingCopy::Background);
381        assert_eq!(entry.conflict, ConflictState::Clean);
382        assert_eq!(entry.content, ContentState::HasContent);
383    }
384
385    #[test]
386    fn log_empty_commit() {
387        let output = r#"{"commitId":"abc","changeId":"xyz","authorName":"A","authorEmail":"a@b","description":"empty","parents":["p1"],"localBookmarks":[],"remoteBookmarks":[],"isWorkingCopy":"false","conflict":"false","empty":"true"}"#;
388        let result = parse_log_output(output);
389        assert!(result.entries[0].content.is_empty());
390        assert!(!result.entries[0].conflict.is_conflicted());
391    }
392
393    #[test]
394    fn log_conflicted_commit() {
395        let output = r#"{"commitId":"abc","changeId":"xyz","authorName":"A","authorEmail":"a@b","description":"conflict","parents":["p1"],"localBookmarks":[],"remoteBookmarks":[],"isWorkingCopy":"false","conflict":"true","empty":"false"}"#;
396        let result = parse_log_output(output);
397        assert!(result.entries[0].conflict.is_conflicted());
398        assert!(!result.entries[0].content.is_empty());
399    }
400
401    #[test]
402    fn log_conflicted_and_empty() {
403        let output = r#"{"commitId":"abc","changeId":"xyz","authorName":"A","authorEmail":"a@b","description":"both","parents":["p1"],"localBookmarks":[],"remoteBookmarks":[],"isWorkingCopy":"false","conflict":"true","empty":"true"}"#;
404        let result = parse_log_output(output);
405        assert!(result.entries[0].conflict.is_conflicted());
406        assert!(result.entries[0].content.is_empty());
407    }
408
409    #[test]
410    fn log_working_copy() {
411        let output = r#"{"commitId":"abc","changeId":"xyz","authorName":"A","authorEmail":"a@b","description":"wip","parents":["p1"],"localBookmarks":[],"remoteBookmarks":[],"isWorkingCopy":"true","conflict":"false","empty":"false"}"#;
412        let result = parse_log_output(output);
413        assert_eq!(result.entries[0].working_copy, WorkingCopy::Current);
414    }
415
416    #[test]
417    fn log_malformed_line_skipped() {
418        let output = concat!(
419            "not json\n",
420            r#"{"commitId":"abc","changeId":"xyz","authorName":"A","authorEmail":"a@b","description":"ok","parents":[],"localBookmarks":[],"remoteBookmarks":[],"isWorkingCopy":"false","conflict":"false","empty":"false"}"#,
421            "\n",
422        );
423        let result = parse_log_output(output);
424        assert_eq!(result.entries.len(), 1);
425        assert_eq!(result.skipped.len(), 1);
426    }
427
428    #[test]
429    fn log_summary_method() {
430        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"}"#;
431        let result = parse_log_output(output);
432        assert_eq!(result.entries[0].summary(), "line1");
433        assert_eq!(result.entries[0].description, "line1\nline2\nline3");
434    }
435
436    // --- parse_remote_list ---
437
438    #[test]
439    fn remote_list_empty() {
440        assert!(parse_remote_list("").is_empty());
441    }
442
443    #[test]
444    fn remote_list_single() {
445        let remotes = parse_remote_list("origin https://github.com/user/repo.git");
446        assert_eq!(remotes.len(), 1);
447        assert_eq!(remotes[0].name, "origin");
448        assert_eq!(remotes[0].url, "https://github.com/user/repo.git");
449    }
450
451    #[test]
452    fn remote_list_multiple() {
453        let remotes = parse_remote_list("origin https://a.com\nupstream https://b.com");
454        assert_eq!(remotes.len(), 2);
455    }
456
457    #[test]
458    fn remote_list_skips_empty_lines() {
459        let remotes = parse_remote_list("\norigin https://example.com\n\n");
460        assert_eq!(remotes.len(), 1);
461    }
462
463    // --- template constants ---
464
465    #[test]
466    fn bookmark_template_contains_required_fields() {
467        assert!(BOOKMARK_TEMPLATE.contains("name"));
468        assert!(BOOKMARK_TEMPLATE.contains("commitId"));
469        assert!(BOOKMARK_TEMPLATE.contains("escape_json"));
470    }
471
472    #[test]
473    fn log_template_contains_required_fields() {
474        assert!(LOG_TEMPLATE.contains("commitId"));
475        assert!(LOG_TEMPLATE.contains("description"));
476        assert!(LOG_TEMPLATE.contains("conflict"));
477    }
478}