lincli 2026.4.21

Fast, agent-friendly Linear CLI — manage issues, projects, cycles from the terminal
use std::fs;
use std::io::Write;
use std::path::Path;
use std::time::Duration;

/// Write-only PostHog project token. Safe to embed — cannot read data, only send events.
/// Alternative: use compile-time env var instead:
///   const POSTHOG_TOKEN: &str = env!("LIN_POSTHOG_TOKEN");
/// This requires LIN_POSTHOG_TOKEN set at build time (add to CI secrets).
const POSTHOG_TOKEN: &str = "phc_3DIgL4ES4ukoFmH4hgg3jR0e6O52PiQIfzfsVEjJu9u";

const POSTHOG_BATCH_URL: &str = "https://app.posthog.com/batch/";

/// Check if analytics are enabled. Checks in order:
/// 1. DO_NOT_TRACK=1 env var → disabled
/// 2. Config analytics_enabled == Some(false) → disabled
/// 3. Otherwise → enabled
pub fn is_enabled() -> bool {
    if std::env::var("DO_NOT_TRACK").ok().as_deref() == Some("1") {
        return false;
    }
    crate::config::is_analytics_enabled()
}

pub struct Event {
    pub command: String,
    pub flags: Vec<String>,
    pub success: bool,
    pub duration_ms: u64,
}

/// Track a command execution event. Appends to the local queue file.
/// Prints first-run notice to stderr if this is the first invocation.
pub fn track(event: &Event) {
    if !is_enabled() {
        return;
    }
    let Some(dir) = crate::config::config_dir() else {
        return;
    };
    if track_to_dir(&dir, event) {
        eprintln!("lin: anonymous usage stats enabled. Disable: lin config analytics off");
    }
}

/// Write event to queue file in the given directory. Returns true if first run.
fn track_to_dir(dir: &Path, event: &Event) -> bool {
    let Some((install_id, first_run)) = get_or_create_install_id_in(dir) else {
        return false;
    };

    let payload = serde_json::json!({
        "event": "command_executed",
        "distinct_id": install_id,
        "properties": {
            "command": event.command,
            "flags": event.flags,
            "success": event.success,
            "duration_ms": event.duration_ms,
            "version": env!("CARGO_PKG_VERSION"),
            "os": std::env::consts::OS,
            "arch": std::env::consts::ARCH,
        }
    });

    let queue_path = dir.join("analytics_queue.jsonl");
    let Ok(line) = serde_json::to_string(&payload) else {
        return false;
    };

    let Ok(mut file) = fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(&queue_path)
    else {
        return false;
    };
    let _ = writeln!(file, "{line}");
    first_run
}

/// Flush pending analytics events to PostHog.
pub async fn flush() {
    let Some(dir) = crate::config::config_dir() else {
        return;
    };
    flush_dir(&dir, POSTHOG_BATCH_URL).await;
}

/// Flush events from a specific directory to a specific URL. Testable.
async fn flush_dir(dir: &Path, url: &str) {
    let queue_path = dir.join("analytics_queue.jsonl");

    let contents = match fs::read_to_string(&queue_path) {
        Ok(c) if !c.trim().is_empty() => c,
        _ => return,
    };

    let events: Vec<serde_json::Value> = contents
        .lines()
        .filter_map(|line| serde_json::from_str(line).ok())
        .collect();

    if events.is_empty() {
        return;
    }

    let batch_payload = serde_json::json!({
        "api_key": POSTHOG_TOKEN,
        "batch": events,
    });

    let client = match reqwest::Client::builder()
        .timeout(Duration::from_secs(5))
        .build()
    {
        Ok(c) => c,
        Err(_) => return,
    };

    let response = client.post(url).json(&batch_payload).send().await;

    if let Ok(resp) = response
        && resp.status().is_success()
    {
        let _ = fs::write(&queue_path, "");
    }
}

/// Get or create install ID in a specific directory. Testable.
fn get_or_create_install_id_in(dir: &Path) -> Option<(String, bool)> {
    let path = dir.join("analytics_id");
    if let Ok(id) = fs::read_to_string(&path) {
        let id = id.trim().to_string();
        if !id.is_empty() {
            return Some((id, false));
        }
    }
    let id = uuid::Uuid::new_v4().to_string();
    fs::create_dir_all(dir).ok()?;
    fs::write(&path, &id).ok()?;
    Some((id, true))
}

/// Extract the top-level command name from a Commands variant.
pub fn command_name(cmd: &crate::cli::Commands) -> &'static str {
    use crate::cli::Commands;
    match cmd {
        Commands::Api(_) => "api",
        Commands::Auth(_) => "auth",
        Commands::Issues(_) => "issues",
        Commands::Projects(_) => "projects",
        Commands::Cycles(_) => "cycles",
        Commands::Initiatives(_) => "initiatives",
        Commands::Roadmap(_) => "roadmap",
        Commands::Labels(_) => "labels",
        Commands::Teams(_) => "teams",
        Commands::Relations(_) => "relations",
        Commands::Customers(_) => "customers",
        Commands::Views(_) => "views",
        Commands::Docs(_) => "docs",
        Commands::Notifications(_) => "notifications",
        Commands::Me(_) => "me",
        Commands::Attachments(_) => "attachments",
        Commands::Search(_) => "search",
        Commands::Config(_) => "config",
        Commands::Completions { .. } => "completions",
    }
}

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

    #[test]
    fn test_is_enabled_default() {
        temp_env::with_var_unset("DO_NOT_TRACK", || {
            // With no config file and no env var, should default to enabled
            assert!(is_enabled());
        });
    }

    #[test]
    fn test_is_enabled_do_not_track() {
        temp_env::with_var("DO_NOT_TRACK", Some("1"), || {
            assert!(!is_enabled());
        });
    }

    #[test]
    fn test_is_enabled_do_not_track_other_values() {
        temp_env::with_var("DO_NOT_TRACK", Some("0"), || {
            assert!(is_enabled());
        });
        temp_env::with_var("DO_NOT_TRACK", Some("true"), || {
            assert!(is_enabled());
        });
    }

    #[test]
    fn test_install_id_created_on_first_run() {
        let dir = std::env::temp_dir().join(format!("lin-test-{}", uuid::Uuid::new_v4()));
        let _ = fs::create_dir_all(&dir);

        let (id, first_run) = get_or_create_install_id_in(&dir).unwrap();
        assert!(first_run);
        assert_eq!(id.len(), 36); // UUID format

        // File should exist now
        let stored = fs::read_to_string(dir.join("analytics_id")).unwrap();
        assert_eq!(stored, id);

        let _ = fs::remove_dir_all(&dir);
    }

    #[test]
    fn test_install_id_loaded_on_second_run() {
        let dir = std::env::temp_dir().join(format!("lin-test-{}", uuid::Uuid::new_v4()));
        let _ = fs::create_dir_all(&dir);

        let (id1, first) = get_or_create_install_id_in(&dir).unwrap();
        assert!(first);

        let (id2, second) = get_or_create_install_id_in(&dir).unwrap();
        assert!(!second);
        assert_eq!(id1, id2);

        let _ = fs::remove_dir_all(&dir);
    }

    #[test]
    fn test_track_writes_to_queue() {
        let dir = std::env::temp_dir().join(format!("lin-test-{}", uuid::Uuid::new_v4()));
        let _ = fs::create_dir_all(&dir);

        let event = Event {
            command: "issues list".to_string(),
            flags: vec!["--json".to_string()],
            success: true,
            duration_ms: 150,
        };

        let first_run = track_to_dir(&dir, &event);
        assert!(first_run);

        let queue = fs::read_to_string(dir.join("analytics_queue.jsonl")).unwrap();
        let parsed: serde_json::Value = serde_json::from_str(queue.trim()).unwrap();
        assert_eq!(parsed["event"], "command_executed");
        assert_eq!(parsed["properties"]["command"], "issues list");
        assert_eq!(parsed["properties"]["flags"][0], "--json");
        assert_eq!(parsed["properties"]["success"], true);
        assert_eq!(parsed["properties"]["duration_ms"], 150);
        assert!(parsed["properties"]["version"].is_string());
        assert!(parsed["properties"]["os"].is_string());
        assert!(parsed["properties"]["arch"].is_string());

        let _ = fs::remove_dir_all(&dir);
    }

    #[test]
    fn test_track_appends_multiple_events() {
        let dir = std::env::temp_dir().join(format!("lin-test-{}", uuid::Uuid::new_v4()));
        let _ = fs::create_dir_all(&dir);

        for i in 0..3 {
            let event = Event {
                command: format!("command {i}"),
                flags: vec![],
                success: true,
                duration_ms: i * 100,
            };
            track_to_dir(&dir, &event);
        }

        let queue = fs::read_to_string(dir.join("analytics_queue.jsonl")).unwrap();
        let lines: Vec<&str> = queue.trim().lines().collect();
        assert_eq!(lines.len(), 3);

        // All lines should be valid JSON with the same distinct_id
        let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
        let third: serde_json::Value = serde_json::from_str(lines[2]).unwrap();
        assert_eq!(first["distinct_id"], third["distinct_id"]);

        let _ = fs::remove_dir_all(&dir);
    }

    #[tokio::test]
    async fn test_flush_sends_and_clears_queue() {
        use wiremock::{Mock, MockServer, ResponseTemplate, matchers};

        let server = MockServer::start().await;
        Mock::given(matchers::method("POST"))
            .and(matchers::path("/batch/"))
            .respond_with(ResponseTemplate::new(200))
            .expect(1)
            .mount(&server)
            .await;

        let dir = std::env::temp_dir().join(format!("lin-test-{}", uuid::Uuid::new_v4()));
        let _ = fs::create_dir_all(&dir);

        // Write some events to the queue
        let event = Event {
            command: "issues list".to_string(),
            flags: vec![],
            success: true,
            duration_ms: 100,
        };
        track_to_dir(&dir, &event);
        track_to_dir(&dir, &event);

        let queue_path = dir.join("analytics_queue.jsonl");
        assert!(fs::read_to_string(&queue_path).unwrap().lines().count() == 2);

        // Flush to the mock server
        let url = format!("{}/batch/", server.uri());
        flush_dir(&dir, &url).await;

        // Queue should be empty after successful flush
        let after = fs::read_to_string(&queue_path).unwrap();
        assert!(after.is_empty());

        let _ = fs::remove_dir_all(&dir);
    }

    #[tokio::test]
    async fn test_flush_keeps_queue_on_failure() {
        use wiremock::{Mock, MockServer, ResponseTemplate, matchers};

        let server = MockServer::start().await;
        Mock::given(matchers::method("POST"))
            .and(matchers::path("/batch/"))
            .respond_with(ResponseTemplate::new(500))
            .expect(1)
            .mount(&server)
            .await;

        let dir = std::env::temp_dir().join(format!("lin-test-{}", uuid::Uuid::new_v4()));
        let _ = fs::create_dir_all(&dir);

        let event = Event {
            command: "teams list".to_string(),
            flags: vec![],
            success: true,
            duration_ms: 50,
        };
        track_to_dir(&dir, &event);

        let queue_path = dir.join("analytics_queue.jsonl");
        let before = fs::read_to_string(&queue_path).unwrap();

        let url = format!("{}/batch/", server.uri());
        flush_dir(&dir, &url).await;

        // Queue should still have the event
        let after = fs::read_to_string(&queue_path).unwrap();
        assert_eq!(before, after);

        let _ = fs::remove_dir_all(&dir);
    }

    #[tokio::test]
    async fn test_flush_noop_on_empty_queue() {
        let dir = std::env::temp_dir().join(format!("lin-test-{}", uuid::Uuid::new_v4()));
        let _ = fs::create_dir_all(&dir);

        // No queue file exists — flush should silently return
        flush_dir(&dir, "http://localhost:1/batch/").await;

        // No crash, no file created
        assert!(!dir.join("analytics_queue.jsonl").exists());

        let _ = fs::remove_dir_all(&dir);
    }
}