1use crate::error::{Error, Result};
6use crate::types::{Bookmark, BookmarkSegment, ChangeGraph, NarrowedBookmarkSegment};
7
8#[derive(Debug, Clone)]
10pub struct SubmissionAnalysis {
11 pub target_bookmark: String,
13 pub segments: Vec<NarrowedBookmarkSegment>,
15}
16
17pub fn analyze_submission(
23 graph: &ChangeGraph,
24 target_bookmark: Option<&str>,
25) -> Result<SubmissionAnalysis> {
26 let stack = graph
27 .stack
28 .as_ref()
29 .ok_or_else(|| Error::NoStack("No bookmarks found between trunk and working copy. Create a bookmark with: jj bookmark create <name>".to_string()))?;
30
31 if stack.segments.is_empty() {
32 return Err(Error::NoStack("Stack has no segments".to_string()));
33 }
34
35 let target_index = if let Some(target) = target_bookmark {
37 stack
38 .segments
39 .iter()
40 .position(|segment| segment.bookmarks.iter().any(|b| b.name == target))
41 .ok_or_else(|| Error::BookmarkNotFound(target.to_string()))?
42 } else {
43 stack.segments.len() - 1
45 };
46
47 let relevant_segments = &stack.segments[0..=target_index];
49
50 let narrowed: Vec<NarrowedBookmarkSegment> = relevant_segments
52 .iter()
53 .map(|segment| {
54 let bookmark = select_bookmark_for_segment(segment, target_bookmark);
55
56 NarrowedBookmarkSegment {
57 bookmark,
58 changes: segment.changes.clone(),
59 }
60 })
61 .collect();
62
63 let actual_target = narrowed
65 .last()
66 .map(|s| s.bookmark.name.clone())
67 .unwrap_or_default();
68
69 Ok(SubmissionAnalysis {
70 target_bookmark: actual_target,
71 segments: narrowed,
72 })
73}
74
75pub fn select_bookmark_for_segment(segment: &BookmarkSegment, target: Option<&str>) -> Bookmark {
83 let bookmarks = &segment.bookmarks;
84
85 if bookmarks.len() == 1 {
87 return bookmarks[0].clone();
88 }
89
90 if let Some(target_name) = target
92 && let Some(b) = bookmarks.iter().find(|b| b.name == target_name)
93 {
94 return b.clone();
95 }
96
97 let candidates: Vec<_> = bookmarks
99 .iter()
100 .filter(|b| !is_temporary_bookmark(&b.name))
101 .collect();
102
103 let pool: Vec<&Bookmark> = if candidates.is_empty() {
104 bookmarks.iter().collect()
105 } else {
106 candidates
107 };
108
109 pool.into_iter()
111 .min_by(|a, b| match a.name.len().cmp(&b.name.len()) {
112 std::cmp::Ordering::Equal => a.name.cmp(&b.name),
113 other => other,
114 })
115 .cloned()
116 .unwrap_or_else(|| bookmarks[0].clone())
117}
118
119fn is_temporary_bookmark(name: &str) -> bool {
121 let lower = name.to_lowercase();
122 lower.contains("wip")
123 || lower.contains("tmp")
124 || lower.contains("temp")
125 || lower.contains("backup")
126 || lower.ends_with("-old")
127 || lower.ends_with("_old")
128 || lower.starts_with("wip-")
129 || lower.starts_with("wip/")
130}
131
132pub fn get_base_branch(
137 bookmark_name: &str,
138 segments: &[NarrowedBookmarkSegment],
139 default_branch: &str,
140) -> Result<String> {
141 for (i, segment) in segments.iter().enumerate() {
142 if segment.bookmark.name == bookmark_name {
143 if i == 0 {
144 return Ok(default_branch.to_string());
146 }
147 return Ok(segments[i - 1].bookmark.name.clone());
149 }
150 }
151
152 Err(Error::BookmarkNotFound(bookmark_name.to_string()))
153}
154
155pub fn generate_pr_title(
161 bookmark_name: &str,
162 segments: &[NarrowedBookmarkSegment],
163) -> Result<String> {
164 let segment = segments
165 .iter()
166 .find(|s| s.bookmark.name == bookmark_name)
167 .ok_or_else(|| Error::BookmarkNotFound(bookmark_name.to_string()))?;
168
169 if segment.changes.is_empty() {
170 return Ok(bookmark_name.to_string());
171 }
172
173 let root_commit = segment
176 .changes
177 .last()
178 .expect("segment has at least one change");
179 let title = &root_commit.description_first_line;
180 if title.is_empty() {
181 Ok(bookmark_name.to_string())
182 } else {
183 Ok(title.clone())
184 }
185}
186
187pub fn create_narrowed_segments(
191 resolved_bookmarks: &[Bookmark],
192 analysis: &SubmissionAnalysis,
193) -> Result<Vec<NarrowedBookmarkSegment>> {
194 let mut segments = Vec::new();
195
196 for (i, bookmark) in resolved_bookmarks.iter().enumerate() {
197 let corresponding_segment = analysis
198 .segments
199 .get(i)
200 .ok_or_else(|| Error::Internal(format!("No segment at index {i}")))?;
201
202 segments.push(NarrowedBookmarkSegment {
203 bookmark: bookmark.clone(),
204 changes: corresponding_segment.changes.clone(),
205 });
206 }
207
208 Ok(segments)
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214 use crate::types::{BookmarkSegment, BranchStack, LogEntry};
215 use chrono::Utc;
216
217 fn make_bookmark(name: &str) -> Bookmark {
218 Bookmark {
219 name: name.to_string(),
220 commit_id: format!("{name}_commit"),
221 change_id: format!("{name}_change"),
222 has_remote: false,
223 is_synced: false,
224 }
225 }
226
227 fn make_log_entry(desc: &str, bookmarks: &[&str]) -> LogEntry {
228 LogEntry {
229 commit_id: format!("{desc}_commit"),
230 change_id: format!("{desc}_change"),
231 author_name: "Test".to_string(),
232 author_email: "test@example.com".to_string(),
233 description_first_line: desc.to_string(),
234 parents: vec![],
235 local_bookmarks: bookmarks.iter().map(ToString::to_string).collect(),
236 remote_bookmarks: vec![],
237 is_working_copy: false,
238 authored_at: Utc::now(),
239 committed_at: Utc::now(),
240 }
241 }
242
243 #[test]
244 fn test_analyze_submission_finds_target() {
245 let bm1 = make_bookmark("feat-a");
246 let bm2 = make_bookmark("feat-b");
247
248 let stack = BranchStack {
249 segments: vec![
250 BookmarkSegment {
251 bookmarks: vec![bm1.clone()],
252 changes: vec![make_log_entry("First change", &["feat-a"])],
253 },
254 BookmarkSegment {
255 bookmarks: vec![bm2.clone()],
256 changes: vec![make_log_entry("Second change", &["feat-b"])],
257 },
258 ],
259 };
260
261 let graph = ChangeGraph {
262 bookmarks: [("feat-a".to_string(), bm1), ("feat-b".to_string(), bm2)]
263 .into_iter()
264 .collect(),
265 stack: Some(stack),
266 excluded_bookmark_count: 0,
267 };
268
269 let analysis = analyze_submission(&graph, Some("feat-b")).unwrap();
270 assert_eq!(analysis.target_bookmark, "feat-b");
271 assert_eq!(analysis.segments.len(), 2);
272 assert_eq!(analysis.segments[0].bookmark.name, "feat-a");
273 assert_eq!(analysis.segments[1].bookmark.name, "feat-b");
274 }
275
276 #[test]
277 fn test_analyze_submission_no_target_uses_leaf() {
278 let bm1 = make_bookmark("feat-a");
279 let bm2 = make_bookmark("feat-b");
280
281 let stack = BranchStack {
282 segments: vec![
283 BookmarkSegment {
284 bookmarks: vec![bm1.clone()],
285 changes: vec![make_log_entry("First change", &["feat-a"])],
286 },
287 BookmarkSegment {
288 bookmarks: vec![bm2.clone()],
289 changes: vec![make_log_entry("Second change", &["feat-b"])],
290 },
291 ],
292 };
293
294 let graph = ChangeGraph {
295 bookmarks: [("feat-a".to_string(), bm1), ("feat-b".to_string(), bm2)]
296 .into_iter()
297 .collect(),
298 stack: Some(stack),
299 excluded_bookmark_count: 0,
300 };
301
302 let analysis = analyze_submission(&graph, None).unwrap();
304 assert_eq!(analysis.target_bookmark, "feat-b");
305 assert_eq!(analysis.segments.len(), 2);
306 }
307
308 #[test]
309 fn test_analyze_submission_no_stack() {
310 let graph = ChangeGraph::default();
311 let result = analyze_submission(&graph, None);
312 assert!(matches!(result, Err(Error::NoStack(_))));
313 }
314
315 #[test]
316 fn test_analyze_submission_bookmark_not_found() {
317 let bm1 = make_bookmark("feat-a");
318
319 let stack = BranchStack {
320 segments: vec![BookmarkSegment {
321 bookmarks: vec![bm1.clone()],
322 changes: vec![make_log_entry("First change", &["feat-a"])],
323 }],
324 };
325
326 let graph = ChangeGraph {
327 bookmarks: std::iter::once(("feat-a".to_string(), bm1)).collect(),
328 stack: Some(stack),
329 excluded_bookmark_count: 0,
330 };
331
332 let result = analyze_submission(&graph, Some("nonexistent"));
333 assert!(matches!(result, Err(Error::BookmarkNotFound(_))));
334 }
335
336 #[test]
337 fn test_get_base_branch_first() {
338 let segments = vec![NarrowedBookmarkSegment {
339 bookmark: make_bookmark("feat-a"),
340 changes: vec![],
341 }];
342
343 let base = get_base_branch("feat-a", &segments, "main").unwrap();
344 assert_eq!(base, "main");
345 }
346
347 #[test]
348 fn test_get_base_branch_stacked() {
349 let segments = vec![
350 NarrowedBookmarkSegment {
351 bookmark: make_bookmark("feat-a"),
352 changes: vec![],
353 },
354 NarrowedBookmarkSegment {
355 bookmark: make_bookmark("feat-b"),
356 changes: vec![],
357 },
358 ];
359
360 let base = get_base_branch("feat-b", &segments, "main").unwrap();
361 assert_eq!(base, "feat-a");
362 }
363
364 #[test]
365 fn test_generate_pr_title() {
366 let segments = vec![NarrowedBookmarkSegment {
367 bookmark: make_bookmark("feat-a"),
368 changes: vec![make_log_entry("Add cool feature", &["feat-a"])],
369 }];
370
371 let title = generate_pr_title("feat-a", &segments).unwrap();
372 assert_eq!(title, "Add cool feature");
373 }
374
375 #[test]
376 fn test_generate_pr_title_empty_fallback() {
377 let segments = vec![NarrowedBookmarkSegment {
378 bookmark: make_bookmark("feat-a"),
379 changes: vec![make_log_entry("", &["feat-a"])],
380 }];
381
382 let title = generate_pr_title("feat-a", &segments).unwrap();
383 assert_eq!(title, "feat-a");
384 }
385
386 #[test]
387 fn test_generate_pr_title_uses_root_commit() {
388 let segments = vec![NarrowedBookmarkSegment {
390 bookmark: make_bookmark("feat-a"),
391 changes: vec![
392 make_log_entry("Fix typo in feature", &["feat-a"]), make_log_entry("Add tests for feature", &[]), make_log_entry("Implement cool feature", &[]), ],
396 }];
397
398 let title = generate_pr_title("feat-a", &segments).unwrap();
399 assert_eq!(title, "Implement cool feature");
401 }
402
403 #[test]
404 fn test_select_bookmark_single() {
405 let segment = BookmarkSegment {
406 bookmarks: vec![make_bookmark("feat-a")],
407 changes: vec![],
408 };
409
410 let selected = select_bookmark_for_segment(&segment, None);
411 assert_eq!(selected.name, "feat-a");
412 }
413
414 #[test]
415 fn test_select_bookmark_prefers_target() {
416 let segment = BookmarkSegment {
417 bookmarks: vec![make_bookmark("feat-a"), make_bookmark("feat-b")],
418 changes: vec![],
419 };
420
421 let selected = select_bookmark_for_segment(&segment, Some("feat-b"));
422 assert_eq!(selected.name, "feat-b");
423 }
424
425 #[test]
426 fn test_select_bookmark_excludes_wip() {
427 let segment = BookmarkSegment {
428 bookmarks: vec![make_bookmark("feat-a-wip"), make_bookmark("feat-a")],
429 changes: vec![],
430 };
431
432 let selected = select_bookmark_for_segment(&segment, None);
433 assert_eq!(selected.name, "feat-a");
434 }
435
436 #[test]
437 fn test_select_bookmark_excludes_tmp() {
438 let segment = BookmarkSegment {
439 bookmarks: vec![make_bookmark("tmp-test"), make_bookmark("feature")],
440 changes: vec![],
441 };
442
443 let selected = select_bookmark_for_segment(&segment, None);
444 assert_eq!(selected.name, "feature");
445 }
446
447 #[test]
448 fn test_select_bookmark_excludes_backup() {
449 let segment = BookmarkSegment {
450 bookmarks: vec![make_bookmark("feat-backup"), make_bookmark("feat")],
451 changes: vec![],
452 };
453
454 let selected = select_bookmark_for_segment(&segment, None);
455 assert_eq!(selected.name, "feat");
456 }
457
458 #[test]
459 fn test_select_bookmark_excludes_old_suffix() {
460 let segment = BookmarkSegment {
461 bookmarks: vec![make_bookmark("feat-old"), make_bookmark("feat")],
462 changes: vec![],
463 };
464
465 let selected = select_bookmark_for_segment(&segment, None);
466 assert_eq!(selected.name, "feat");
467 }
468
469 #[test]
470 fn test_select_bookmark_prefers_shorter() {
471 let segment = BookmarkSegment {
472 bookmarks: vec![
473 make_bookmark("feature-implementation"),
474 make_bookmark("feat"),
475 ],
476 changes: vec![],
477 };
478
479 let selected = select_bookmark_for_segment(&segment, None);
480 assert_eq!(selected.name, "feat");
481 }
482
483 #[test]
484 fn test_select_bookmark_alphabetical_tiebreaker() {
485 let segment = BookmarkSegment {
487 bookmarks: vec![make_bookmark("beta1"), make_bookmark("alpha")],
488 changes: vec![],
489 };
490
491 let selected = select_bookmark_for_segment(&segment, None);
492 assert_eq!(selected.name, "alpha");
493 }
494
495 #[test]
496 fn test_select_bookmark_prefers_shorter_over_alphabetical() {
497 let segment = BookmarkSegment {
499 bookmarks: vec![make_bookmark("alpha"), make_bookmark("beta")],
500 changes: vec![],
501 };
502
503 let selected = select_bookmark_for_segment(&segment, None);
504 assert_eq!(selected.name, "beta"); }
506
507 #[test]
508 fn test_select_bookmark_all_temporary_falls_back() {
509 let segment = BookmarkSegment {
510 bookmarks: vec![make_bookmark("wip-a"), make_bookmark("tmp-b")],
511 changes: vec![],
512 };
513
514 let selected = select_bookmark_for_segment(&segment, None);
516 assert_eq!(selected.name, "tmp-b"); }
518
519 #[test]
520 fn test_is_temporary_bookmark() {
521 assert!(is_temporary_bookmark("feat-wip"));
522 assert!(is_temporary_bookmark("WIP-feature"));
523 assert!(is_temporary_bookmark("wip/test"));
524 assert!(is_temporary_bookmark("tmp-test"));
525 assert!(is_temporary_bookmark("temp-feature"));
526 assert!(is_temporary_bookmark("my-backup"));
527 assert!(is_temporary_bookmark("feat-old"));
528 assert!(is_temporary_bookmark("feat_old"));
529
530 assert!(!is_temporary_bookmark("feature"));
531 assert!(!is_temporary_bookmark("my-feat"));
532 assert!(!is_temporary_bookmark("gold-feature")); }
534}