use std::{collections::BTreeMap, fs, path::Path};
use color_eyre::eyre::{Result, WrapErr};
use serde::{Deserialize, Serialize};
use crate::sort::RowOrder;
const STATE_FILE: &str = "state.toml";
const ACTIVE_SET_KEY: &str = "active_set";
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct ScopeState {
#[serde(default)]
pub(crate) selection: Vec<String>,
#[serde(default)]
pub(crate) filter: String,
#[serde(default)]
pub(crate) sort: Option<RowOrder>,
#[serde(default)]
pub(crate) show_worktrees: Option<bool>,
#[serde(default)]
pub(crate) show_ignored: Option<bool>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct StateFile {
#[serde(default, skip_serializing_if = "Option::is_none")]
active_set: Option<String>,
#[serde(flatten)]
scopes: BTreeMap<String, ScopeState>,
}
impl StateFile {
pub(crate) fn scope(&self, key: &str) -> ScopeState {
self.scopes.get(key).cloned().unwrap_or_default()
}
pub(crate) fn active_set(&self) -> Option<&str> {
self.active_set.as_deref()
}
pub(crate) fn set_active_set(&mut self, name: String) {
self.active_set = Some(name);
}
pub(crate) fn set_scope(&mut self, key: String, state: ScopeState) {
if key == ACTIVE_SET_KEY {
return;
}
self.scopes.insert(key, state);
}
}
pub(crate) fn load(data_dir: &Path) -> StateFile {
let Ok(text) = fs::read_to_string(data_dir.join(STATE_FILE)) else {
return StateFile::default();
};
toml::from_str(&text).unwrap_or_default()
}
pub(crate) fn save(data_dir: &Path, state: &StateFile) -> Result<()> {
fs::create_dir_all(data_dir)
.wrap_err_with(|| format!("could not create {}", data_dir.display()))?;
let text = toml::to_string_pretty(state).wrap_err("could not encode state.toml")?;
let path = data_dir.join(STATE_FILE);
fs::write(&path, text).wrap_err_with(|| format!("could not write {}", path.display()))
}
pub(crate) fn scope_key(zero_config: bool, cwd: &Path, active_set_name: &str) -> String {
if zero_config {
cwd.to_string_lossy().into_owned()
} else {
active_set_name.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_missing_file_loads_as_an_empty_state_file() {
let dir = tempfile::tempdir().expect("temp dir");
let file = load(dir.path());
assert_eq!(file, StateFile::default());
assert_eq!(file.scope("anything"), ScopeState::default());
}
#[test]
fn save_then_load_round_trips_a_scopes_whole_content() {
let dir = tempfile::tempdir().expect("temp dir");
let mut file = StateFile::default();
file.set_scope(
"work".to_string(),
ScopeState {
selection: vec!["repo-a".to_string(), "repo-b".to_string()],
filter: "kind:worktree".to_string(),
sort: None,
show_worktrees: None,
show_ignored: None,
},
);
save(dir.path(), &file).expect("save state.toml");
let reloaded = load(dir.path());
assert_eq!(
reloaded.scope("work"),
ScopeState {
selection: vec!["repo-a".to_string(), "repo-b".to_string()],
filter: "kind:worktree".to_string(),
sort: None,
show_worktrees: None,
show_ignored: None,
}
);
}
#[test]
fn the_written_file_holds_only_selection_filter_sort_and_the_two_toggles_nothing_else() {
let dir = tempfile::tempdir().expect("temp dir");
let mut file = StateFile::default();
file.set_scope(
"work".to_string(),
ScopeState {
selection: vec!["repo-a".to_string()],
filter: "is:dirty".to_string(),
sort: Some(RowOrder::cold_start()),
show_worktrees: Some(false),
show_ignored: Some(true),
},
);
save(dir.path(), &file).expect("save state.toml");
let text = fs::read_to_string(dir.path().join(STATE_FILE)).expect("read state.toml");
let parsed: toml::Value = toml::from_str(&text).expect("parse written toml");
let scope = parsed
.get("work")
.expect("the scope table")
.as_table()
.expect("scope is a table");
let mut keys: Vec<&str> = scope.keys().map(String::as_str).collect();
keys.sort_unstable();
assert_eq!(
keys,
vec![
"filter",
"selection",
"show_ignored",
"show_worktrees",
"sort"
],
"a scope must hold exactly `selection`, `filter`, `sort`, `show_worktrees` and \
`show_ignored`, nothing git computed: {text:?}"
);
}
#[test]
fn a_recorded_sort_round_trips_through_state_toml() {
use crate::sort::{Direction, SortColumn};
let dir = tempfile::tempdir().expect("temp dir");
let mut file = StateFile::default();
file.set_scope(
"sorted".to_string(),
ScopeState {
selection: vec![],
filter: String::new(),
sort: Some(RowOrder::By {
column: SortColumn::Dirty,
direction: Direction::Descending,
}),
show_worktrees: None,
show_ignored: None,
},
);
file.set_scope(
"natural".to_string(),
ScopeState {
selection: vec![],
filter: String::new(),
sort: Some(RowOrder::Natural),
show_worktrees: None,
show_ignored: None,
},
);
save(dir.path(), &file).expect("save state.toml");
let reloaded = load(dir.path());
assert_eq!(
reloaded.scope("sorted").sort,
Some(RowOrder::By {
column: SortColumn::Dirty,
direction: Direction::Descending,
})
);
assert_eq!(reloaded.scope("natural").sort, Some(RowOrder::Natural));
}
#[test]
fn a_recorded_worktrees_toggle_round_trips_through_state_toml() {
let dir = tempfile::tempdir().expect("temp dir");
let mut file = StateFile::default();
file.set_scope(
"hidden".to_string(),
ScopeState {
show_worktrees: Some(false),
show_ignored: None,
..ScopeState::default()
},
);
file.set_scope(
"shown".to_string(),
ScopeState {
show_worktrees: Some(true),
show_ignored: None,
..ScopeState::default()
},
);
save(dir.path(), &file).expect("save state.toml");
let reloaded = load(dir.path());
assert_eq!(reloaded.scope("hidden").show_worktrees, Some(false));
assert_eq!(reloaded.scope("shown").show_worktrees, Some(true));
}
#[test]
fn a_recorded_ignored_toggle_round_trips_through_state_toml() {
let dir = tempfile::tempdir().expect("temp dir");
let mut file = StateFile::default();
file.set_scope(
"hidden".to_string(),
ScopeState {
show_ignored: Some(false),
..ScopeState::default()
},
);
file.set_scope(
"shown".to_string(),
ScopeState {
show_ignored: Some(true),
..ScopeState::default()
},
);
save(dir.path(), &file).expect("save state.toml");
let reloaded = load(dir.path());
assert_eq!(reloaded.scope("hidden").show_ignored, Some(false));
assert_eq!(reloaded.scope("shown").show_ignored, Some(true));
}
#[test]
fn a_scope_with_no_show_ignored_key_loads_it_as_none() {
let dir = tempfile::tempdir().expect("temp dir");
fs::write(
dir.path().join(STATE_FILE),
"[work]\nselection = [\"repo-a\"]\nfilter = \"is:dirty\"\n",
)
.expect("write a pre-toggle state.toml");
let scope = load(dir.path()).scope("work");
assert_eq!(scope.show_ignored, None);
}
#[test]
fn a_scope_with_no_show_worktrees_key_loads_it_as_none() {
let dir = tempfile::tempdir().expect("temp dir");
fs::write(
dir.path().join(STATE_FILE),
"[work]\nselection = [\"repo-a\"]\nfilter = \"is:dirty\"\n",
)
.expect("write a pre-toggle state.toml");
let scope = load(dir.path()).scope("work");
assert_eq!(scope.show_worktrees, None);
}
#[test]
fn a_scope_with_no_sort_key_loads_sort_as_none() {
let dir = tempfile::tempdir().expect("temp dir");
fs::write(
dir.path().join(STATE_FILE),
"[work]\nselection = [\"repo-a\"]\nfilter = \"is:dirty\"\n",
)
.expect("write a pre-sort state.toml");
let scope = load(dir.path()).scope("work");
assert_eq!(scope.sort, None);
assert_eq!(scope.selection, vec!["repo-a"]);
assert_eq!(scope.filter, "is:dirty");
}
#[test]
fn the_active_set_round_trips_beside_the_scopes_it_chooses_between() {
let dir = tempfile::tempdir().expect("temp dir");
let mut file = StateFile::default();
file.set_scope(
"work".to_string(),
ScopeState {
selection: vec!["repo-a".to_string()],
filter: "is:dirty".to_string(),
sort: None,
show_worktrees: None,
show_ignored: None,
},
);
file.set_active_set("work".to_string());
save(dir.path(), &file).expect("save state.toml");
let reloaded = load(dir.path());
assert_eq!(reloaded.active_set(), Some("work"));
assert_eq!(reloaded.scope("work").selection, vec!["repo-a"]);
assert_eq!(reloaded.scope("work").filter, "is:dirty");
}
#[test]
fn a_scope_named_after_the_reserved_key_never_displaces_the_remembered_set() {
let dir = tempfile::tempdir().expect("temp dir");
let mut file = StateFile::default();
file.set_scope(
ACTIVE_SET_KEY.to_string(),
ScopeState {
selection: vec!["repo-a".to_string()],
..ScopeState::default()
},
);
file.set_scope(
"work".to_string(),
ScopeState {
selection: vec!["repo-b".to_string()],
..ScopeState::default()
},
);
file.set_active_set(ACTIVE_SET_KEY.to_string());
save(dir.path(), &file).expect("save state.toml");
let reloaded = load(dir.path());
assert_eq!(reloaded.active_set(), Some(ACTIVE_SET_KEY));
assert_eq!(
reloaded.scope("work").selection,
vec!["repo-b"],
"every other scope must survive a Set named after the reserved key"
);
}
#[test]
fn a_file_with_no_remembered_active_set_loads_every_scope_with_none_remembered() {
let dir = tempfile::tempdir().expect("temp dir");
fs::write(
dir.path().join(STATE_FILE),
"[work]\nselection = [\"repo-a\"]\nfilter = \"is:dirty\"\n",
)
.expect("write a state.toml with no remembered Set");
let file = load(dir.path());
assert_eq!(file.active_set(), None);
assert_eq!(file.scope("work").selection, vec!["repo-a"]);
}
#[test]
fn two_different_set_scopes_do_not_read_each_others_state() {
let dir = tempfile::tempdir().expect("temp dir");
let mut file = StateFile::default();
file.set_scope(
"work".to_string(),
ScopeState {
selection: vec!["repo-a".to_string()],
filter: "kind:worktree".to_string(),
sort: None,
show_worktrees: None,
show_ignored: None,
},
);
file.set_scope(
"personal".to_string(),
ScopeState {
selection: vec!["dotfiles".to_string()],
filter: String::new(),
sort: None,
show_worktrees: None,
show_ignored: None,
},
);
save(dir.path(), &file).expect("save state.toml");
let reloaded = load(dir.path());
assert_eq!(reloaded.scope("work").selection, vec!["repo-a"]);
assert_eq!(reloaded.scope("personal").selection, vec!["dotfiles"]);
assert_ne!(reloaded.scope("work"), reloaded.scope("personal"));
}
#[test]
fn scope_key_uses_the_active_sets_name_when_a_config_was_loaded() {
let key_a = scope_key(false, Path::new("/irrelevant"), "work");
let key_b = scope_key(false, Path::new("/irrelevant"), "personal");
assert_eq!(key_a, "work");
assert_ne!(key_a, key_b);
}
#[test]
fn scope_key_uses_the_working_directory_when_running_with_no_config() {
let key_a = scope_key(true, Path::new("/home/paul/dev/one"), "all");
let key_b = scope_key(true, Path::new("/home/paul/dev/two"), "all");
assert_ne!(
key_a, key_b,
"two different working directories running zero-config must never collide on \
the Set name they share"
);
assert_eq!(key_a, "/home/paul/dev/one");
}
#[test]
fn malformed_toml_loads_the_same_as_a_missing_file() {
let dir = tempfile::tempdir().expect("temp dir");
fs::write(
dir.path().join(STATE_FILE),
"this is not = = valid toml [[[\n",
)
.expect("write malformed state.toml");
assert_eq!(load(dir.path()), StateFile::default());
}
#[test]
fn well_formed_toml_in_the_wrong_shape_loads_the_same_as_a_missing_file() {
let dir = tempfile::tempdir().expect("temp dir");
fs::write(
dir.path().join(STATE_FILE),
"[work]\nselection = \"repo-a\"\nfilter = \"is:dirty\"\n",
)
.expect("write well-formed but wrong-shaped state.toml");
assert_eq!(load(dir.path()), StateFile::default());
}
#[test]
fn deleting_the_file_by_hand_is_a_supported_reset() {
let dir = tempfile::tempdir().expect("temp dir");
let mut file = StateFile::default();
file.set_scope(
"work".to_string(),
ScopeState {
selection: vec!["repo-a".to_string()],
filter: "is:dirty".to_string(),
sort: None,
show_worktrees: None,
show_ignored: None,
},
);
save(dir.path(), &file).expect("save state.toml");
fs::remove_file(dir.path().join(STATE_FILE)).expect("delete state.toml by hand");
assert_eq!(load(dir.path()), StateFile::default());
}
}