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())
})
}
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)
}
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)
}
}
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()))?;
if db::projection::list(db.conn())?.is_empty() {
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(())
}