Skip to main content

jj_ryu/submit/
analysis.rs

1//! Phase 1: Submission analysis
2//!
3//! Identifies what needs to be submitted for a given target bookmark.
4
5use crate::error::{Error, Result};
6use crate::types::{Bookmark, BookmarkSegment, ChangeGraph, NarrowedBookmarkSegment};
7
8/// Result of submission analysis
9#[derive(Debug, Clone)]
10pub struct SubmissionAnalysis {
11    /// Target bookmark name
12    pub target_bookmark: String,
13    /// Segments to submit (from trunk towards target), each narrowed to one bookmark
14    pub segments: Vec<NarrowedBookmarkSegment>,
15}
16
17/// Analyze what needs to be submitted for a given bookmark
18///
19/// Works with single-stack semantics: the graph contains only one stack
20/// from trunk to working copy. If `target_bookmark` is None, submits the
21/// entire stack (leaf bookmark). If specified, submits up to that bookmark.
22pub 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    // Determine target index
36    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        // No target specified - use leaf (last segment)
44        stack.segments.len() - 1
45    };
46
47    // Get segments from trunk (index 0) to target (inclusive)
48    let relevant_segments = &stack.segments[0..=target_index];
49
50    // Narrow each segment to a single bookmark using heuristics
51    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    // Use the actual selected bookmark name for the target
64    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
75/// Select a single bookmark from a segment using heuristics
76///
77/// Selection priority:
78/// 1. If target is specified and present, use it
79/// 2. Exclude temporary bookmarks (wip, tmp, backup, -old)
80/// 3. Prefer shorter names (more likely to be "canonical")
81/// 4. Fall back to alphabetically first
82pub fn select_bookmark_for_segment(segment: &BookmarkSegment, target: Option<&str>) -> Bookmark {
83    let bookmarks = &segment.bookmarks;
84
85    // Single bookmark - no selection needed
86    if bookmarks.len() == 1 {
87        return bookmarks[0].clone();
88    }
89
90    // 1. Prefer target if specified and present
91    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    // 2. Filter out temporary bookmarks
98    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    // 3. Prefer shorter names, then alphabetically first
110    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
119/// Check if a bookmark name appears to be temporary
120fn 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
132/// Get the expected base branch for a bookmark in a submission
133///
134/// Returns the bookmark name that this bookmark should be based on,
135/// or the default branch name if it's the first in the stack.
136pub 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                // First segment is based on default branch
145                return Ok(default_branch.to_string());
146            }
147            // Otherwise, based on previous segment's bookmark
148            return Ok(segments[i - 1].bookmark.name.clone());
149        }
150    }
151
152    Err(Error::BookmarkNotFound(bookmark_name.to_string()))
153}
154
155/// Generate a PR title from the bookmark's commits
156///
157/// Uses the oldest (root) commit's description as the title, since that
158/// typically represents the primary intent of the change. Falls back to
159/// bookmark name if no description is available.
160pub 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    // Use the oldest (root) commit's description as the title
174    // changes[0] is newest, changes[last] is oldest/root
175    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
187/// Create narrowed segments from resolved bookmarks and analysis
188///
189/// This bridges CLI bookmark selection with submission planning.
190pub 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        // No target - should use leaf (feat-b)
303        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        // changes[0] is newest, changes[last] is oldest (root)
389        let segments = vec![NarrowedBookmarkSegment {
390            bookmark: make_bookmark("feat-a"),
391            changes: vec![
392                make_log_entry("Fix typo in feature", &["feat-a"]), // newest
393                make_log_entry("Add tests for feature", &[]),       // middle
394                make_log_entry("Implement cool feature", &[]),      // oldest (root)
395            ],
396        }];
397
398        let title = generate_pr_title("feat-a", &segments).unwrap();
399        // Should use the root commit's description, not the latest
400        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        // Same length names - should pick alphabetically first
486        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        // Different length names - should pick shorter even if not alphabetically first
498        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"); // shorter (4) beats alpha (5)
505    }
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        // Should still select something even if all are "temporary"
515        let selected = select_bookmark_for_segment(&segment, None);
516        assert_eq!(selected.name, "tmp-b"); // shorter, then alphabetical
517    }
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")); // contains "old" but not suffix
533    }
534}