difflore-core 0.2.0

Core library for the difflore CLI — rule store, retrieval, MCP server, hooks, cloud sync. Not intended for direct use; depend on `difflore-cli` instead.
#![allow(clippy::unwrap_used, clippy::expect_used)]
#![allow(unsafe_code)]
//! End-to-end gate test for `DIFFLORE_CAPTURE=false` on the observations
//! outbox.
//!
//! When the env var is the literal `"false"`,
//! `ObservationEmitter::enqueue` must be a no-op — no row enters
//! `observation_events`. Both outbox queues (`cloud_outbox` and
//! `observation_events`) gate at enqueue so the `difflore cloud privacy`
//! promise holds.
//!
//! Lives in its own test binary because setting an env var would race
//! with parallel siblings; Cargo runs each `tests/*.rs` in a dedicated
//! process and this file holds exactly one test, so the mutation can't
//! leak.

use chrono::{TimeZone, Utc};
use difflore_core::cloud::capture::DIFFLORE_CAPTURE_ENV;
use difflore_core::cloud::observations::{ObservationEmitter, ObservationEvent};
use sqlx::Row;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use std::time::Duration;
use tempfile::TempDir;

async fn row_count(db_path: &std::path::Path) -> i64 {
    // Open a separate read-only connection to the same DB file, avoiding
    // the crate-internal emitter pool accessor.
    let opts = SqliteConnectOptions::new()
        .filename(db_path)
        .create_if_missing(false)
        .busy_timeout(Duration::from_secs(2));
    let pool = SqlitePoolOptions::new()
        .max_connections(1)
        .connect_with(opts)
        .await
        .expect("open observation_events for read");
    let row = sqlx::query("SELECT COUNT(*) FROM observation_events")
        .fetch_one(&pool)
        .await
        .expect("count observation_events");
    row.get::<i64, _>(0)
}

#[tokio::test]
async fn enqueue_is_noop_when_capture_disabled() {
    let tmp = TempDir::new().expect("tmp dir");
    let db_path = tmp.path().join("observations_outbox.db");
    let emitter = ObservationEmitter::open_at(&db_path)
        .await
        .expect("open observations outbox");

    let event = ObservationEvent::RuleFired {
        rule_ids: vec![String::from("rule-test-1")],
        file_path: Some(String::from("src/main.rs")),
        intent: Some(String::from("test capture gate")),
        session_id: String::from("session-test"),
        fired_at: Utc.timestamp_opt(1_700_000_000, 0).unwrap(),
    };

    // SAFETY: this integration test binary contains exactly one test,
    // so no sibling test can observe the mutated env. The `remove_var`
    // at the end restores process state for downstream Drop logic.
    unsafe {
        std::env::set_var(DIFFLORE_CAPTURE_ENV, "false");
    }

    let skipped_id = emitter.enqueue(&event).await.expect("enqueue (skipped)");
    let count_after_skip = row_count(&db_path).await;

    assert_eq!(
        skipped_id, 0,
        "enqueue must return sentinel 0 when capture is disabled",
    );
    assert_eq!(
        count_after_skip, 0,
        "no row may enter observation_events when capture is disabled",
    );

    // SAFETY: same single-test isolation as above.
    unsafe {
        std::env::remove_var(DIFFLORE_CAPTURE_ENV);
    }

    // With the gate cleared, the same emitter inserts normally —
    // confirms the gate is the only thing that suppressed the insert.
    let real_id = emitter.enqueue(&event).await.expect("enqueue (enabled)");
    let count_after_enabled = row_count(&db_path).await;

    assert!(real_id > 0, "enqueue must insert when capture is enabled");
    assert_eq!(
        count_after_enabled, 1,
        "exactly one row inserted once the gate is cleared",
    );
}