ccd-cli 1.0.0-alpha.8

Bootstrap and validate Continuous Context Development repositories
use std::fs;
use std::path::Path;

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};

use crate::db;
use crate::paths::state::StateLayout;
use crate::state::compiled::{self, CompiledStateStore, ProjectionDigests};
use crate::state::session as session_state;

const TOOL_SURFACE_FINGERPRINT_VARS: &[&str] = &[
    "CCD_TOOL_SURFACE_FINGERPRINT",
    "CCD_MCP_TOOL_SURFACE_FINGERPRINT",
];

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectionMetadataFile {
    pub schema_version: u32,
    #[serde(default)]
    pub observations: Vec<ProjectionObservation>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectionObservation {
    pub observed_at_epoch_s: u64,
    pub source_fingerprint: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub projection_digests: Option<ProjectionDigests>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tool_surface_fingerprint: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub session_id: Option<String>,
}

pub fn load_for_layout(layout: &StateLayout) -> Result<Option<ProjectionMetadataFile>> {
    let Some(db) = try_open_projection_db(layout)? else {
        return Ok(None);
    };
    let observations = db::projection::list(db.conn())?;
    if observations.is_empty() {
        return Ok(None);
    }
    Ok(Some(ProjectionMetadataFile {
        schema_version: 1,
        observations,
    }))
}

pub fn load_baseline_for_session(
    layout: &StateLayout,
    session_id: &str,
) -> Result<Option<ProjectionObservation>> {
    let Some(db) = try_open_projection_db(layout)? else {
        return Ok(None);
    };
    db::projection::find_baseline_for_session(db.conn(), session_id)
}

pub fn record_baseline(
    layout: &StateLayout,
    store: &CompiledStateStore,
    session_id: &str,
    extended_digests: &ProjectionDigests,
) -> Result<()> {
    let db = open_projection_db(layout)?;
    let observation = ProjectionObservation {
        observed_at_epoch_s: session_state::now_epoch_s()?,
        source_fingerprint: store.source_fingerprint.clone(),
        projection_digests: Some(extended_digests.clone()),
        tool_surface_fingerprint: current_tool_surface_fingerprint(),
        session_id: Some(session_id.to_owned()),
    };
    db::projection::record(db.conn(), &observation)?;
    Ok(())
}

pub fn record_for_compiled_store(layout: &StateLayout, store: &CompiledStateStore) -> Result<()> {
    let db = open_projection_db(layout)?;

    let observation = ProjectionObservation {
        observed_at_epoch_s: session_state::now_epoch_s()?,
        source_fingerprint: store.source_fingerprint.clone(),
        projection_digests: store
            .projection_digests
            .clone()
            .or_else(|| Some(compiled::compute_projection_digests(store))),
        tool_surface_fingerprint: current_tool_surface_fingerprint(),
        session_id: None,
    };

    db::projection::record(db.conn(), &observation)?;
    Ok(())
}

pub fn warn_record_error(layout: &StateLayout, error: &anyhow::Error) {
    eprintln!(
        "Warning: failed to record projection metadata at {}: {error:#}",
        layout.state_db_path().display()
    );
}

pub fn current_tool_surface_fingerprint() -> Option<String> {
    TOOL_SURFACE_FINGERPRINT_VARS.iter().find_map(|name| {
        std::env::var(name)
            .ok()
            .map(|value| value.trim().to_owned())
            .filter(|value| !value.is_empty())
    })
}

// --- DB helpers ---

/// Open the state DB for writing projection metadata.
/// Creates the DB if it does not exist.
fn open_projection_db(layout: &StateLayout) -> Result<db::StateDb> {
    let db = db::StateDb::open(&layout.state_db_path())?;
    migrate_projection_json(&db, layout)?;
    Ok(db)
}

/// Open the state DB only if it already exists or legacy JSON needs migration.
/// Returns `None` when neither is present, avoiding DB creation on read-only
/// clone-local trees that have no projection data.
fn try_open_projection_db(layout: &StateLayout) -> Result<Option<db::StateDb>> {
    if layout.state_db_path().exists() || layout.clone_projection_metadata_path().exists() {
        open_projection_db(layout).map(Some)
    } else {
        Ok(None)
    }
}

/// Import legacy `projection_metadata.json` into the DB if it still exists.
/// Idempotent: clears existing rows before re-insert, then renames the source
/// file to `.migrated` only after all inserts succeed.
fn migrate_projection_json(db: &db::StateDb, layout: &StateLayout) -> Result<()> {
    let path = layout.clone_projection_metadata_path();
    let contents = match fs::read_to_string(&path) {
        Ok(c) => c,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
        Err(e) => return Err(e).with_context(|| format!("failed to read {}", path.display())),
    };

    let metadata: ProjectionMetadataFile = serde_json::from_str(&contents)
        .with_context(|| format!("failed to parse {}", path.display()))?;

    // Only import if the DB has no observations yet — a surviving legacy file
    // after a partial rename must not overwrite newer DB-backed state.
    if db::projection::list(db.conn())?.is_empty() {
        // Wrap all inserts in a transaction so a crash mid-loop cannot
        // leave a partial import that the is_empty() guard later treats
        // as canonical, silently discarding the tail of the JSON file.
        db.conn().execute_batch("BEGIN")?;
        let result = (|| -> Result<()> {
            for obs in &metadata.observations {
                let digests_json = obs
                    .projection_digests
                    .as_ref()
                    .map(serde_json::to_string)
                    .transpose()?;

                db.conn().execute(
                    "INSERT INTO projection_metadata
                        (observed_at_epoch_s, source_fingerprint, projection_digests,
                         tool_surface_fingerprint)
                     VALUES (?1, ?2, ?3, ?4)",
                    rusqlite::params![
                        obs.observed_at_epoch_s,
                        obs.source_fingerprint,
                        digests_json,
                        obs.tool_surface_fingerprint,
                    ],
                )?;
            }

            db::projection::prune(db.conn(), 32)?;
            Ok(())
        })();
        match result {
            Ok(()) => db.conn().execute_batch("COMMIT")?,
            Err(e) => {
                let _ = db.conn().execute_batch("ROLLBACK");
                return Err(e);
            }
        }
    }

    let mut migrated = path.as_os_str().to_owned();
    migrated.push(".migrated");
    fs::rename(&path, Path::new(&migrated))
        .with_context(|| format!("failed to rename {} to .migrated", path.display()))?;
    Ok(())
}