openlatch-client 0.1.6

The open-source security layer for AI agents — client forwarder
//! Consent resolver for the telemetry subsystem.
//!
//! Resolves the current consent state by applying the precedence rules from
//! `.brainstorms/2026-04-13-posthog-client-telemetry.md §4.3`:
//!
//! 1. `DO_NOT_TRACK=1` → disabled (cross-tool standard)
//! 2. `OPENLATCH_TELEMETRY_DISABLED=1` → disabled
//! 3. CI environment detected → disabled
//! 4. `~/.openlatch/telemetry.json { enabled: false }` → disabled
//! 5. `~/.openlatch/telemetry.json { enabled: true }` → enabled
//! 6. File missing → disabled (until `openlatch init` writes consent)
//!
//! The resolver also reports which rule fired, so `openlatch telemetry status`
//! can explain the deciding factor to the user (invariant I7).

use std::path::Path;

use super::config::{read_consent, ConsentFile};

/// Final consent state — the question `capture()` asks before doing anything.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConsentState {
    /// Telemetry is active — events may be captured and sent.
    Enabled,
    /// Telemetry is off — `capture()` is a no-op, no network.
    Disabled,
}

/// Which precedence rule decided the final state.
///
/// Exposed via `openlatch telemetry status` so users can verify opt-out
/// actually took effect (invariant I7 — "trust but verify").
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DecidedBy {
    /// `DO_NOT_TRACK` environment variable is set to a truthy value.
    DoNotTrackEnv,
    /// `OPENLATCH_TELEMETRY_DISABLED` environment variable is set.
    OpenlatchDisabledEnv,
    /// A CI environment was detected (`CI=true`, `GITHUB_ACTIONS`, etc.).
    CiEnvironment,
    /// Value came from the `~/.openlatch/telemetry.json` file.
    ConfigFile,
    /// No consent file existed — default is disabled until the first-run notice fires.
    DefaultUnconsented,
    /// The baked PostHog key is empty, so the subsystem cannot emit anyway.
    NoBakedKey,
}

/// Resolved consent state plus the rule that decided it.
#[derive(Debug, Clone, Copy)]
pub struct Resolved {
    pub state: ConsentState,
    pub decided_by: DecidedBy,
}

impl Resolved {
    pub fn enabled(&self) -> bool {
        self.state == ConsentState::Enabled
    }
}

/// Resolve the current consent state by reading env vars and `telemetry.json`.
///
/// Pure function — takes the config path as a parameter so tests can point it
/// at a tempdir. Never fails: parse errors on the config file are treated as
/// "disabled" (invariant — corrupt file never silently re-enables).
pub fn resolve(config_path: &Path) -> Resolved {
    if is_truthy_env("DO_NOT_TRACK") {
        return Resolved {
            state: ConsentState::Disabled,
            decided_by: DecidedBy::DoNotTrackEnv,
        };
    }
    if is_truthy_env("OPENLATCH_TELEMETRY_DISABLED") {
        return Resolved {
            state: ConsentState::Disabled,
            decided_by: DecidedBy::OpenlatchDisabledEnv,
        };
    }
    if in_ci() {
        return Resolved {
            state: ConsentState::Disabled,
            decided_by: DecidedBy::CiEnvironment,
        };
    }

    match read_consent(config_path) {
        Ok(Some(ConsentFile { enabled: true, .. })) => Resolved {
            state: ConsentState::Enabled,
            decided_by: DecidedBy::ConfigFile,
        },
        Ok(Some(ConsentFile { enabled: false, .. })) => Resolved {
            state: ConsentState::Disabled,
            decided_by: DecidedBy::ConfigFile,
        },
        Ok(None) => Resolved {
            state: ConsentState::Disabled,
            decided_by: DecidedBy::DefaultUnconsented,
        },
        // Corrupt file → treat as disabled. This is a non-observable failure
        // by design (invariant I10 — no telemetry about telemetry).
        Err(_) => Resolved {
            state: ConsentState::Disabled,
            decided_by: DecidedBy::ConfigFile,
        },
    }
}

fn is_truthy_env(name: &str) -> bool {
    match std::env::var(name) {
        Ok(v) => {
            let v = v.trim().to_ascii_lowercase();
            !matches!(v.as_str(), "" | "0" | "false" | "no" | "off")
        }
        Err(_) => false,
    }
}

/// Detect common CI environments. A best-effort match against the most
/// prevalent CI indicators — any unexpected CI is still caught by the
/// generic `CI` variable that most providers set.
fn in_ci() -> bool {
    const CI_VARS: &[&str] = &[
        "CI",
        "GITHUB_ACTIONS",
        "GITLAB_CI",
        "CIRCLECI",
        "JENKINS_URL",
        "BUILDKITE",
        "TF_BUILD",
        "TEAMCITY_VERSION",
        "BITBUCKET_BUILD_NUMBER",
    ];
    CI_VARS
        .iter()
        .any(|v| is_truthy_env(v) || std::env::var(v).is_ok_and(|x| !x.is_empty()))
}

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

    // Env vars are process-global; serialize tests that mutate them.
    static ENV_LOCK: Mutex<()> = Mutex::new(());

    fn clear_env() {
        for v in [
            "DO_NOT_TRACK",
            "OPENLATCH_TELEMETRY_DISABLED",
            "CI",
            "GITHUB_ACTIONS",
            "GITLAB_CI",
            "CIRCLECI",
            "JENKINS_URL",
            "BUILDKITE",
            "TF_BUILD",
            "TEAMCITY_VERSION",
            "BITBUCKET_BUILD_NUMBER",
        ] {
            std::env::remove_var(v);
        }
    }

    #[test]
    fn test_do_not_track_hard_overrides_enabled_config() {
        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        clear_env();
        std::env::set_var("DO_NOT_TRACK", "1");
        let tmp = TempDir::new().unwrap();
        let path = tmp.path().join("telemetry.json");
        // Even with consent=true written, DO_NOT_TRACK wins (I3).
        super::super::config::write_consent(&path, true).unwrap();

        let r = resolve(&path);

        assert_eq!(r.state, ConsentState::Disabled);
        assert_eq!(r.decided_by, DecidedBy::DoNotTrackEnv);
        clear_env();
    }

    #[test]
    fn test_openlatch_disabled_env_wins_over_config() {
        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        clear_env();
        std::env::set_var("OPENLATCH_TELEMETRY_DISABLED", "1");
        let tmp = TempDir::new().unwrap();
        let path = tmp.path().join("telemetry.json");
        super::super::config::write_consent(&path, true).unwrap();

        let r = resolve(&path);

        assert_eq!(r.state, ConsentState::Disabled);
        assert_eq!(r.decided_by, DecidedBy::OpenlatchDisabledEnv);
        clear_env();
    }

    #[test]
    fn test_ci_environment_disables_even_when_enabled() {
        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        clear_env();
        std::env::set_var("GITHUB_ACTIONS", "true");
        let tmp = TempDir::new().unwrap();
        let path = tmp.path().join("telemetry.json");
        super::super::config::write_consent(&path, true).unwrap();

        let r = resolve(&path);

        assert_eq!(r.state, ConsentState::Disabled);
        assert_eq!(r.decided_by, DecidedBy::CiEnvironment);
        clear_env();
    }

    #[test]
    fn test_missing_file_defaults_to_disabled_unconsented() {
        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        clear_env();
        let tmp = TempDir::new().unwrap();
        let path = tmp.path().join("telemetry.json");

        let r = resolve(&path);

        assert_eq!(r.state, ConsentState::Disabled);
        assert_eq!(r.decided_by, DecidedBy::DefaultUnconsented);
    }

    #[test]
    fn test_enabled_config_with_no_env_overrides_is_enabled() {
        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        clear_env();
        let tmp = TempDir::new().unwrap();
        let path = tmp.path().join("telemetry.json");
        super::super::config::write_consent(&path, true).unwrap();

        let r = resolve(&path);

        assert_eq!(r.state, ConsentState::Enabled);
        assert_eq!(r.decided_by, DecidedBy::ConfigFile);
    }

    #[test]
    fn test_falsy_env_values_do_not_disable() {
        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        clear_env();
        std::env::set_var("DO_NOT_TRACK", "0");
        let tmp = TempDir::new().unwrap();
        let path = tmp.path().join("telemetry.json");
        super::super::config::write_consent(&path, true).unwrap();

        let r = resolve(&path);

        // "0" is falsy — DO_NOT_TRACK does not fire.
        assert_eq!(r.state, ConsentState::Enabled);
        assert_eq!(r.decided_by, DecidedBy::ConfigFile);
        clear_env();
    }

    #[test]
    fn test_corrupt_config_file_resolves_disabled() {
        let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        clear_env();
        let tmp = TempDir::new().unwrap();
        let path = tmp.path().join("telemetry.json");
        std::fs::write(&path, b"{ not json").unwrap();

        let r = resolve(&path);

        assert_eq!(r.state, ConsentState::Disabled);
        assert_eq!(r.decided_by, DecidedBy::ConfigFile);
    }
}