use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::config::VcsKind;
use crate::source::{RepoRef, ReviewKind};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct ReviewKey {
pub source: String,
pub id: String,
}
impl ReviewKey {
pub fn new(source: impl Into<String>, id: impl Into<String>) -> Self {
Self {
source: source.into(),
id: id.into(),
}
}
pub fn slug(&self) -> String {
format!("{}/{}", self.source, self.id)
}
}
impl std::fmt::Display for ReviewKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.slug())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Status {
Ready,
ApplyFailed,
Dirty,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Workspace {
pub repo_path: PathBuf,
pub vcs: VcsKind,
pub workspace_path: PathBuf,
pub head_id: String,
pub status: Status,
pub tip: ReviewKey,
pub version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReviewEntry {
pub key: ReviewKey,
pub title: String,
pub author: String,
pub url: String,
pub repo: RepoRef,
pub kind: ReviewKind,
pub version: String,
pub in_queue: bool,
pub resolved: bool,
pub last_synced: chrono::DateTime<chrono::Utc>,
#[serde(default)]
pub stack_id: Option<String>,
#[serde(default)]
pub ancestors: Vec<ReviewKey>,
pub diff_stat: Option<String>,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct State {
reviews: BTreeMap<String, ReviewEntry>,
#[serde(default)]
workspaces: BTreeMap<String, Workspace>,
}
impl State {
pub fn load(path: &Path) -> Result<Self> {
if !path.exists() {
return Ok(Self::default());
}
let text = std::fs::read_to_string(path)
.with_context(|| format!("reading state {}", path.display()))?;
if text.trim().is_empty() {
return Ok(Self::default());
}
let mut value: serde_json::Value = serde_json::from_str(&text)
.with_context(|| format!("parsing state {}", path.display()))?;
migrate_per_review_workspaces(&mut value);
serde_json::from_value(value).with_context(|| format!("parsing state {}", path.display()))
}
pub fn save(&self, path: &Path) -> Result<()> {
let dir = path
.parent()
.context("state path has no parent directory")?;
std::fs::create_dir_all(dir)?;
let tmp = dir.join(format!(
".{}.tmp",
path.file_name().unwrap().to_string_lossy()
));
let text = serde_json::to_string_pretty(self)?;
std::fs::write(&tmp, text).with_context(|| format!("writing {}", tmp.display()))?;
std::fs::rename(&tmp, path)
.with_context(|| format!("renaming {} to {}", tmp.display(), path.display()))?;
Ok(())
}
pub fn get(&self, key: &ReviewKey) -> Option<&ReviewEntry> {
self.reviews.get(&key.slug())
}
pub fn insert(&mut self, entry: ReviewEntry) {
self.reviews.insert(entry.key.slug(), entry);
}
pub fn remove(&mut self, key: &ReviewKey) -> Option<ReviewEntry> {
self.reviews.remove(&key.slug())
}
pub fn iter(&self) -> impl Iterator<Item = &ReviewEntry> {
self.reviews.values()
}
pub fn workspace(&self, stack_id: &str) -> Option<&Workspace> {
self.workspaces.get(stack_id)
}
pub fn workspace_of(&self, entry: &ReviewEntry) -> Option<&Workspace> {
entry.stack_id.as_deref().and_then(|id| self.workspace(id))
}
pub fn workspace_for(&self, key: &ReviewKey) -> Option<&Workspace> {
self.get(key).and_then(|e| self.workspace_of(e))
}
pub fn workspaces(&self) -> impl Iterator<Item = (&String, &Workspace)> {
self.workspaces.iter()
}
pub fn insert_workspace(&mut self, stack_id: String, ws: Workspace) {
self.workspaces.insert(stack_id, ws);
}
pub fn remove_workspace(&mut self, stack_id: &str) -> Option<Workspace> {
self.workspaces.remove(stack_id)
}
pub fn members_of(&self, stack_id: &str) -> Vec<ReviewKey> {
self.reviews
.values()
.filter(|e| e.stack_id.as_deref() == Some(stack_id))
.map(|e| e.key.clone())
.collect()
}
pub fn for_each_entry_mut(&mut self, mut f: impl FnMut(&mut ReviewEntry)) {
self.reviews.values_mut().for_each(&mut f);
}
pub fn find_by_prefix<'a>(&'a self, prefix: &str) -> Vec<&'a ReviewEntry> {
self.reviews
.values()
.filter(|e| {
let id = &e.key.id;
id.starts_with(prefix)
|| id
.rsplit('/')
.next()
.is_some_and(|last| last.starts_with(prefix))
|| e.key.slug() == prefix
})
.collect()
}
}
fn migrate_per_review_workspaces(value: &mut serde_json::Value) {
use serde_json::Value;
let Some(reviews) = value.get_mut("reviews").and_then(Value::as_object_mut) else {
return;
};
let mut migrated = serde_json::Map::new();
for (slug, entry) in reviews.iter_mut() {
let Some(entry) = entry.as_object_mut() else {
continue;
};
let Some(Value::Object(mut ws)) = entry.remove("workspace") else {
continue;
};
ws.insert(
"tip".into(),
entry.get("key").cloned().unwrap_or(Value::Null),
);
ws.insert(
"version".into(),
entry.get("version").cloned().unwrap_or(Value::Null),
);
entry.insert("stack_id".into(), Value::String(slug.clone()));
migrated.insert(slug.clone(), Value::Object(ws));
}
if migrated.is_empty() {
return;
}
if let Some(root) = value.as_object_mut() {
let existing = root
.entry("workspaces")
.or_insert_with(|| Value::Object(Default::default()));
if let Some(existing) = existing.as_object_mut() {
existing.extend(migrated);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn sample_entry(id: &str) -> ReviewEntry {
sample_entry_for("moz", id)
}
fn sample_entry_for(source: &str, id: &str) -> ReviewEntry {
ReviewEntry {
key: ReviewKey::new(source, id),
title: "Fix the thing".into(),
author: "someone".into(),
url: format!("https://phabricator.services.mozilla.com/{id}"),
repo: RepoRef {
urls: vec!["https://example.com/o/r".into()],
display_name: "o/r".into(),
},
kind: ReviewKind::Direct,
version: "1".into(),
in_queue: true,
resolved: false,
last_synced: chrono::Utc::now(),
stack_id: None,
ancestors: Vec::new(),
diff_stat: None,
description: None,
}
}
#[test]
fn round_trips_through_disk() {
let dir = tempdir().unwrap();
let path = dir.path().join("state.json");
let mut state = State::default();
state.insert(sample_entry("D1"));
state.save(&path).unwrap();
let loaded = State::load(&path).unwrap();
assert_eq!(
loaded.get(&ReviewKey::new("moz", "D1")).unwrap().title,
"Fix the thing"
);
}
#[test]
fn migrates_per_review_workspaces_into_the_shared_map() {
let dir = tempdir().unwrap();
let path = dir.path().join("state.json");
let mut entry = serde_json::to_value(sample_entry("D1")).unwrap();
entry.as_object_mut().unwrap().remove("stack_id");
entry.as_object_mut().unwrap().remove("ancestors");
entry.as_object_mut().unwrap().insert(
"workspace".into(),
serde_json::json!({
"repo_path": "/tmp/repo",
"vcs": "git",
"workspace_path": "/tmp/ws/D1",
"head_id": "abc123",
"status": "ready",
}),
);
let old = serde_json::json!({"reviews": {"moz/D1": entry}});
std::fs::write(&path, old.to_string()).unwrap();
let state = State::load(&path).unwrap();
let ws = state.workspace_for(&ReviewKey::new("moz", "D1")).unwrap();
assert_eq!(ws.head_id, "abc123");
assert_eq!(ws.tip, ReviewKey::new("moz", "D1"));
assert_eq!(ws.version, "1");
}
#[test]
fn missing_file_is_empty_state() {
let state = State::load(Path::new("/nonexistent/state.json")).unwrap();
assert_eq!(state.iter().count(), 0);
}
#[test]
fn find_by_prefix_matches_id_or_full_slug() {
let mut state = State::default();
state.insert(sample_entry("D12345"));
state.insert(sample_entry("D999"));
assert_eq!(state.find_by_prefix("D123").len(), 1);
assert_eq!(state.find_by_prefix("moz/D999").len(), 1);
assert_eq!(state.find_by_prefix("D").len(), 2);
assert_eq!(state.find_by_prefix("nope").len(), 0);
}
#[test]
fn find_by_prefix_matches_the_trailing_segment_of_a_multi_part_id() {
let mut state = State::default();
state.insert(sample_entry_for("gh", "mozilla/gecko-dev/123"));
state.insert(sample_entry_for("gh", "mozilla/other-repo/456"));
let by_number = state.find_by_prefix("123");
assert_eq!(by_number.len(), 1);
assert_eq!(by_number[0].key.id, "mozilla/gecko-dev/123");
assert_eq!(state.find_by_prefix("12").len(), 1);
assert_eq!(state.find_by_prefix("23").len(), 0);
assert_eq!(state.find_by_prefix("mozilla/gecko-dev").len(), 1);
}
}