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
11pub 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
25pub 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#[derive(Debug)]
45pub struct BookmarkParseResult {
46 pub bookmarks: Vec<Bookmark>,
47 pub skipped: Vec<String>,
50}
51
52#[derive(Debug)]
54pub struct LogParseResult {
55 pub entries: Vec<LogEntry>,
56 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
110pub 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
153pub 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
216pub 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
232pub 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 let changes = parse_diff_summary("R just_one_path.rs");
614 assert!(changes.is_empty());
615 }
616}