1use std::collections::HashSet;
2
3use serde::Deserialize;
4
5use crate::types::{
6 Bookmark, ConflictState, ContentState, GitRemote, LogEntry, RemoteStatus, WorkingCopy,
7};
8
9pub 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
23pub 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#[derive(Debug)]
43pub struct BookmarkParseResult {
44 pub bookmarks: Vec<Bookmark>,
45 pub skipped: Vec<String>,
48}
49
50#[derive(Debug)]
52pub struct LogParseResult {
53 pub entries: Vec<LogEntry>,
54 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
108pub 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
151pub 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
214pub 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 #[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 #[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 #[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 #[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 #[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 #[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}