openlatch-client 0.1.6

The open-source security layer for AI agents — client forwarder
//! Read the `[crashreport]` section of `~/.openlatch/config.toml`.
//!
//! Unlike the PostHog telemetry consent file, crash reporting does **not** have
//! its own sidecar JSON. The toggle lives in the main TOML config because
//! (a) it has a single boolean field and (b) it shares the same per-user
//! directory lifecycle.
//!
//! Default: **enabled** when the section is absent. This is the single
//! exception in Observability Strategy §11's consent table — crash reports are
//! diagnostic, not behavioural telemetry.

use std::path::Path;

use serde::Deserialize;

/// On-disk shape of `[crashreport]`. All fields are optional so a missing or
/// partially-written section still parses.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct CrashreportSection {
    pub enabled: bool,
}

impl Default for CrashreportSection {
    fn default() -> Self {
        Self { enabled: true }
    }
}

/// Outer shape used for the partial TOML parse — we only care about the one
/// section and ignore every other top-level key.
#[derive(Debug, Default, Deserialize)]
struct PartialConfig {
    #[serde(default)]
    crashreport: Option<CrashreportSection>,
}

/// Parse the `[crashreport]` section from the given config.toml path.
///
/// Returns:
/// - `Ok(Some(section))` when the section is present (any parse error inside
///   the section bubbles up as `Err` so corruption doesn't silently re-enable).
/// - `Ok(None)` when the file does not exist OR the section is absent — the
///   consent layer treats both as "default on".
/// - `Err(...)` on I/O or TOML parse failure of the whole file.
pub fn read_section(path: &Path) -> Result<Option<CrashreportSection>, ReadError> {
    let raw = match std::fs::read_to_string(path) {
        Ok(s) => s,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
        Err(e) => return Err(ReadError::Io(e.to_string())),
    };
    let parsed: PartialConfig =
        toml::from_str(&raw).map_err(|e| ReadError::Parse(e.to_string()))?;
    Ok(parsed.crashreport)
}

#[derive(Debug)]
pub enum ReadError {
    Io(String),
    Parse(String),
}

impl std::fmt::Display for ReadError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ReadError::Io(s) => write!(f, "config.toml read error: {s}"),
            ReadError::Parse(s) => write!(f, "config.toml parse error: {s}"),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    #[test]
    fn test_read_section_missing_file_returns_none() {
        let tmp = TempDir::new().unwrap();
        let path = tmp.path().join("config.toml");
        assert!(read_section(&path).unwrap().is_none());
    }

    #[test]
    fn test_read_section_absent_returns_none() {
        let tmp = TempDir::new().unwrap();
        let path = tmp.path().join("config.toml");
        std::fs::write(&path, "[other]\nkey = \"value\"\n").unwrap();
        assert!(read_section(&path).unwrap().is_none());
    }

    #[test]
    fn test_read_section_enabled_true() {
        let tmp = TempDir::new().unwrap();
        let path = tmp.path().join("config.toml");
        std::fs::write(&path, "[crashreport]\nenabled = true\n").unwrap();
        assert_eq!(
            read_section(&path).unwrap(),
            Some(CrashreportSection { enabled: true })
        );
    }

    #[test]
    fn test_read_section_enabled_false() {
        let tmp = TempDir::new().unwrap();
        let path = tmp.path().join("config.toml");
        std::fs::write(&path, "[crashreport]\nenabled = false\n").unwrap();
        assert_eq!(
            read_section(&path).unwrap(),
            Some(CrashreportSection { enabled: false })
        );
    }

    #[test]
    fn test_read_section_empty_section_defaults_to_enabled() {
        let tmp = TempDir::new().unwrap();
        let path = tmp.path().join("config.toml");
        std::fs::write(&path, "[crashreport]\n").unwrap();
        assert_eq!(
            read_section(&path).unwrap(),
            Some(CrashreportSection { enabled: true })
        );
    }
}