jj_ryu/tracking/
storage.rs1use super::{TRACKING_VERSION, TrackingState};
4use crate::error::{Error, Result};
5use std::fs;
6use std::path::{Path, PathBuf};
7
8const RYU_DIR: &str = "ryu";
10
11const TRACKING_FILE: &str = "tracked.toml";
13
14pub(super) fn resolve_repo_path(workspace_root: &Path) -> PathBuf {
23 let repo_path = workspace_root.join(".jj").join("repo");
24
25 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 return repo_path;
35 }
36
37 repo_path
38}
39
40fn ryu_dir(workspace_root: &Path) -> PathBuf {
42 resolve_repo_path(workspace_root).join(RYU_DIR)
43}
44
45pub fn tracking_path(workspace_root: &Path) -> PathBuf {
47 ryu_dir(workspace_root).join(TRACKING_FILE)
48}
49
50pub 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
69pub 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 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 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 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 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 let resolved = resolve_repo_path(temp.path());
192
193 assert!(resolved.ends_with(".jj/repo"));
195 assert!(!resolved.exists());
196 }
197
198 #[test]
199 fn test_resolve_repo_path_pointer_file() {
200 let temp = TempDir::new().unwrap();
204 let parent = temp.path().join("parent");
205 let child = temp.path().join("child");
206
207 let parent_repo = parent.join(".jj").join("repo");
209 fs::create_dir_all(&parent_repo).unwrap();
210
211 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 let resolved = resolve_repo_path(&child);
222
223 let canonical_parent = fs::canonicalize(&parent_repo).unwrap();
225 assert_eq!(resolved, canonical_parent);
226 }
227}