openlatch-provider 0.2.1

Self-service onboarding CLI + runtime daemon for OpenLatch Editors and Providers
//! Identity stitching for telemetry — `$create_alias` machine_id → editor_id.
//!
//! Persistence at `~/.openlatch/provider/identity.json`, separate from `config.toml`
//! so the wire-format config stays stable. The file is the source of truth
//! for "we already aliased this (machine_id, editor_id) pair" — without it
//! we would re-alias on every login.
//!
//! The wiring at the auth call site lands in P1.T2.2; T1 just ships the
//! helpers so the auth clone has somewhere to plug in.

use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

use super::client::TelemetryHandle;
use super::events::Event;

#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct Identity {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub editor_id: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub org_id: Option<String>,
    #[serde(default)]
    pub aliased_pairs: Vec<AliasedPair>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AliasedPair {
    pub machine_id: String,
    pub editor_id: String,
}

pub fn identity_path(provider_dir: &Path) -> PathBuf {
    provider_dir.join("identity.json")
}

pub fn read(path: &Path) -> Option<Identity> {
    let raw = std::fs::read_to_string(path).ok()?;
    serde_json::from_str(&raw).ok()
}

pub fn write(path: &Path, identity: &Identity) -> std::io::Result<()> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let serialized = serde_json::to_string_pretty(identity).map_err(std::io::Error::other)?;
    std::fs::write(path, serialized)
}

pub fn already_aliased(identity: &Identity, machine_id: &str, editor_id: &str) -> bool {
    identity
        .aliased_pairs
        .iter()
        .any(|p| p.machine_id == machine_id && p.editor_id == editor_id)
}

/// Record a successful login: persist `editor_id` + emit `$create_alias` once.
///
/// Side-effects are intentionally swallowed — telemetry must not break auth.
pub fn record_auth_success(
    handle: &TelemetryHandle,
    provider_dir: &Path,
    machine_id: &str,
    editor_id: &str,
    org_id: Option<&str>,
) {
    let path = identity_path(provider_dir);
    let mut identity = read(&path).unwrap_or_default();

    if !already_aliased(&identity, machine_id, editor_id) {
        handle.capture(Event::create_alias(machine_id, editor_id));
        identity.aliased_pairs.push(AliasedPair {
            machine_id: machine_id.into(),
            editor_id: editor_id.into(),
        });
    }

    identity.editor_id = Some(editor_id.into());
    if let Some(o) = org_id {
        identity.org_id = Some(o.into());
    }

    let _ = write(&path, &identity);
}

/// Distinct-id resolver used at process startup: prefer `editor_id` from
/// the persisted identity file, fall back to `machine_id`.
pub fn resolve_distinct_id(provider_dir: &Path, machine_id: &str) -> String {
    read(&identity_path(provider_dir))
        .and_then(|i| i.editor_id)
        .unwrap_or_else(|| machine_id.to_string())
}

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

    #[test]
    fn already_aliased_returns_true_after_record() {
        let mut id = Identity::default();
        id.aliased_pairs.push(AliasedPair {
            machine_id: "mach_x".into(),
            editor_id: "edt_42".into(),
        });
        assert!(already_aliased(&id, "mach_x", "edt_42"));
        assert!(!already_aliased(&id, "mach_x", "edt_99"));
        assert!(!already_aliased(&id, "mach_y", "edt_42"));
    }

    #[test]
    fn write_then_read_roundtrip() {
        let tmp = TempDir::new().unwrap();
        let path = identity_path(tmp.path());
        let identity = Identity {
            editor_id: Some("edt_42".into()),
            org_id: Some("org_7".into()),
            aliased_pairs: vec![AliasedPair {
                machine_id: "mach_x".into(),
                editor_id: "edt_42".into(),
            }],
        };
        write(&path, &identity).unwrap();
        let loaded = read(&path).unwrap();
        assert_eq!(loaded, identity);
    }

    #[test]
    fn resolve_distinct_id_prefers_editor_id_when_present() {
        let tmp = TempDir::new().unwrap();
        let identity = Identity {
            editor_id: Some("edt_42".into()),
            ..Default::default()
        };
        write(&identity_path(tmp.path()), &identity).unwrap();
        assert_eq!(resolve_distinct_id(tmp.path(), "mach_x"), "edt_42");
    }

    #[test]
    fn resolve_distinct_id_falls_back_to_machine_id() {
        let tmp = TempDir::new().unwrap();
        assert_eq!(resolve_distinct_id(tmp.path(), "mach_x"), "mach_x");
    }

    #[test]
    fn record_auth_success_aliases_once_and_persists() {
        let tmp = TempDir::new().unwrap();
        let handle = TelemetryHandle::disabled("mach_x".into(), false);
        record_auth_success(&handle, tmp.path(), "mach_x", "edt_42", Some("org_7"));
        let loaded = read(&identity_path(tmp.path())).unwrap();
        assert_eq!(loaded.editor_id.as_deref(), Some("edt_42"));
        assert_eq!(loaded.org_id.as_deref(), Some("org_7"));
        assert_eq!(loaded.aliased_pairs.len(), 1);

        record_auth_success(&handle, tmp.path(), "mach_x", "edt_42", Some("org_7"));
        let loaded = read(&identity_path(tmp.path())).unwrap();
        assert_eq!(loaded.aliased_pairs.len(), 1);
    }
}