use std::collections::HashMap;
use std::error::Error;
use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
pub const STATE_FILE_NAME: &str = "state.toml";
pub const COMMIT_HISTORY_LIMIT: usize = 10;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AppState {
#[serde(default)]
pub visits: HashMap<String, u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active_label_filter: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub label_slots: Vec<String>,
#[serde(default)]
pub commit_history: HashMap<String, Vec<String>>,
}
impl AppState {
pub fn path_for(config_path: &Path) -> PathBuf {
config_path.with_file_name(STATE_FILE_NAME)
}
pub fn is_empty(&self) -> bool {
self.visits.is_empty()
&& self.active_label_filter.is_none()
&& self.label_slots.is_empty()
&& self.commit_history.is_empty()
}
pub fn take_legacy(table: &mut toml::Table) -> Option<AppState> {
let mut found = false;
let mut state = AppState::default();
if let Some(value) = table.remove("visits") {
found = true;
state.visits = value.try_into().unwrap_or_default();
}
if let Some(value) = table.remove("active_label_filter") {
found = true;
state.active_label_filter = value.as_str().map(str::to_string);
}
if let Some(value) = table.remove("label_slots") {
found = true;
state.label_slots = value.try_into().unwrap_or_default();
}
if let Some(toml::Value::Table(repos)) = table.get_mut("repo_configs") {
for (repo, entry) in repos.iter_mut() {
let Some(entry) = entry.as_table_mut() else { continue };
if let Some(value) = entry.remove("commit_history") {
found = true;
let history: Vec<String> = value.try_into().unwrap_or_default();
if !history.is_empty() {
state.commit_history.insert(repo.clone(), history);
}
}
}
}
found.then_some(state)
}
pub fn absorb(&mut self, legacy: AppState) {
for (repo, time) in legacy.visits {
self.visits.entry(repo).or_insert(time);
}
if self.active_label_filter.is_none() {
self.active_label_filter = legacy.active_label_filter;
}
if self.label_slots.is_empty() {
self.label_slots = legacy.label_slots;
}
for (repo, history) in legacy.commit_history {
self.commit_history.entry(repo).or_insert(history);
}
}
pub fn record_commit_message(&mut self, repo: &str, msg: &str) {
let history = self.commit_history.entry(repo.to_string()).or_default();
history.retain(|x| x != msg);
history.insert(0, msg.to_string());
history.truncate(COMMIT_HISTORY_LIMIT);
}
pub fn commit_history_for(&self, repo: &str) -> Vec<String> {
self.commit_history.get(repo).cloned().unwrap_or_default()
}
}
pub fn load_state(path: &Path) -> (AppState, Option<String>) {
if !path.exists() {
return (AppState::default(), None);
}
let parsed = fs::read_to_string(path)
.map_err(|e| e.to_string())
.and_then(|contents| toml::from_str::<AppState>(&contents).map_err(|e| e.to_string()));
match parsed {
Ok(state) => (state, None),
Err(_) => {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let corrupt_path = path.with_extension(format!("toml.corrupt-{}", ts));
let msg = match fs::rename(path, &corrupt_path) {
Ok(_) => format!(
"State file corrupt! Moved to {} and reset",
corrupt_path.file_name().unwrap_or_default().to_string_lossy()
),
Err(e) => format!("State file corrupt! Reset (failed to rename: {})", e),
};
(AppState::default(), Some(msg))
}
}
}
pub fn save_state(state: &AppState, path: &Path) -> Result<(), Box<dyn Error>> {
let serialized = toml::to_string_pretty(state)?;
crate::config::write_toml_atomic(path, &serialized)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic)]
mod tests {
use super::*;
fn temp_state_path(tag: &str) -> PathBuf {
let nanos =
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!(
"gitwig_state_{}_{}_{}",
tag,
std::process::id(),
nanos
));
fs::create_dir_all(&dir).unwrap();
dir.join(STATE_FILE_NAME)
}
#[test]
fn path_for_is_a_sibling_of_the_config() {
let config = PathBuf::from("/home/me/.gitwig/config.toml");
assert_eq!(AppState::path_for(&config), PathBuf::from("/home/me/.gitwig/state.toml"));
}
#[test]
fn take_legacy_lifts_every_state_key_out_of_a_config_table() {
let mut table: toml::Table = toml::from_str(
r#"
items = ["/a", "/b"]
active_label_filter = "work"
label_slots = ["work", "oss"]
[visits]
"/a" = 10
"/b" = 20
[repo_configs."/a"]
page_size = 5
commit_history = ["second", "first"]
[repo_configs."/b"]
note = "keep me"
"#,
)
.unwrap();
let legacy = AppState::take_legacy(&mut table).expect("legacy keys present");
assert_eq!(legacy.visits.get("/a"), Some(&10));
assert_eq!(legacy.visits.get("/b"), Some(&20));
assert_eq!(legacy.active_label_filter.as_deref(), Some("work"));
assert_eq!(legacy.label_slots, vec!["work".to_string(), "oss".to_string()]);
assert_eq!(
legacy.commit_history.get("/a"),
Some(&vec!["second".to_string(), "first".to_string()])
);
assert!(!legacy.commit_history.contains_key("/b"));
assert!(table.get("visits").is_none());
assert!(table.get("active_label_filter").is_none());
assert!(table.get("label_slots").is_none());
let repos = table["repo_configs"].as_table().unwrap();
assert!(repos["/a"].get("commit_history").is_none());
assert_eq!(repos["/a"]["page_size"].as_integer(), Some(5));
assert_eq!(repos["/b"]["note"].as_str(), Some("keep me"));
assert_eq!(table["items"].as_array().unwrap().len(), 2);
}
#[test]
fn take_legacy_is_none_for_a_clean_config() {
let mut table: toml::Table = toml::from_str(
r#"
items = ["/a"]
[repo_configs."/a"]
page_size = 5
"#,
)
.unwrap();
assert!(AppState::take_legacy(&mut table).is_none());
assert!(table.get("repo_configs").is_some());
}
#[test]
fn absorb_never_overrides_what_the_state_file_holds() {
let mut state = AppState {
visits: HashMap::from([("/a".to_string(), 99)]),
active_label_filter: Some("oss".to_string()),
label_slots: vec!["oss".to_string()],
commit_history: HashMap::from([("/a".to_string(), vec!["new".to_string()])]),
};
let legacy = AppState {
visits: HashMap::from([("/a".to_string(), 1), ("/b".to_string(), 2)]),
active_label_filter: Some("work".to_string()),
label_slots: vec!["work".to_string()],
commit_history: HashMap::from([
("/a".to_string(), vec!["old".to_string()]),
("/b".to_string(), vec!["b".to_string()]),
]),
};
state.absorb(legacy);
assert_eq!(state.visits.get("/a"), Some(&99));
assert_eq!(state.visits.get("/b"), Some(&2));
assert_eq!(state.active_label_filter.as_deref(), Some("oss"));
assert_eq!(state.label_slots, vec!["oss".to_string()]);
assert_eq!(state.commit_history["/a"], vec!["new".to_string()]);
assert_eq!(state.commit_history["/b"], vec!["b".to_string()]);
}
#[test]
fn absorb_fills_gaps_from_legacy() {
let mut state = AppState::default();
let legacy = AppState {
visits: HashMap::from([("/a".to_string(), 1)]),
active_label_filter: Some("work".to_string()),
label_slots: vec!["work".to_string()],
commit_history: HashMap::new(),
};
state.absorb(legacy.clone());
assert_eq!(state, legacy);
}
#[test]
fn record_commit_message_dedups_and_caps_the_history() {
let mut state = AppState::default();
for i in 0..(COMMIT_HISTORY_LIMIT + 3) {
state.record_commit_message("/repo", &format!("msg {}", i));
}
let history = state.commit_history_for("/repo");
assert_eq!(history.len(), COMMIT_HISTORY_LIMIT);
assert_eq!(history[0], format!("msg {}", COMMIT_HISTORY_LIMIT + 2));
state.record_commit_message("/repo", "msg 5");
let history = state.commit_history_for("/repo");
assert_eq!(history[0], "msg 5");
assert_eq!(history.iter().filter(|m| *m == "msg 5").count(), 1);
assert_eq!(history.len(), COMMIT_HISTORY_LIMIT);
assert!(state.commit_history_for("/other").is_empty());
}
#[test]
fn save_and_load_round_trip() {
let path = temp_state_path("roundtrip");
let mut state = AppState::default();
state.visits.insert("/a".to_string(), 42);
state.active_label_filter = Some("work".to_string());
state.label_slots = vec!["work".to_string(), "oss".to_string()];
state.record_commit_message("/a", "feat: thing");
save_state(&state, &path).unwrap();
let (loaded, warning) = load_state(&path);
assert!(warning.is_none());
assert_eq!(loaded, state);
let _ = fs::remove_dir_all(path.parent().unwrap());
}
#[test]
fn missing_state_file_is_empty_without_a_warning() {
let path = temp_state_path("missing");
let (loaded, warning) = load_state(&path);
assert!(loaded.is_empty());
assert!(warning.is_none());
let _ = fs::remove_dir_all(path.parent().unwrap());
}
#[test]
fn corrupt_state_file_is_set_aside_and_reset() {
let path = temp_state_path("corrupt");
fs::write(&path, "this is = not [ toml").unwrap();
let (loaded, warning) = load_state(&path);
assert!(loaded.is_empty());
assert!(warning.unwrap().contains("corrupt"));
assert!(!path.exists());
let set_aside = fs::read_dir(path.parent().unwrap())
.unwrap()
.filter_map(Result::ok)
.any(|e| e.file_name().to_string_lossy().starts_with("state.toml.corrupt-"));
assert!(set_aside);
let _ = fs::remove_dir_all(path.parent().unwrap());
}
#[test]
fn empty_optional_keys_are_not_written() {
let path = temp_state_path("sparse");
let mut state = AppState::default();
state.visits.insert("/a".to_string(), 1);
save_state(&state, &path).unwrap();
let written = fs::read_to_string(&path).unwrap();
assert!(!written.contains("active_label_filter"));
assert!(!written.contains("label_slots"));
let _ = fs::remove_dir_all(path.parent().unwrap());
}
}