fsmon 0.1.5

Lightweight High-Performance File System Change Tracking Tool
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
    pub monitor: Option<MonitorConfig>,
    pub query: Option<QueryConfig>,
    pub clean: Option<CleanConfig>,
    pub install: Option<InstallConfig>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MonitorConfig {
    pub paths: Option<Vec<PathBuf>>,
    pub min_size: Option<String>,
    pub types: Option<String>,
    pub exclude: Option<String>,
    pub all_events: Option<bool>,
    pub output: Option<PathBuf>,
    pub format: Option<String>,
    pub recursive: Option<bool>,
    pub buffer_size: Option<usize>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct QueryConfig {
    pub log_file: Option<PathBuf>,
    pub since: Option<String>,
    pub until: Option<String>,
    pub pid: Option<String>,
    pub cmd: Option<String>,
    pub user: Option<String>,
    pub types: Option<String>,
    pub min_size: Option<String>,
    pub format: Option<String>,
    pub sort: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CleanConfig {
    pub log_file: Option<PathBuf>,
    pub keep_days: Option<u32>,
    pub max_size: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct InstallConfig {
    pub protect_system: Option<String>,
    pub protect_home: Option<String>,
    pub read_write_paths: Option<Vec<String>>,
    pub private_tmp: Option<String>,
}

impl Config {
    pub fn load() -> Result<Self> {
        match Self::find_config_file() {
            Some(path) => Self::load_from_path(&path),
            None => Ok(Config::default()),
        }
    }

    pub fn load_from_path(path: &Path) -> Result<Self> {
        let content = fs::read_to_string(path)
            .map_err(|e| anyhow::anyhow!("Failed to read config {}: {}", path.display(), e))?;
        let config: Config = toml::from_str(&content)
            .map_err(|e| anyhow::anyhow!("Invalid TOML in {}: {}", path.display(), e))?;
        Ok(config)
    }

    fn find_config_file() -> Option<PathBuf> {
        if let Some(home) = dirs::home_dir() {
            let home_config = home.join(".fsmon").join("config.toml");
            if home_config.exists() {
                return Some(home_config);
            }
        }

        let etc_config = PathBuf::from("/etc/fsmon/config.toml");
        if etc_config.exists() {
            return Some(etc_config);
        }

        None
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write;

    #[test]
    fn test_config_load_nonexistent() {
        let config = Config::load().unwrap();
        assert!(config.monitor.is_none());
        assert!(config.query.is_none());
        assert!(config.clean.is_none());
        assert!(config.install.is_none());
    }

    #[test]
    fn test_config_load_valid() {
        let dir = std::env::temp_dir().join("fsmon_test_config_valid");
        fs::create_dir_all(&dir).unwrap();
        let config_path = dir.join("config.toml");

        let toml_content = r#"
[monitor]
paths = ["/var/log", "/tmp"]
min_size = "100MB"
types = "MODIFY,CREATE"
exclude = "*.tmp"
all_events = true
output = "/var/log/fsmon.log"
format = "json"
recursive = true

[query]
log_file = "/var/log/fsmon.log"
since = "1h"
format = "json"
sort = "size"

[clean]
keep_days = 7
max_size = "500MB"
"#;

        let mut file = fs::File::create(&config_path).unwrap();
        file.write_all(toml_content.as_bytes()).unwrap();

        let config = Config::load_from_path(&config_path).unwrap();

        let monitor = config.monitor.unwrap();
        assert_eq!(monitor.paths.unwrap().len(), 2);
        assert_eq!(monitor.min_size.unwrap(), "100MB");
        assert_eq!(monitor.types.unwrap(), "MODIFY,CREATE");
        assert_eq!(monitor.exclude.unwrap(), "*.tmp");
        assert!(monitor.all_events.unwrap());
        assert_eq!(monitor.output.unwrap(), PathBuf::from("/var/log/fsmon.log"));
        assert_eq!(monitor.format.unwrap(), "json");
        assert!(monitor.recursive.unwrap());

        let query = config.query.unwrap();
        assert_eq!(query.log_file.unwrap(), PathBuf::from("/var/log/fsmon.log"));
        assert_eq!(query.since.unwrap(), "1h");
        assert_eq!(query.format.unwrap(), "json");
        assert_eq!(query.sort.unwrap(), "size");

        let clean = config.clean.unwrap();
        assert_eq!(clean.keep_days.unwrap(), 7);
        assert_eq!(clean.max_size.unwrap(), "500MB");

        let _ = fs::remove_dir_all(&dir);
    }

    #[test]
    fn test_config_load_invalid() {
        let dir = std::env::temp_dir().join("fsmon_test_config_invalid");
        fs::create_dir_all(&dir).unwrap();
        let config_path = dir.join("config.toml");

        let invalid_toml = "this is not valid toml [[[[";

        let mut file = fs::File::create(&config_path).unwrap();
        file.write_all(invalid_toml.as_bytes()).unwrap();

        let result = Config::load_from_path(&config_path);
        assert!(result.is_err());
        let err_msg = result.unwrap_err().to_string();
        assert!(
            err_msg.contains("Invalid TOML"),
            "error should mention invalid TOML: {err_msg}"
        );

        let _ = fs::remove_dir_all(&dir);
    }

    #[test]
    fn test_config_load_buffer_size() {
        let dir = std::env::temp_dir().join("fsmon_test_config_buffer_size");
        fs::create_dir_all(&dir).unwrap();
        let config_path = dir.join("config.toml");

        let toml_content = r#"
[monitor]
buffer_size = 65536
"#;

        let mut file = fs::File::create(&config_path).unwrap();
        file.write_all(toml_content.as_bytes()).unwrap();

        let config = Config::load_from_path(&config_path).unwrap();
        let monitor = config.monitor.unwrap();
        assert_eq!(monitor.buffer_size.unwrap(), 65536);

        let _ = fs::remove_dir_all(&dir);
    }

    #[test]
    fn test_config_merge_cli_overrides() {
        let toml_content = r#"
[monitor]
paths = ["/var/log"]
min_size = "100MB"
types = "MODIFY"
"#;

        let config: Config = toml::from_str(toml_content).unwrap();
        let monitor = config.monitor.as_ref().unwrap();

        assert_eq!(
            monitor.paths.as_ref().unwrap(),
            &vec![PathBuf::from("/var/log")]
        );
        assert_eq!(monitor.min_size.as_deref(), Some("100MB"));
        assert_eq!(monitor.types.as_deref(), Some("MODIFY"));

        let cli_min_size: Option<String> = Some("50MB".to_string());
        let merged_min_size = cli_min_size.or(monitor.min_size.clone());
        assert_eq!(merged_min_size.as_deref(), Some("50MB"));

        let cli_types: Option<String> = None;
        let merged_types = cli_types.or(monitor.types.clone());
        assert_eq!(merged_types.as_deref(), Some("MODIFY"));
    }

    #[test]
    fn test_install_config() {
        let toml_content = r#"
[install]
protect_system = "false"
protect_home = "false"
read_write_paths = ["/var/log", "/tmp"]
private_tmp = "no"
"#;

        let config: Config = toml::from_str(toml_content).unwrap();
        let install = config.install.unwrap();
        assert_eq!(install.protect_system.as_deref(), Some("false"));
        assert_eq!(install.protect_home.as_deref(), Some("false"));
        assert_eq!(
            install.read_write_paths.unwrap(),
            vec!["/var/log".to_string(), "/tmp".to_string()]
        );
        assert_eq!(install.private_tmp.as_deref(), Some("no"));
    }

    #[test]
    fn test_install_config_partial() {
        let toml_content = r#"
[install]
protect_system = "false"
"#;

        let config: Config = toml::from_str(toml_content).unwrap();
        let install = config.install.unwrap();
        assert_eq!(install.protect_system.as_deref(), Some("false"));
        assert!(install.protect_home.is_none());
        assert!(install.read_write_paths.is_none());
        assert!(install.private_tmp.is_none());
    }
}