Skip to main content

jj_ryu/tracking/
storage.rs

1//! Persistence for tracking state in `.jj/repo/ryu/`.
2
3use super::{TRACKING_VERSION, TrackingState};
4use crate::error::{Error, Result};
5use std::fs;
6use std::path::{Path, PathBuf};
7
8/// Directory name for ryu metadata within `.jj/repo/`.
9const RYU_DIR: &str = "ryu";
10
11/// Filename for tracking state.
12const TRACKING_FILE: &str = "tracked.toml";
13
14/// Resolve the `.jj/repo` path, handling jj workspace indirection.
15///
16/// In jj workspaces (created via `jj workspace add`), the `.jj/repo` path
17/// in child workspaces is a plain text file containing the absolute path
18/// to the parent workspace's `.jj/repo` directory. We must read this file
19/// and use its contents as the actual repo path.
20///
21/// Falls back to the original path if resolution fails.
22pub(super) fn resolve_repo_path(workspace_root: &Path) -> PathBuf {
23    let repo_path = workspace_root.join(".jj").join("repo");
24
25    // In jj workspaces, .jj/repo may be a file containing the path to the real repo
26    if repo_path.is_file() {
27        if let Ok(contents) = fs::read_to_string(&repo_path) {
28            let target = PathBuf::from(contents.trim());
29            if target.is_dir() {
30                return fs::canonicalize(&target).unwrap_or(target);
31            }
32        }
33        // Pointer file exists but is invalid/unreadable - return as-is to surface error
34        return repo_path;
35    }
36
37    repo_path
38}
39
40/// Get path to the ryu metadata directory.
41fn ryu_dir(workspace_root: &Path) -> PathBuf {
42    resolve_repo_path(workspace_root).join(RYU_DIR)
43}
44
45/// Get path to the tracking state file.
46pub fn tracking_path(workspace_root: &Path) -> PathBuf {
47    ryu_dir(workspace_root).join(TRACKING_FILE)
48}
49
50/// Load tracking state from disk.
51///
52/// Returns an empty `TrackingState` if the file doesn't exist.
53pub fn load_tracking(workspace_root: &Path) -> Result<TrackingState> {
54    let path = tracking_path(workspace_root);
55
56    if !path.exists() {
57        return Ok(TrackingState::new());
58    }
59
60    let content = fs::read_to_string(&path)
61        .map_err(|e| Error::Tracking(format!("failed to read {}: {e}", path.display())))?;
62
63    let state: TrackingState = toml::from_str(&content)
64        .map_err(|e| Error::Tracking(format!("failed to parse {}: {e}", path.display())))?;
65
66    Ok(state)
67}
68
69/// Save tracking state to disk.
70///
71/// Creates the `.jj/repo/ryu/` directory if it doesn't exist.
72pub fn save_tracking(workspace_root: &Path, state: &TrackingState) -> Result<()> {
73    let dir = ryu_dir(workspace_root);
74    let path = dir.join(TRACKING_FILE);
75
76    // Ensure directory exists
77    if !dir.exists() {
78        fs::create_dir_all(&dir)
79            .map_err(|e| Error::Tracking(format!("failed to create {}: {e}", dir.display())))?;
80    }
81
82    // Serialize with version
83    let mut state_to_save = state.clone();
84    state_to_save.version = TRACKING_VERSION;
85
86    let content = toml::to_string_pretty(&state_to_save)
87        .map_err(|e| Error::Tracking(format!("failed to serialize tracking state: {e}")))?;
88
89    // Add header comment
90    let content_with_header = format!(
91        "# ryu tracking metadata\n# Auto-generated - manual edits may be overwritten\n\n{content}"
92    );
93
94    fs::write(&path, content_with_header)
95        .map_err(|e| Error::Tracking(format!("failed to write {}: {e}", path.display())))?;
96
97    Ok(())
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103    use crate::tracking::TrackedBookmark;
104    use tempfile::TempDir;
105
106    fn setup_fake_jj_workspace() -> TempDir {
107        let temp = TempDir::new().unwrap();
108        // Create .jj/repo directory structure
109        fs::create_dir_all(temp.path().join(".jj").join("repo")).unwrap();
110        temp
111    }
112
113    #[test]
114    fn test_tracking_path() {
115        let temp = setup_fake_jj_workspace();
116        let path = tracking_path(temp.path());
117        assert!(path.ends_with(".jj/repo/ryu/tracked.toml"));
118    }
119
120    #[test]
121    fn test_load_missing_file_returns_empty() {
122        let temp = setup_fake_jj_workspace();
123        let state = load_tracking(temp.path()).unwrap();
124        assert!(state.bookmarks.is_empty());
125        assert_eq!(state.version, TRACKING_VERSION);
126    }
127
128    #[test]
129    fn test_save_creates_directory() {
130        let temp = setup_fake_jj_workspace();
131        let ryu_dir = temp.path().join(".jj").join("repo").join("ryu");
132        assert!(!ryu_dir.exists());
133
134        let state = TrackingState::new();
135        save_tracking(temp.path(), &state).unwrap();
136
137        assert!(ryu_dir.exists());
138        assert!(tracking_path(temp.path()).exists());
139    }
140
141    #[test]
142    fn test_roundtrip_serialization() {
143        let temp = setup_fake_jj_workspace();
144
145        let mut state = TrackingState::new();
146        state.track(TrackedBookmark::new(
147            "feat-auth".to_string(),
148            "abc123".to_string(),
149        ));
150        state.track(TrackedBookmark::with_remote(
151            "feat-db".to_string(),
152            "def456".to_string(),
153            "upstream".to_string(),
154        ));
155
156        save_tracking(temp.path(), &state).unwrap();
157
158        let loaded = load_tracking(temp.path()).unwrap();
159        assert_eq!(loaded.bookmarks.len(), 2);
160        assert_eq!(loaded.bookmarks[0].name, "feat-auth");
161        assert_eq!(loaded.bookmarks[0].change_id, "abc123");
162        assert!(loaded.bookmarks[0].remote.is_none());
163        assert_eq!(loaded.bookmarks[1].name, "feat-db");
164        assert_eq!(loaded.bookmarks[1].remote, Some("upstream".to_string()));
165    }
166
167    #[test]
168    fn test_file_contains_header_comment() {
169        let temp = setup_fake_jj_workspace();
170        let state = TrackingState::new();
171        save_tracking(temp.path(), &state).unwrap();
172
173        let content = fs::read_to_string(tracking_path(temp.path())).unwrap();
174        assert!(content.starts_with("# ryu tracking metadata"));
175        assert!(content.contains("Auto-generated"));
176    }
177
178    #[test]
179    fn test_resolve_repo_path_regular_directory() {
180        let temp = setup_fake_jj_workspace();
181        let resolved = resolve_repo_path(temp.path());
182
183        assert!(resolved.ends_with(".jj/repo"));
184        assert!(resolved.exists());
185    }
186
187    #[test]
188    fn test_resolve_repo_path_nonexistent_fallback() {
189        let temp = TempDir::new().unwrap();
190        // Don't create .jj/repo - it doesn't exist
191        let resolved = resolve_repo_path(temp.path());
192
193        // Should return the original path as fallback
194        assert!(resolved.ends_with(".jj/repo"));
195        assert!(!resolved.exists());
196    }
197
198    #[test]
199    fn test_resolve_repo_path_pointer_file() {
200        // Simulate jj workspace pointer file structure:
201        //   parent/.jj/repo/  (real directory)
202        //   child/.jj/repo   (file containing path to parent's repo)
203        let temp = TempDir::new().unwrap();
204        let parent = temp.path().join("parent");
205        let child = temp.path().join("child");
206
207        // Create parent workspace with real .jj/repo
208        let parent_repo = parent.join(".jj").join("repo");
209        fs::create_dir_all(&parent_repo).unwrap();
210
211        // Create child workspace with pointer file
212        let child_jj = child.join(".jj");
213        fs::create_dir_all(&child_jj).unwrap();
214        fs::write(
215            child_jj.join("repo"),
216            parent_repo.to_string_lossy().as_ref(),
217        )
218        .unwrap();
219
220        // resolve_repo_path should read the pointer file and canonicalize
221        let resolved = resolve_repo_path(&child);
222
223        // The resolved path should be the canonicalized parent's repo
224        let canonical_parent = fs::canonicalize(&parent_repo).unwrap();
225        assert_eq!(resolved, canonical_parent);
226    }
227}