Skip to main content

jj_ryu/tracking/
mod.rs

1//! Bookmark tracking for explicit submission management.
2//!
3//! This module provides persistence for tracking which bookmarks should be
4//! submitted to the remote platform. It stores metadata in `.jj/repo/ryu/`.
5
6mod pr_cache;
7mod storage;
8
9pub use pr_cache::{
10    CachedPr, PR_CACHE_VERSION, PrCache, load_pr_cache, pr_cache_path, save_pr_cache,
11};
12pub use storage::{load_tracking, save_tracking, tracking_path};
13
14use chrono::{DateTime, Utc};
15use serde::{Deserialize, Serialize};
16
17/// Current version of the tracking file format.
18pub const TRACKING_VERSION: u32 = 1;
19
20/// A bookmark that has been explicitly tracked for submission.
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
22pub struct TrackedBookmark {
23    /// Bookmark name (e.g., "feat-auth").
24    pub name: String,
25    /// jj change ID for rename detection.
26    pub change_id: String,
27    /// Optional remote to submit to (defaults to auto-detect).
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub remote: Option<String>,
30    /// When this bookmark was tracked.
31    pub tracked_at: DateTime<Utc>,
32}
33
34impl TrackedBookmark {
35    /// Create a new tracked bookmark.
36    pub fn new(name: String, change_id: String) -> Self {
37        Self {
38            name,
39            change_id,
40            remote: None,
41            tracked_at: Utc::now(),
42        }
43    }
44
45    /// Create a new tracked bookmark with a specific remote.
46    pub fn with_remote(name: String, change_id: String, remote: String) -> Self {
47        Self {
48            name,
49            change_id,
50            remote: Some(remote),
51            tracked_at: Utc::now(),
52        }
53    }
54}
55
56/// Persistent state of tracked bookmarks.
57#[derive(Debug, Clone, Serialize, Deserialize, Default)]
58pub struct TrackingState {
59    /// File format version.
60    pub version: u32,
61    /// List of tracked bookmarks.
62    #[serde(default)]
63    pub bookmarks: Vec<TrackedBookmark>,
64}
65
66impl TrackingState {
67    /// Create a new empty tracking state.
68    pub const fn new() -> Self {
69        Self {
70            version: TRACKING_VERSION,
71            bookmarks: Vec::new(),
72        }
73    }
74
75    /// Check if a bookmark is tracked.
76    pub fn is_tracked(&self, name: &str) -> bool {
77        self.bookmarks.iter().any(|b| b.name == name)
78    }
79
80    /// Get a tracked bookmark by name.
81    pub fn get(&self, name: &str) -> Option<&TrackedBookmark> {
82        self.bookmarks.iter().find(|b| b.name == name)
83    }
84
85    /// Add a bookmark to tracking (no-op if already tracked).
86    pub fn track(&mut self, bookmark: TrackedBookmark) {
87        if !self.is_tracked(&bookmark.name) {
88            self.bookmarks.push(bookmark);
89        }
90    }
91
92    /// Remove a bookmark from tracking. Returns true if it was removed.
93    pub fn untrack(&mut self, name: &str) -> bool {
94        let len_before = self.bookmarks.len();
95        self.bookmarks.retain(|b| b.name != name);
96        self.bookmarks.len() < len_before
97    }
98
99    /// Get all tracked bookmark names.
100    pub fn tracked_names(&self) -> Vec<&str> {
101        self.bookmarks.iter().map(|b| b.name.as_str()).collect()
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[test]
110    fn test_tracked_bookmark_new() {
111        let bookmark = TrackedBookmark::new("feat-auth".to_string(), "abc123".to_string());
112        assert_eq!(bookmark.name, "feat-auth");
113        assert_eq!(bookmark.change_id, "abc123");
114        assert!(bookmark.remote.is_none());
115    }
116
117    #[test]
118    fn test_tracked_bookmark_with_remote() {
119        let bookmark = TrackedBookmark::with_remote(
120            "feat-auth".to_string(),
121            "abc123".to_string(),
122            "upstream".to_string(),
123        );
124        assert_eq!(bookmark.remote, Some("upstream".to_string()));
125    }
126
127    #[test]
128    fn test_tracking_state_track_untrack() {
129        let mut state = TrackingState::new();
130        assert!(!state.is_tracked("feat-auth"));
131
132        state.track(TrackedBookmark::new(
133            "feat-auth".to_string(),
134            "abc123".to_string(),
135        ));
136        assert!(state.is_tracked("feat-auth"));
137        assert_eq!(state.tracked_names(), vec!["feat-auth"]);
138
139        // Duplicate track is no-op
140        state.track(TrackedBookmark::new(
141            "feat-auth".to_string(),
142            "def456".to_string(),
143        ));
144        assert_eq!(state.bookmarks.len(), 1);
145
146        assert!(state.untrack("feat-auth"));
147        assert!(!state.is_tracked("feat-auth"));
148        assert!(!state.untrack("feat-auth")); // Already removed
149    }
150
151    #[test]
152    fn test_tracking_state_serialization() {
153        let mut state = TrackingState::new();
154        state.track(TrackedBookmark::new(
155            "feat-auth".to_string(),
156            "abc123".to_string(),
157        ));
158
159        let toml_str = toml::to_string_pretty(&state).unwrap();
160        assert!(toml_str.contains("feat-auth"));
161        assert!(toml_str.contains("abc123"));
162
163        let deserialized: TrackingState = toml::from_str(&toml_str).unwrap();
164        assert_eq!(deserialized.bookmarks.len(), 1);
165        assert_eq!(deserialized.bookmarks[0].name, "feat-auth");
166    }
167}