csm-duckdb 0.3.8

Optional analytics, Parquet export, and SQL inspection for chaotic_semantic_memory
use crate::Analytics;
use crate::error::Result;
use serde::{Deserialize, Serialize};
use std::path::Path;

#[derive(Debug, Serialize, Deserialize, Default)]
pub struct IngestReport {
    pub concepts_loaded: usize,
    pub associations_loaded: usize,
    pub benchmarks_loaded: usize,
}

#[derive(Debug, Deserialize)]
struct ExportPayloadStub {
    pub concepts: Vec<serde_json::Value>,
    pub associations: Vec<(String, String, f64)>,
}

impl Analytics {
    pub fn load_export_json<P: AsRef<Path>>(&mut self, path: P) -> Result<IngestReport> {
        let file = std::fs::File::open(path)?;
        let payload: ExportPayloadStub = serde_json::from_reader(file)?;

        let tx = self.conn.transaction()?;

        let mut concepts_loaded = 0;
        for concept in &payload.concepts {
            let id = concept["id"].as_str().ok_or_else(|| {
                crate::error::AnalyticsError::InvalidInput("Missing concept id".to_string())
            })?;
            // text is optional or might be in metadata depending on version
            let text = concept["text"]
                .as_str()
                .or(concept["metadata"]["text"].as_str());
            let namespace = concept["namespace"].as_str().unwrap_or("default");
            let created_at_us = concept["created_at"].as_i64().unwrap_or(0);
            let updated_at_us = concept["modified_at"].as_i64().unwrap_or(created_at_us);
            let expires_at_us = concept["expires_at"].as_i64();
            let metadata_json = serde_json::to_string(&concept["metadata"])?;

            tx.execute(
                "INSERT OR REPLACE INTO concepts (id, text, namespace, created_at_us, updated_at_us, expires_at_us, metadata_json)
                 VALUES (?, ?, ?, ?, ?, ?, ?)",
                duckdb::params![id, text, namespace, created_at_us, updated_at_us, expires_at_us, metadata_json],
            )?;
            concepts_loaded += 1;
        }

        let mut associations_loaded = 0;
        for (src_id, dst_id, strength) in &payload.associations {
            tx.execute(
                "INSERT OR REPLACE INTO associations (src_id, dst_id, strength) VALUES (?, ?, ?)",
                duckdb::params![src_id, dst_id, strength],
            )?;
            associations_loaded += 1;
        }

        tx.commit()?;

        Ok(IngestReport {
            concepts_loaded,
            associations_loaded,
            ..Default::default()
        })
    }
}

#[cfg(test)]
mod tests {
    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
    use super::*;
    use crate::schema::SCHEMA_DDL;
    use duckdb::Connection;
    use std::io::Write;

    #[test]
    fn test_load_export_minimal() {
        let conn = Connection::open_in_memory().unwrap();
        conn.execute_batch(SCHEMA_DDL).unwrap();
        let mut analytics = Analytics { conn };

        let mut temp = ::tempfile::NamedTempFile::new().unwrap();
        temp.as_file_mut()
            .write_all(br#"{"concepts": [{"id": "t1", "metadata": {}}], "associations": []}"#)
            .unwrap();

        let report = analytics.load_export_json(temp.path()).unwrap();
        assert_eq!(report.concepts_loaded, 1);
    }

    #[test]
    fn test_load_export_invalid_json() {
        let conn = Connection::open_in_memory().unwrap();
        let mut analytics = Analytics { conn };
        let mut temp = ::tempfile::NamedTempFile::new().unwrap();
        temp.as_file_mut().write_all(b"invalid").unwrap();
        assert!(analytics.load_export_json(temp.path()).is_err());
    }
}