openlatch-provider 0.1.0

Self-service onboarding CLI + runtime daemon for OpenLatch Editors and Providers
//! Consent resolver — applies the 6-level precedence from
//! `.claude/rules/telemetry.md`:
//!
//! 1. `DO_NOT_TRACK=1` → disabled (cross-tool standard)
//! 2. `OPENLATCH_TELEMETRY_DISABLED=1` → disabled (tool-specific)
//! 3. CI environment detected → disabled
//! 4. `~/.openlatch/provider/telemetry.json { enabled: false }` → disabled
//! 5. `~/.openlatch/provider/telemetry.json { enabled: true }` → enabled
//! 6. file missing → disabled (until first-run prompt or `config set`)

use std::path::Path;

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

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConsentState {
    Enabled,
    Disabled,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DecidedBy {
    DoNotTrackEnv,
    OpenlatchDisabledEnv,
    CiEnvironment,
    ConfigFile,
    DefaultUnconsented,
}

#[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
    }
}

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 → disabled (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,
    }
}

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| std::env::var(v).is_ok_and(|x| !x.is_empty()))
}

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

    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 do_not_track_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");
        super::super::consent_file::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 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::consent_file::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 ci_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::consent_file::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 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 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::consent_file::write_consent(&path, true).unwrap();
        let r = resolve(&path);
        assert_eq!(r.state, ConsentState::Enabled);
        assert_eq!(r.decided_by, DecidedBy::ConfigFile);
    }
}