csm-duckdb 0.3.8

Optional analytics, Parquet export, and SQL inspection for chaotic_semantic_memory
use crate::Analytics;
use crate::error::Result;
use crate::ingest_export::IngestReport;
use std::path::Path;

use crate::connection::validate_analytics_path;

impl Analytics {
    pub fn attach_libsql<P: AsRef<Path>>(&mut self, path: P) -> Result<IngestReport> {
        validate_analytics_path(path.as_ref())?;

        let path_str = path.as_ref().to_str().ok_or_else(|| {
            crate::error::AnalyticsError::InvalidInput("Invalid path for libsql file".to_string())
        })?;

        self.conn.execute(
            &format!("ATTACH '{path_str}' AS csm_src (TYPE SQLITE, READ_ONLY)"),
            [],
        )?;

        // Copy concepts
        let concepts_loaded = match self.conn.execute(
            "INSERT OR REPLACE INTO concepts (id, namespace, created_at_us, updated_at_us, expires_at_us, metadata_json)
             SELECT id, 'default', created_at, modified_at, expires_at, metadata FROM csm_src.concepts",
            [],
        ) {
            Ok(n) => n,
            Err(e) => {
                let _ = self.conn.execute("DETACH csm_src", []);
                return Err(e.into());
            }
        };

        // Copy associations
        let associations_loaded = match self.conn.execute(
            "INSERT OR REPLACE INTO associations (src_id, dst_id, strength)
             SELECT from_id, to_id, strength FROM csm_src.associations",
            [],
        ) {
            Ok(n) => n,
            Err(e) => {
                let _ = self.conn.execute("DETACH csm_src", []);
                return Err(e.into());
            }
        };

        self.conn.execute("DETACH csm_src", [])?;

        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;

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

        let res = analytics.attach_libsql("nonexistent.db");
        assert!(res.is_err());
    }

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

        // Semicolon
        let res = analytics.attach_libsql("test;db");
        assert!(matches!(
            res,
            Err(crate::error::AnalyticsError::InvalidInput(_))
        ));

        // Single quote
        let res = analytics.attach_libsql("test'db");
        assert!(matches!(
            res,
            Err(crate::error::AnalyticsError::InvalidInput(_))
        ));

        // Double quote
        let res = analytics.attach_libsql("test\"db");
        assert!(matches!(
            res,
            Err(crate::error::AnalyticsError::InvalidInput(_))
        ));

        // Path traversal
        let res = analytics.attach_libsql("../test.db");
        assert!(matches!(
            res,
            Err(crate::error::AnalyticsError::InvalidInput(_))
        ));

        // Control characters (like newline)
        let res = analytics.attach_libsql("test\ndb");
        assert!(matches!(
            res,
            Err(crate::error::AnalyticsError::InvalidInput(_))
        ));

        // Null bytes
        let res = analytics.attach_libsql("test\0db");
        assert!(matches!(
            res,
            Err(crate::error::AnalyticsError::InvalidInput(_))
        ));
    }
}