sara-tasks 1.5.0

Sara — folder-aware task manager
use crate::infrastructure::config::{self, Config};
use serde::Serialize;
use std::io::Write;
use std::path::{Path, PathBuf};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Source {
    Cli,
    Mcp,
}

impl Source {
    fn as_str(self) -> &'static str {
        match self {
            Source::Cli => "cli",
            Source::Mcp => "mcp",
        }
    }
}

#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct TelemetryRecord {
    pub install_id: String,
    pub ts: String,
    pub source: &'static str,
    pub name: String,
    pub duration_ms: u64,
    pub ok: bool,
    pub err_code: Option<&'static str>,
    pub version: &'static str,
    pub os: &'static str,
    pub arch: &'static str,
}

pub fn err_code(err: &anyhow::Error) -> &'static str {
    for cause in err.chain() {
        if cause.downcast_ref::<std::io::Error>().is_some() {
            return "io";
        }
        if cause.downcast_ref::<rusqlite::Error>().is_some() {
            return "db";
        }
        if cause.downcast_ref::<toml::de::Error>().is_some()
            || cause.downcast_ref::<toml::ser::Error>().is_some()
        {
            return "config";
        }
        if cause.downcast_ref::<serde_json::Error>().is_some() {
            return "serde";
        }
    }
    "other"
}

pub fn build_record<T>(
    install_id: &str,
    source: Source,
    name: &str,
    duration_ms: u64,
    result: &anyhow::Result<T>,
) -> TelemetryRecord {
    TelemetryRecord {
        install_id: install_id.to_string(),
        ts: chrono::Utc::now().to_rfc3339(),
        source: source.as_str(),
        name: name.to_string(),
        duration_ms,
        ok: result.is_ok(),
        err_code: result.as_ref().err().map(err_code),
        version: env!("CARGO_PKG_VERSION"),
        os: std::env::consts::OS,
        arch: std::env::consts::ARCH,
    }
}

pub fn append(queue_path: &Path, rec: &TelemetryRecord) -> std::io::Result<()> {
    if let Some(parent) = queue_path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let mut line = serde_json::to_string(rec).map_err(std::io::Error::other)?;
    line.push('\n');
    let mut f = std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(queue_path)?;
    f.write_all(line.as_bytes())
}

pub fn queue_path() -> anyhow::Result<PathBuf> {
    if let Ok(p) = std::env::var("SARA_TELEMETRY_QUEUE")
        && !p.is_empty()
    {
        return Ok(PathBuf::from(p));
    }
    let dirs = config::project_dirs()?;
    Ok(dirs.data_dir().join("telemetry-queue.jsonl"))
}

fn install_id_path() -> anyhow::Result<PathBuf> {
    let dirs = config::project_dirs()?;
    Ok(dirs.data_dir().join("install_id"))
}

pub fn install_id() -> String {
    match install_id_path() {
        Ok(path) => {
            if let Ok(existing) = std::fs::read_to_string(&path) {
                let trimmed = existing.trim();
                if !trimmed.is_empty() {
                    return trimmed.to_string();
                }
            }
            let id = uuid::Uuid::new_v4().to_string();
            if let Some(parent) = path.parent() {
                let _ = std::fs::create_dir_all(parent);
            }
            let _ = std::fs::write(&path, &id);
            id
        }
        Err(_) => uuid::Uuid::new_v4().to_string(),
    }
}

pub fn enabled(cfg: &Config) -> bool {
    if let Ok(v) = std::env::var("SARA_NO_TELEMETRY")
        && !v.is_empty()
    {
        return false;
    }
    cfg.telemetry.enabled
}

fn notice_marker_path() -> anyhow::Result<PathBuf> {
    let dirs = config::project_dirs()?;
    Ok(dirs.data_dir().join("telemetry_notice_shown"))
}

pub fn maybe_show_notice(cfg: &Config) {
    if !enabled(cfg) {
        return;
    }
    let Ok(marker) = notice_marker_path() else {
        return;
    };
    if marker.exists() {
        return;
    }
    eprintln!(
        "sara records anonymous local usage telemetry (command name, duration, \
         ok/error — never arguments, paths or content). Inspect it with \
         `sara telemetry --show`; disable with `sara telemetry off` or \
         SARA_NO_TELEMETRY=1."
    );
    if let Some(parent) = marker.parent() {
        let _ = std::fs::create_dir_all(parent);
    }
    let _ = std::fs::write(&marker, b"1");
}

pub fn read_queue() -> anyhow::Result<Vec<serde_json::Value>> {
    let path = queue_path()?;
    let text = match std::fs::read_to_string(&path) {
        Ok(t) => t,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
        Err(e) => return Err(e.into()),
    };
    let mut out = Vec::new();
    for line in text.lines() {
        if line.trim().is_empty() {
            continue;
        }
        out.push(serde_json::from_str(line)?);
    }
    Ok(out)
}

pub fn capture<T>(
    cfg: &Config,
    source: Source,
    name: &str,
    duration_ms: u64,
    result: &anyhow::Result<T>,
) {
    if !enabled(cfg) {
        return;
    }
    let Ok(path) = queue_path() else {
        return;
    };
    let rec = build_record(&install_id(), source, name, duration_ms, result);
    let _ = append(&path, &rec);
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::{Mutex, MutexGuard, OnceLock};

    static TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
    fn lock() -> MutexGuard<'static, ()> {
        TEST_LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
    }

    fn temp_queue(name: &str) -> PathBuf {
        let p = std::env::temp_dir().join(format!(
            "sara-tel-{name}-{}-{:?}.jsonl",
            std::process::id(),
            std::thread::current().id()
        ));
        let _ = std::fs::remove_file(&p);
        p
    }

    fn read_lines(path: &Path) -> Vec<serde_json::Value> {
        let text = std::fs::read_to_string(path).unwrap_or_default();
        text.lines()
            .filter(|l| !l.trim().is_empty())
            .map(|l| serde_json::from_str(l).unwrap())
            .collect()
    }

    #[test]
    fn records_cli_invocation() {
        let _g = lock();
        let path = temp_queue("cli");
        let rec = build_record("iid-1", Source::Cli, "recall", 12, &Ok(()));
        append(&path, &rec).unwrap();

        let rows = read_lines(&path);
        assert_eq!(rows.len(), 1, "exactly one record appended");
        let r = &rows[0];
        assert_eq!(r["source"], "cli");
        assert_eq!(r["name"], "recall");
        assert_eq!(r["ok"], true);
        assert_eq!(r["duration_ms"], 12);
        assert_eq!(r["install_id"], "iid-1");
        assert!(r["err_code"].is_null());
        let keys: std::collections::BTreeSet<&str> =
            r.as_object().unwrap().keys().map(String::as_str).collect();
        let expected: std::collections::BTreeSet<&str> = [
            "install_id",
            "ts",
            "source",
            "name",
            "duration_ms",
            "ok",
            "err_code",
            "version",
            "os",
            "arch",
        ]
        .into_iter()
        .collect();
        assert_eq!(keys, expected, "payload is exactly the allowlist, no leaks");

        append(
            &path,
            &build_record("iid-1", Source::Cli, "list", 3, &Ok(())),
        )
        .unwrap();
        assert_eq!(read_lines(&path).len(), 2);
        let _ = std::fs::remove_file(&path);
    }

    #[test]
    fn records_mcp_invocation() {
        let _g = lock();
        let path = temp_queue("mcp");
        let rec = build_record("iid-2", Source::Mcp, "mcp add", 7, &Ok(()));
        append(&path, &rec).unwrap();

        let rows = read_lines(&path);
        assert_eq!(rows.len(), 1);
        assert_eq!(rows[0]["source"], "mcp");
        assert_eq!(rows[0]["name"], "mcp add");
        let _ = std::fs::remove_file(&path);
    }

    #[test]
    fn err_code_is_type_derived() {
        let _g = lock();

        let io_err: anyhow::Error = anyhow::Error::new(std::io::Error::new(
            std::io::ErrorKind::NotFound,
            "/Users/secret/tasks.db missing",
        ))
        .context("Failed to write /Users/secret/queue.jsonl");
        assert_eq!(err_code(&io_err), "io");

        let db_err: anyhow::Error =
            anyhow::Error::new(rusqlite::Error::QueryReturnedNoRows).context("loading task");
        assert_eq!(err_code(&db_err), "db");

        let serde_err: anyhow::Error =
            anyhow::Error::new(serde_json::from_str::<i32>("nope").unwrap_err());
        assert_eq!(err_code(&serde_err), "serde");

        let other: anyhow::Error = anyhow::anyhow!("something bespoke");
        assert_eq!(err_code(&other), "other");

        let failed: anyhow::Result<()> = Err(io_err);
        let rec = build_record("iid", Source::Cli, "validate", 5, &failed);
        assert!(!rec.ok);
        assert_eq!(rec.err_code, Some("io"));
        let json = serde_json::to_string(&rec).unwrap();
        assert!(
            !json.contains("secret"),
            "record must not embed the error message"
        );
    }

    #[test]
    fn disabled_writes_nothing() {
        let _g = lock();
        let path = temp_queue("disabled");

        let mut cfg = Config::default();
        cfg.telemetry.enabled = true;

        unsafe {
            std::env::set_var("SARA_NO_TELEMETRY", "1");
            std::env::set_var("SARA_TELEMETRY_QUEUE", &path);
        }
        assert!(!enabled(&cfg));
        capture(&cfg, Source::Cli, "recall", 1, &Ok(()));
        assert!(
            !path.exists(),
            "no queue file created while disabled by env"
        );

        unsafe {
            std::env::remove_var("SARA_NO_TELEMETRY");
        }
        assert!(enabled(&cfg));
        capture(&cfg, Source::Cli, "recall", 1, &Ok(()));
        assert_eq!(read_lines(&path).len(), 1, "capture writes when enabled");

        cfg.telemetry.enabled = false;
        assert!(!enabled(&cfg));

        unsafe {
            std::env::remove_var("SARA_TELEMETRY_QUEUE");
        }
        let _ = std::fs::remove_file(&path);
    }
}