use crate::error::FrostxError;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProjectState {
pub project_path: PathBuf,
pub last_scan: Option<DateTime<Utc>>,
#[serde(default, rename = "rule")]
pub rules: Vec<RuleState>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuleState {
#[serde(default)]
pub hash: String,
#[serde(default)]
pub completed: Vec<String>,
pub last_run: Option<DateTime<Utc>>,
#[serde(default)]
pub rule_done: bool,
}
impl ProjectState {
pub fn load(state_dir: &Path, uuid: Uuid) -> Result<Self, FrostxError> {
let path = state_file_path(state_dir, uuid);
if !path.exists() {
return Ok(Self::default());
}
let content = std::fs::read_to_string(&path)?;
toml::from_str(&content)
.map_err(|e| FrostxError::Config(format!("state file parse error: {e}")))
}
pub fn save(&self, state_dir: &Path, uuid: Uuid) -> Result<(), FrostxError> {
std::fs::create_dir_all(state_dir)?;
let path = state_file_path(state_dir, uuid);
let content = toml::to_string_pretty(self)
.map_err(|e| FrostxError::Config(format!("state serialisation error: {e}")))?;
std::fs::write(path, content)?;
Ok(())
}
pub fn delete(state_dir: &Path, uuid: Uuid) -> Result<(), FrostxError> {
let path = state_file_path(state_dir, uuid);
if path.exists() {
std::fs::remove_file(path)?;
}
Ok(())
}
#[must_use]
pub fn rule_mut(&mut self, rule_hash: &str) -> &mut RuleState {
if let Some(pos) = self.rules.iter().position(|r| r.hash == rule_hash) {
&mut self.rules[pos]
} else {
let pos = self.rules.len();
self.rules.push(RuleState {
hash: rule_hash.to_string(),
completed: vec![],
last_run: None,
rule_done: false,
});
&mut self.rules[pos]
}
}
#[must_use]
pub fn rule(&self, rule_hash: &str) -> Option<&RuleState> {
self.rules.iter().find(|r| r.hash == rule_hash)
}
#[must_use]
pub fn is_completed(&self, rule_hash: &str, action_name: &str) -> bool {
self.rule(rule_hash)
.is_some_and(|r| r.completed.iter().any(|a| a == action_name))
}
pub fn mark_completed(&mut self, rule_hash: &str, action_name: &str) {
let rule = self.rule_mut(rule_hash);
if !rule.completed.iter().any(|a| a == action_name) {
rule.completed.push(action_name.to_string());
}
rule.last_run = Some(Utc::now());
}
#[must_use]
pub fn is_rule_done(&self, rule_hash: &str) -> bool {
self.rule(rule_hash).is_some_and(|r| r.rule_done)
}
pub fn mark_rule_done(&mut self, rule_hash: &str) {
let rule = self.rule_mut(rule_hash);
rule.rule_done = true;
rule.last_run = Some(Utc::now());
}
}
pub fn list_state_files(state_dir: &Path) -> Result<Vec<(Uuid, PathBuf)>, FrostxError> {
if !state_dir.exists() {
return Ok(vec![]);
}
let mut entries = Vec::new();
for entry in std::fs::read_dir(state_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("toml") {
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
if let Ok(uuid) = stem.parse::<Uuid>() {
entries.push((uuid, path));
}
}
}
}
Ok(entries)
}
fn state_file_path(state_dir: &Path, uuid: Uuid) -> PathBuf {
state_dir.join(format!("{uuid}.toml"))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn save_and_load_roundtrip() {
let tmp = tempdir().unwrap();
let uuid = Uuid::new_v4();
let mut state = ProjectState {
project_path: PathBuf::from("/some/project"),
last_scan: Some(Utc::now()),
..Default::default()
};
state.mark_completed("abc123", "archive.compress");
state.save(tmp.path(), uuid).unwrap();
let loaded = ProjectState::load(tmp.path(), uuid).unwrap();
assert_eq!(loaded.project_path, PathBuf::from("/some/project"));
assert!(loaded.is_completed("abc123", "archive.compress"));
assert!(!loaded.is_completed("abc123", "backup.upload"));
}
#[test]
fn missing_state_returns_default() {
let tmp = tempdir().unwrap();
let state = ProjectState::load(tmp.path(), Uuid::new_v4()).unwrap();
assert!(state.project_path.as_os_str().is_empty());
}
#[test]
fn mark_completed_idempotent() {
let mut state = ProjectState::default();
state.mark_completed("abc123", "archive.compress");
state.mark_completed("abc123", "archive.compress");
assert_eq!(state.rule("abc123").unwrap().completed.len(), 1);
}
#[test]
fn hash_change_resets_completion() {
let mut state = ProjectState::default();
state.mark_completed("hash_v1", "archive.compress");
assert!(state.is_completed("hash_v1", "archive.compress"));
assert!(!state.is_completed("hash_v2", "archive.compress"));
}
#[test]
fn list_state_files_finds_entries() {
let tmp = tempdir().unwrap();
let uuid = Uuid::new_v4();
let state = ProjectState {
project_path: PathBuf::from("/test"),
..Default::default()
};
state.save(tmp.path(), uuid).unwrap();
let files = list_state_files(tmp.path()).unwrap();
assert_eq!(files.len(), 1);
assert_eq!(files[0].0, uuid);
}
#[test]
fn delete_removes_file() {
let tmp = tempdir().unwrap();
let uuid = Uuid::new_v4();
let state = ProjectState {
project_path: PathBuf::from("/test"),
..Default::default()
};
state.save(tmp.path(), uuid).unwrap();
ProjectState::delete(tmp.path(), uuid).unwrap();
assert!(list_state_files(tmp.path()).unwrap().is_empty());
}
#[test]
fn mark_rule_done_and_is_rule_done() {
let mut state = ProjectState::default();
assert!(!state.is_rule_done("abc123"));
state.mark_rule_done("abc123");
assert!(state.is_rule_done("abc123"));
}
#[test]
fn rule_done_persists_across_save_load() {
let tmp = tempdir().unwrap();
let uuid = Uuid::new_v4();
let mut state = ProjectState {
project_path: PathBuf::from("/some/project"),
last_scan: None,
..Default::default()
};
state.mark_rule_done("myhash");
state.save(tmp.path(), uuid).unwrap();
let loaded = ProjectState::load(tmp.path(), uuid).unwrap();
assert!(loaded.is_rule_done("myhash"));
}
#[test]
fn rule_done_does_not_affect_other_hashes() {
let mut state = ProjectState::default();
state.mark_rule_done("hash_a");
assert!(state.is_rule_done("hash_a"));
assert!(!state.is_rule_done("hash_b"));
}
}