1use std::collections::BTreeMap;
5use std::path::{Path, PathBuf};
6
7use anyhow::{Context, Result};
8use serde::{Deserialize, Serialize};
9
10use crate::config::VcsKind;
11use crate::source::{RepoRef, ReviewKind};
12
13#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
19pub struct ReviewKey {
20 pub source: String,
21 pub id: String,
22}
23
24impl ReviewKey {
25 pub fn new(source: impl Into<String>, id: impl Into<String>) -> Self {
26 Self {
27 source: source.into(),
28 id: id.into(),
29 }
30 }
31
32 pub fn slug(&self) -> String {
34 format!("{}/{}", self.source, self.id)
35 }
36}
37
38impl std::fmt::Display for ReviewKey {
39 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 write!(f, "{}", self.slug())
41 }
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "lowercase")]
46pub enum Status {
47 Ready,
49 ApplyFailed,
51 Dirty,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct Workspace {
61 pub repo_path: PathBuf,
64 pub vcs: VcsKind,
65 pub workspace_path: PathBuf,
66 pub head_id: String,
69 pub status: Status,
70 pub tip: ReviewKey,
73 pub version: String,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct ReviewEntry {
79 pub key: ReviewKey,
80 pub title: String,
81 pub author: String,
82 pub url: String,
83 pub repo: RepoRef,
86 pub kind: ReviewKind,
87 pub version: String,
90 pub in_queue: bool,
93 pub resolved: bool,
97 pub last_synced: chrono::DateTime<chrono::Utc>,
98 #[serde(default)]
101 pub stack_id: Option<String>,
102 #[serde(default)]
106 pub ancestors: Vec<ReviewKey>,
107 pub diff_stat: Option<String>,
111 #[serde(default)]
114 pub description: Option<String>,
115}
116
117#[derive(Debug, Clone, Default, Serialize, Deserialize)]
118pub struct State {
119 reviews: BTreeMap<String, ReviewEntry>,
120 #[serde(default)]
121 workspaces: BTreeMap<String, Workspace>,
122}
123
124impl State {
125 pub fn load(path: &Path) -> Result<Self> {
126 if !path.exists() {
127 return Ok(Self::default());
128 }
129 let text = std::fs::read_to_string(path)
130 .with_context(|| format!("reading state {}", path.display()))?;
131 if text.trim().is_empty() {
132 return Ok(Self::default());
133 }
134 let mut value: serde_json::Value = serde_json::from_str(&text)
135 .with_context(|| format!("parsing state {}", path.display()))?;
136 migrate_per_review_workspaces(&mut value);
137 serde_json::from_value(value).with_context(|| format!("parsing state {}", path.display()))
138 }
139
140 pub fn save(&self, path: &Path) -> Result<()> {
142 let dir = path
143 .parent()
144 .context("state path has no parent directory")?;
145 std::fs::create_dir_all(dir)?;
146 let tmp = dir.join(format!(
147 ".{}.tmp",
148 path.file_name().unwrap().to_string_lossy()
149 ));
150 let text = serde_json::to_string_pretty(self)?;
151 std::fs::write(&tmp, text).with_context(|| format!("writing {}", tmp.display()))?;
152 std::fs::rename(&tmp, path)
153 .with_context(|| format!("renaming {} to {}", tmp.display(), path.display()))?;
154 Ok(())
155 }
156
157 pub fn get(&self, key: &ReviewKey) -> Option<&ReviewEntry> {
158 self.reviews.get(&key.slug())
159 }
160
161 pub fn insert(&mut self, entry: ReviewEntry) {
162 self.reviews.insert(entry.key.slug(), entry);
163 }
164
165 pub fn remove(&mut self, key: &ReviewKey) -> Option<ReviewEntry> {
166 self.reviews.remove(&key.slug())
167 }
168
169 pub fn iter(&self) -> impl Iterator<Item = &ReviewEntry> {
170 self.reviews.values()
171 }
172
173 pub fn workspace(&self, stack_id: &str) -> Option<&Workspace> {
174 self.workspaces.get(stack_id)
175 }
176
177 pub fn workspace_of(&self, entry: &ReviewEntry) -> Option<&Workspace> {
179 entry.stack_id.as_deref().and_then(|id| self.workspace(id))
180 }
181
182 pub fn workspace_for(&self, key: &ReviewKey) -> Option<&Workspace> {
183 self.get(key).and_then(|e| self.workspace_of(e))
184 }
185
186 pub fn workspaces(&self) -> impl Iterator<Item = (&String, &Workspace)> {
187 self.workspaces.iter()
188 }
189
190 pub fn insert_workspace(&mut self, stack_id: String, ws: Workspace) {
191 self.workspaces.insert(stack_id, ws);
192 }
193
194 pub fn remove_workspace(&mut self, stack_id: &str) -> Option<Workspace> {
195 self.workspaces.remove(stack_id)
196 }
197
198 pub fn members_of(&self, stack_id: &str) -> Vec<ReviewKey> {
200 self.reviews
201 .values()
202 .filter(|e| e.stack_id.as_deref() == Some(stack_id))
203 .map(|e| e.key.clone())
204 .collect()
205 }
206
207 pub fn for_each_entry_mut(&mut self, mut f: impl FnMut(&mut ReviewEntry)) {
209 self.reviews.values_mut().for_each(&mut f);
210 }
211
212 pub fn find_by_prefix<'a>(&'a self, prefix: &str) -> Vec<&'a ReviewEntry> {
217 self.reviews
218 .values()
219 .filter(|e| {
220 let id = &e.key.id;
221 id.starts_with(prefix)
222 || id
223 .rsplit('/')
224 .next()
225 .is_some_and(|last| last.starts_with(prefix))
226 || e.key.slug() == prefix
227 })
228 .collect()
229 }
230}
231
232fn migrate_per_review_workspaces(value: &mut serde_json::Value) {
235 use serde_json::Value;
236
237 let Some(reviews) = value.get_mut("reviews").and_then(Value::as_object_mut) else {
238 return;
239 };
240 let mut migrated = serde_json::Map::new();
241 for (slug, entry) in reviews.iter_mut() {
242 let Some(entry) = entry.as_object_mut() else {
243 continue;
244 };
245 let Some(Value::Object(mut ws)) = entry.remove("workspace") else {
246 continue;
247 };
248 ws.insert(
249 "tip".into(),
250 entry.get("key").cloned().unwrap_or(Value::Null),
251 );
252 ws.insert(
253 "version".into(),
254 entry.get("version").cloned().unwrap_or(Value::Null),
255 );
256 entry.insert("stack_id".into(), Value::String(slug.clone()));
257 migrated.insert(slug.clone(), Value::Object(ws));
258 }
259 if migrated.is_empty() {
260 return;
261 }
262 if let Some(root) = value.as_object_mut() {
263 let existing = root
264 .entry("workspaces")
265 .or_insert_with(|| Value::Object(Default::default()));
266 if let Some(existing) = existing.as_object_mut() {
267 existing.extend(migrated);
268 }
269 }
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275 use tempfile::tempdir;
276
277 fn sample_entry(id: &str) -> ReviewEntry {
278 sample_entry_for("moz", id)
279 }
280
281 fn sample_entry_for(source: &str, id: &str) -> ReviewEntry {
282 ReviewEntry {
283 key: ReviewKey::new(source, id),
284 title: "Fix the thing".into(),
285 author: "someone".into(),
286 url: format!("https://phabricator.services.mozilla.com/{id}"),
287 repo: RepoRef {
288 urls: vec!["https://example.com/o/r".into()],
289 display_name: "o/r".into(),
290 },
291 kind: ReviewKind::Direct,
292 version: "1".into(),
293 in_queue: true,
294 resolved: false,
295 last_synced: chrono::Utc::now(),
296 stack_id: None,
297 ancestors: Vec::new(),
298 diff_stat: None,
299 description: None,
300 }
301 }
302
303 #[test]
304 fn round_trips_through_disk() {
305 let dir = tempdir().unwrap();
306 let path = dir.path().join("state.json");
307
308 let mut state = State::default();
309 state.insert(sample_entry("D1"));
310 state.save(&path).unwrap();
311
312 let loaded = State::load(&path).unwrap();
313 assert_eq!(
314 loaded.get(&ReviewKey::new("moz", "D1")).unwrap().title,
315 "Fix the thing"
316 );
317 }
318
319 #[test]
320 fn migrates_per_review_workspaces_into_the_shared_map() {
321 let dir = tempdir().unwrap();
322 let path = dir.path().join("state.json");
323 let mut entry = serde_json::to_value(sample_entry("D1")).unwrap();
324 entry.as_object_mut().unwrap().remove("stack_id");
325 entry.as_object_mut().unwrap().remove("ancestors");
326 entry.as_object_mut().unwrap().insert(
327 "workspace".into(),
328 serde_json::json!({
329 "repo_path": "/tmp/repo",
330 "vcs": "git",
331 "workspace_path": "/tmp/ws/D1",
332 "head_id": "abc123",
333 "status": "ready",
334 }),
335 );
336 let old = serde_json::json!({"reviews": {"moz/D1": entry}});
337 std::fs::write(&path, old.to_string()).unwrap();
338
339 let state = State::load(&path).unwrap();
340 let ws = state.workspace_for(&ReviewKey::new("moz", "D1")).unwrap();
341 assert_eq!(ws.head_id, "abc123");
342 assert_eq!(ws.tip, ReviewKey::new("moz", "D1"));
343 assert_eq!(ws.version, "1");
344 }
345
346 #[test]
347 fn missing_file_is_empty_state() {
348 let state = State::load(Path::new("/nonexistent/state.json")).unwrap();
349 assert_eq!(state.iter().count(), 0);
350 }
351
352 #[test]
353 fn find_by_prefix_matches_id_or_full_slug() {
354 let mut state = State::default();
355 state.insert(sample_entry("D12345"));
356 state.insert(sample_entry("D999"));
357
358 assert_eq!(state.find_by_prefix("D123").len(), 1);
359 assert_eq!(state.find_by_prefix("moz/D999").len(), 1);
360 assert_eq!(state.find_by_prefix("D").len(), 2);
361 assert_eq!(state.find_by_prefix("nope").len(), 0);
362 }
363
364 #[test]
365 fn find_by_prefix_matches_the_trailing_segment_of_a_multi_part_id() {
366 let mut state = State::default();
370 state.insert(sample_entry_for("gh", "mozilla/gecko-dev/123"));
371 state.insert(sample_entry_for("gh", "mozilla/other-repo/456"));
372
373 let by_number = state.find_by_prefix("123");
374 assert_eq!(by_number.len(), 1);
375 assert_eq!(by_number[0].key.id, "mozilla/gecko-dev/123");
376
377 assert_eq!(state.find_by_prefix("12").len(), 1);
379
380 assert_eq!(state.find_by_prefix("23").len(), 0);
383
384 assert_eq!(state.find_by_prefix("mozilla/gecko-dev").len(), 1);
386 }
387}