config.rs's config_dir/load_doc/save_doc/atomic_write were a byte-for-byte copy of bos-settings/src/config/mod.rs's versions (own doc comment said as much) introduced in the same fix pass that added them there — now both delegate to bread_utils::tomlcfg/bread_utils::atomic instead of each carrying their own copy (path dependency for now, see the TODO in Cargo.toml). Also: services/hyprland.rs::query_json used a bare Command::output() with no timeout, so an unresponsive hyprctl (Hyprland wedged/reloading) could block clients()/monitors()/layers() indefinitely — switched to bread_utils::proc::run_json with a 3s cap. Builds clean; all 8 existing tests pass.
189 lines
6.5 KiB
Rust
189 lines
6.5 KiB
Rust
//! `~/.config/breadhelp/state.toml` — non-destructive TOML editing via
|
|
//! `bread_utils::tomlcfg`, the same `load_doc`/`save_doc` discipline
|
|
//! `bos-settings/src/config/mod.rs` uses: a missing file yields defaults, a
|
|
//! file that exists but fails to parse is backed up once before falling
|
|
//! back, so a bad edit is always recoverable. This module used to carry its
|
|
//! own byte-for-byte copy of that logic (introduced in the same fix pass
|
|
//! that added it to bos-settings) — now both share one implementation.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use toml_edit::{value, DocumentMut};
|
|
|
|
pub fn config_dir() -> PathBuf {
|
|
bread_utils::xdg::config_home()
|
|
}
|
|
|
|
fn state_path() -> PathBuf {
|
|
config_dir().join("breadhelp").join("state.toml")
|
|
}
|
|
|
|
fn load_doc(path: &Path) -> DocumentMut {
|
|
bread_utils::tomlcfg::load_doc("breadhelp", path)
|
|
}
|
|
|
|
fn save_doc(path: &Path, doc: &DocumentMut) {
|
|
if let Err(e) = bread_utils::tomlcfg::save_doc(path, doc) {
|
|
eprintln!("breadhelp: couldn't write {}: {e}", path.display());
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
|
pub enum Mode {
|
|
Normal,
|
|
Beginner,
|
|
Dad,
|
|
Compact,
|
|
}
|
|
|
|
impl Mode {
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
Mode::Normal => "normal",
|
|
Mode::Beginner => "beginner",
|
|
Mode::Dad => "dad",
|
|
Mode::Compact => "compact",
|
|
}
|
|
}
|
|
|
|
fn from_str(s: &str) -> Self {
|
|
match s {
|
|
"beginner" => Mode::Beginner,
|
|
"dad" => Mode::Dad,
|
|
"compact" => Mode::Compact,
|
|
_ => Mode::Normal,
|
|
}
|
|
}
|
|
|
|
/// Beginner/Dad pick `content.beginner.md` over `content.md` when present.
|
|
pub fn is_simplified(self) -> bool {
|
|
matches!(self, Mode::Beginner | Mode::Dad)
|
|
}
|
|
}
|
|
|
|
pub struct State {
|
|
doc: DocumentMut,
|
|
path: PathBuf,
|
|
}
|
|
|
|
impl State {
|
|
pub fn load() -> Self {
|
|
let path = state_path();
|
|
let doc = load_doc(&path);
|
|
Self { doc, path }
|
|
}
|
|
|
|
pub fn onboarding_completed(&self) -> bool {
|
|
self.doc
|
|
.get("onboarding")
|
|
.and_then(|t| t.get("completed"))
|
|
.and_then(|v| v.as_bool())
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
pub fn set_onboarding_completed(&mut self, completed: bool) {
|
|
self.doc["onboarding"]["completed"] = value(completed);
|
|
self.save();
|
|
}
|
|
|
|
pub fn onboarding_step(&self) -> i64 {
|
|
self.doc.get("onboarding").and_then(|t| t.get("step")).and_then(|v| v.as_integer()).unwrap_or(0)
|
|
}
|
|
|
|
pub fn set_onboarding_step(&mut self, step: i64) {
|
|
self.doc["onboarding"]["step"] = value(step);
|
|
self.save();
|
|
}
|
|
|
|
/// Set the instant before `services::hyprland::rebind_temp` is called for
|
|
/// a tour step with no compositor-observable signal (e.g. the screenshot
|
|
/// step), cleared the instant after reverting. If breadhelp is killed
|
|
/// mid-step, this is how the next launch knows a keybind was left
|
|
/// pointing at a chained `--tour-event` ping and self-heals it before
|
|
/// the user can be surprised by a stray tour popup — see `ui::tour`.
|
|
pub fn pending_rebind(&self) -> Option<(String, String)> {
|
|
let key = self.doc.get("tour")?.get("pending_rebind_key")?.as_str()?.to_string();
|
|
let original = self.doc.get("tour")?.get("pending_rebind_original")?.as_str()?.to_string();
|
|
Some((key, original))
|
|
}
|
|
|
|
pub fn set_pending_rebind(&mut self, key: &str, original_bind_value: &str) {
|
|
self.doc["tour"]["pending_rebind_key"] = value(key);
|
|
self.doc["tour"]["pending_rebind_original"] = value(original_bind_value);
|
|
self.save();
|
|
}
|
|
|
|
pub fn clear_pending_rebind(&mut self) {
|
|
if let Some(table) = self.doc.get_mut("tour").and_then(|t| t.as_table_mut()) {
|
|
table.remove("pending_rebind_key");
|
|
table.remove("pending_rebind_original");
|
|
}
|
|
self.save();
|
|
}
|
|
|
|
pub fn mode(&self) -> Mode {
|
|
let s = self.doc.get("general").and_then(|t| t.get("mode")).and_then(|v| v.as_str()).unwrap_or("normal");
|
|
Mode::from_str(s)
|
|
}
|
|
|
|
pub fn set_mode(&mut self, mode: Mode) {
|
|
self.doc["general"]["mode"] = value(mode.as_str());
|
|
self.save();
|
|
}
|
|
|
|
fn save(&self) {
|
|
save_doc(&self.path, &self.doc);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn state_toml_write_backs_up_previous_contents_and_no_tmp_file_left_behind() {
|
|
// Exercises the same bread_utils::atomic::write_atomic_backed_up
|
|
// path save_doc uses (via tomlcfg::save_doc), at the file-layout
|
|
// breadhelp actually writes to — the deeper backup/no-leftover-tmp
|
|
// behavior itself is covered by bread-utils' own test suite.
|
|
let dir = std::env::temp_dir().join(format!("breadhelp-atomic-write-test-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let path = dir.join("state.toml");
|
|
let backup = dir.join("state.toml.bak");
|
|
|
|
bread_utils::atomic::write_atomic_backed_up(&path, "first").unwrap();
|
|
assert_eq!(std::fs::read_to_string(&path).unwrap(), "first");
|
|
assert!(!backup.exists(), "no backup should be made when there's nothing to back up yet");
|
|
|
|
bread_utils::atomic::write_atomic_backed_up(&path, "second").unwrap();
|
|
assert_eq!(std::fs::read_to_string(&path).unwrap(), "second");
|
|
assert_eq!(std::fs::read_to_string(&backup).unwrap(), "first");
|
|
|
|
let leftover_tmp: Vec<_> = std::fs::read_dir(&dir)
|
|
.unwrap()
|
|
.filter_map(|e| e.ok())
|
|
.map(|e| e.file_name().to_string_lossy().into_owned())
|
|
.filter(|n| n.contains(".tmp."))
|
|
.collect();
|
|
assert!(leftover_tmp.is_empty(), "temp file should be renamed away, not left behind: {leftover_tmp:?}");
|
|
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn save_doc_then_load_doc_round_trips_state() {
|
|
let dir = std::env::temp_dir().join(format!("breadhelp-state-roundtrip-test-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let path = dir.join("state.toml");
|
|
|
|
let mut doc = DocumentMut::default();
|
|
doc["onboarding"]["completed"] = value(true);
|
|
doc["general"]["mode"] = value("dad");
|
|
save_doc(&path, &doc);
|
|
|
|
let loaded = load_doc(&path);
|
|
assert_eq!(loaded.get("onboarding").and_then(|t| t.get("completed")).and_then(|v| v.as_bool()), Some(true));
|
|
assert_eq!(loaded.get("general").and_then(|t| t.get("mode")).and_then(|v| v.as_str()), Some("dad"));
|
|
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
}
|