use std::path::Path;
use crate::config::types::{MatchRule, WindowAction, WindowRule, WindowRulesConfig};
#[derive(Debug, Clone, Default)]
pub struct HistoryStore {
config: WindowRulesConfig,
}
const HISTORY_FILE_HEADER: &str = "\
# history-flow-rules.toml — learned window classification rules.
#
# This file is maintained automatically by flowd based on your `set-window`
# float/tile decisions. You may edit or delete entries by hand; flowd will
# recreate the file as you continue using it. To clear ALL learned rules,
# delete this file (or empty the [[rules]] list).
#
# Priority chain: user rules (flow-rules.toml) > learned rules (this file)
# > default rules > default_action.
";
impl HistoryStore {
#[must_use]
pub fn load(path: &Path) -> Self {
match std::fs::read_to_string(path) {
Ok(contents) => match toml::from_str::<WindowRulesConfig>(&contents) {
Ok(config) => {
log::info!(
"loaded {} learned rules from {:?}",
config.rules.len(),
path
);
Self { config }
}
Err(err) => {
log::warn!("history file {path:?} is corrupt, ignoring: {err}");
Self::default()
}
},
Err(err) => {
if err.kind() == std::io::ErrorKind::NotFound {
log::debug!("no history file at {path:?}; starting with empty learned rules");
} else {
log::warn!(
"failed to read history file {path:?}: {err}; starting with empty learned rules"
);
}
Self::default()
}
}
}
pub fn record(&mut self, action: WindowAction, exe: &str, class: Option<&str>) -> bool {
debug_assert!(!exe.is_empty(), "record() requires a non-empty exe");
let class = class.filter(|c| !c.is_empty());
let match_rule = MatchRule {
exe: Some(exe.to_owned()),
class: class.map(|c| c.to_owned()),
..MatchRule::default()
};
let existing =
self.config.rules.iter_mut().find(|r| {
r.match_.exe.as_deref() == Some(exe) && r.match_.class.as_deref() == class
});
if let Some(rule) = existing {
if rule.action != action {
rule.action = action;
return true;
}
return false;
}
self.config.rules.push(WindowRule {
match_: match_rule,
action,
initial_width_px: None,
override_persist: false,
});
true
}
pub fn save(&self, path: &Path) -> std::io::Result<()> {
let toml_str = toml::to_string_pretty(&self.config)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
let content = format!("{HISTORY_FILE_HEADER}{toml_str}");
let tmp_path = path.with_extension("toml.tmp");
std::fs::write(&tmp_path, &content)?;
match std::fs::rename(&tmp_path, path) {
Ok(()) => Ok(()),
Err(rename_err) => {
let _ = std::fs::remove_file(&tmp_path);
Err(rename_err)
}
}
}
#[must_use]
pub fn rules(&self) -> &[WindowRule] {
&self.config.rules
}
#[must_use]
pub fn len(&self) -> usize {
self.config.rules.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.config.rules.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn load_missing_file_returns_empty() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("nonexistent-history.toml");
let store = HistoryStore::load(&path);
assert!(store.is_empty());
assert_eq!(store.len(), 0);
}
#[test]
fn load_corrupt_file_returns_empty() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("bad-history.toml");
std::fs::write(&path, "this is = not = valid = toml = [[[[").unwrap();
let store = HistoryStore::load(&path);
assert!(store.is_empty());
}
#[test]
fn load_valid_file_populates_store() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("history.toml");
let toml_content = r#"
default_action = "float"
[[rules]]
match = { exe = "chrome.exe", class = "Chrome_WidgetWin_1" }
action = "tile"
"#;
std::fs::write(&path, toml_content).unwrap();
let store = HistoryStore::load(&path);
assert_eq!(store.len(), 1);
assert_eq!(store.rules()[0].action, WindowAction::Tile);
}
#[test]
fn record_new_app_pushes_rule() {
let mut store = HistoryStore::default();
let changed = store.record(WindowAction::Tile, "chrome.exe", Some("Chrome_WidgetWin_1"));
assert!(changed);
assert_eq!(store.len(), 1);
assert_eq!(store.rules()[0].match_.exe.as_deref(), Some("chrome.exe"));
assert_eq!(
store.rules()[0].match_.class.as_deref(),
Some("Chrome_WidgetWin_1")
);
assert_eq!(store.rules()[0].action, WindowAction::Tile);
}
#[test]
fn record_existing_app_updates_action() {
let mut store = HistoryStore::default();
store.record(WindowAction::Tile, "chrome.exe", Some("Chrome_WidgetWin_1"));
let changed = store.record(
WindowAction::Float,
"chrome.exe",
Some("Chrome_WidgetWin_1"),
);
assert!(changed);
assert_eq!(store.len(), 1, "should still be exactly one rule (dedup)");
assert_eq!(store.rules()[0].action, WindowAction::Float);
}
#[test]
fn record_exe_only_when_class_none() {
let mut store = HistoryStore::default();
store.record(WindowAction::Float, "notepad.exe", None);
assert_eq!(store.len(), 1);
assert!(store.rules()[0].match_.class.is_none());
assert_eq!(store.rules()[0].match_.exe.as_deref(), Some("notepad.exe"));
}
#[test]
fn record_no_change_returns_false() {
let mut store = HistoryStore::default();
let first = store.record(WindowAction::Tile, "code.exe", Some("Electron"));
assert!(first);
let second = store.record(WindowAction::Tile, "code.exe", Some("Electron"));
assert!(!second, "same action should not report a change");
assert_eq!(store.len(), 1);
}
#[test]
fn record_empty_class_treated_as_none() {
let mut store = HistoryStore::default();
store.record(WindowAction::Float, "app.exe", Some(""));
assert!(store.rules()[0].match_.class.is_none());
let changed = store.record(WindowAction::Tile, "app.exe", None);
assert!(changed);
assert_eq!(store.len(), 1);
}
#[test]
fn identity_distinguishes_different_classes_same_exe() {
let mut store = HistoryStore::default();
store.record(WindowAction::Tile, "code.exe", Some("Electron"));
store.record(WindowAction::Float, "code.exe", Some("Chrome_WidgetWin_1"));
assert_eq!(store.len(), 2, "different classes should be separate rules");
}
#[test]
fn save_then_load_round_trips() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("history-flow-rules.toml");
let mut store = HistoryStore::default();
store.record(WindowAction::Tile, "chrome.exe", Some("Chrome_WidgetWin_1"));
store.record(WindowAction::Float, "notepad.exe", None);
store.save(&path).expect("save should succeed");
let loaded = HistoryStore::load(&path);
assert_eq!(loaded.len(), 2);
assert_eq!(loaded.rules()[0].action, WindowAction::Tile);
assert_eq!(loaded.rules()[0].match_.exe.as_deref(), Some("chrome.exe"));
assert_eq!(loaded.rules()[1].action, WindowAction::Float);
assert_eq!(loaded.rules()[1].match_.exe.as_deref(), Some("notepad.exe"));
}
#[test]
fn save_includes_human_readable_header() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("history-flow-rules.toml");
let mut store = HistoryStore::default();
store.record(WindowAction::Tile, "app.exe", None);
store.save(&path).expect("save should succeed");
let contents = std::fs::read_to_string(&path).unwrap();
assert!(
contents.contains("history-flow-rules.toml"),
"header should contain the file name"
);
assert!(
contents.contains("Priority chain"),
"header should explain the priority chain"
);
}
#[test]
fn save_output_is_valid_toml() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("history-flow-rules.toml");
let mut store = HistoryStore::default();
store.record(WindowAction::Tile, "app.exe", Some("SomeClass"));
store.save(&path).unwrap();
let contents = std::fs::read_to_string(&path).unwrap();
let _: WindowRulesConfig =
toml::from_str(&contents).expect("saved content must be valid TOML");
}
}