ospipe 0.1.0

OSpipe: RuVector-enhanced personal AI memory system integrating with Screenpipe
Documentation
//! JSON-file persistence layer for OSpipe data.
//!
//! Provides durable storage of frames, configuration, and embedding data
//! using the local filesystem. All data is serialized to JSON (frames and
//! config) or raw bytes (embeddings) inside a configurable data directory.
//!
//! This module is gated behind `cfg(not(target_arch = "wasm32"))` because
//! WASM targets do not have filesystem access.

use crate::capture::CapturedFrame;
use crate::config::OsPipeConfig;
use crate::error::{OsPipeError, Result};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;

/// A serializable wrapper around [`CapturedFrame`] for disk persistence.
///
/// This mirrors all fields of `CapturedFrame` but is kept as a distinct
/// type so the persistence format can evolve independently of the
/// in-memory representation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredFrame {
    /// The captured frame data.
    pub frame: CapturedFrame,
    /// Optional text that was stored after safety-gate processing.
    /// If `None`, the original frame text was used unchanged.
    pub safe_text: Option<String>,
}

/// Filesystem-backed persistence for OSpipe data.
///
/// All files are written inside `data_dir`:
/// - `frames.json` - serialized vector of [`StoredFrame`]
/// - `config.json` - serialized [`OsPipeConfig`]
/// - `embeddings.bin` - raw bytes (e.g. HNSW index serialization)
pub struct PersistenceLayer {
    data_dir: PathBuf,
}

impl PersistenceLayer {
    /// Create a new persistence layer rooted at `data_dir`.
    ///
    /// The directory (and any missing parents) will be created if they
    /// do not already exist.
    pub fn new(data_dir: PathBuf) -> Result<Self> {
        std::fs::create_dir_all(&data_dir).map_err(|e| {
            OsPipeError::Storage(format!(
                "Failed to create data directory {}: {}",
                data_dir.display(),
                e
            ))
        })?;
        Ok(Self { data_dir })
    }

    /// Return the path to a named file inside the data directory.
    fn file_path(&self, name: &str) -> PathBuf {
        self.data_dir.join(name)
    }

    // ---- Frames ----

    /// Persist a slice of stored frames to `frames.json`.
    pub fn save_frames(&self, frames: &[StoredFrame]) -> Result<()> {
        let path = self.file_path("frames.json");
        let json = serde_json::to_string_pretty(frames)?;
        std::fs::write(&path, json).map_err(|e| {
            OsPipeError::Storage(format!(
                "Failed to write frames to {}: {}",
                path.display(),
                e
            ))
        })
    }

    /// Load stored frames from `frames.json`.
    ///
    /// Returns an empty vector if the file does not exist.
    pub fn load_frames(&self) -> Result<Vec<StoredFrame>> {
        let path = self.file_path("frames.json");
        if !path.exists() {
            return Ok(Vec::new());
        }
        let data = std::fs::read_to_string(&path).map_err(|e| {
            OsPipeError::Storage(format!(
                "Failed to read frames from {}: {}",
                path.display(),
                e
            ))
        })?;
        let frames: Vec<StoredFrame> = serde_json::from_str(&data)?;
        Ok(frames)
    }

    // ---- Config ----

    /// Persist the pipeline configuration to `config.json`.
    pub fn save_config(&self, config: &OsPipeConfig) -> Result<()> {
        let path = self.file_path("config.json");
        let json = serde_json::to_string_pretty(config)?;
        std::fs::write(&path, json).map_err(|e| {
            OsPipeError::Storage(format!(
                "Failed to write config to {}: {}",
                path.display(),
                e
            ))
        })
    }

    /// Load the pipeline configuration from `config.json`.
    ///
    /// Returns `None` if the file does not exist.
    pub fn load_config(&self) -> Result<Option<OsPipeConfig>> {
        let path = self.file_path("config.json");
        if !path.exists() {
            return Ok(None);
        }
        let data = std::fs::read_to_string(&path).map_err(|e| {
            OsPipeError::Storage(format!(
                "Failed to read config from {}: {}",
                path.display(),
                e
            ))
        })?;
        let config: OsPipeConfig = serde_json::from_str(&data)?;
        Ok(Some(config))
    }

    // ---- Embeddings (raw bytes) ----

    /// Persist raw embedding bytes to `embeddings.bin`.
    ///
    /// This is intended for serializing an HNSW index or other binary
    /// data that does not fit the JSON format.
    pub fn save_embeddings(&self, data: &[u8]) -> Result<()> {
        let path = self.file_path("embeddings.bin");
        std::fs::write(&path, data).map_err(|e| {
            OsPipeError::Storage(format!(
                "Failed to write embeddings to {}: {}",
                path.display(),
                e
            ))
        })
    }

    /// Load raw embedding bytes from `embeddings.bin`.
    ///
    /// Returns `None` if the file does not exist.
    pub fn load_embeddings(&self) -> Result<Option<Vec<u8>>> {
        let path = self.file_path("embeddings.bin");
        if !path.exists() {
            return Ok(None);
        }
        let data = std::fs::read(&path).map_err(|e| {
            OsPipeError::Storage(format!(
                "Failed to read embeddings from {}: {}",
                path.display(),
                e
            ))
        })?;
        Ok(Some(data))
    }

    /// Return the data directory path.
    pub fn data_dir(&self) -> &PathBuf {
        &self.data_dir
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::capture::CapturedFrame;

    fn temp_dir() -> PathBuf {
        let dir = std::env::temp_dir().join(format!("ospipe_test_{}", uuid::Uuid::new_v4()));
        std::fs::create_dir_all(&dir).unwrap();
        dir
    }

    #[test]
    fn test_frames_roundtrip() {
        let dir = temp_dir();
        let layer = PersistenceLayer::new(dir.clone()).unwrap();

        let frame = CapturedFrame::new_screen("VSCode", "main.rs", "fn main() {}", 0);
        let stored = vec![StoredFrame {
            frame,
            safe_text: None,
        }];

        layer.save_frames(&stored).unwrap();
        let loaded = layer.load_frames().unwrap();

        assert_eq!(loaded.len(), 1);
        assert_eq!(loaded[0].frame.text_content(), "fn main() {}");
        assert!(loaded[0].safe_text.is_none());

        // Cleanup
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn test_frames_empty_when_missing() {
        let dir = temp_dir();
        let layer = PersistenceLayer::new(dir.clone()).unwrap();

        let loaded = layer.load_frames().unwrap();
        assert!(loaded.is_empty());

        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn test_config_roundtrip() {
        let dir = temp_dir();
        let layer = PersistenceLayer::new(dir.clone()).unwrap();

        let config = OsPipeConfig::default();
        layer.save_config(&config).unwrap();

        let loaded = layer.load_config().unwrap();
        assert!(loaded.is_some());
        let loaded = loaded.unwrap();
        assert_eq!(loaded.storage.embedding_dim, 384);
        assert_eq!(loaded.capture.fps, 1.0);

        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn test_config_none_when_missing() {
        let dir = temp_dir();
        let layer = PersistenceLayer::new(dir.clone()).unwrap();

        let loaded = layer.load_config().unwrap();
        assert!(loaded.is_none());

        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn test_embeddings_roundtrip() {
        let dir = temp_dir();
        let layer = PersistenceLayer::new(dir.clone()).unwrap();

        let data: Vec<u8> = vec![0xDE, 0xAD, 0xBE, 0xEF, 1, 2, 3, 4];
        layer.save_embeddings(&data).unwrap();

        let loaded = layer.load_embeddings().unwrap();
        assert!(loaded.is_some());
        assert_eq!(loaded.unwrap(), data);

        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn test_embeddings_none_when_missing() {
        let dir = temp_dir();
        let layer = PersistenceLayer::new(dir.clone()).unwrap();

        let loaded = layer.load_embeddings().unwrap();
        assert!(loaded.is_none());

        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn test_creates_directory_if_missing() {
        let dir = std::env::temp_dir()
            .join(format!("ospipe_test_{}", uuid::Uuid::new_v4()))
            .join("nested")
            .join("deep");
        assert!(!dir.exists());

        let layer = PersistenceLayer::new(dir.clone());
        assert!(layer.is_ok());
        assert!(dir.exists());

        let _ = std::fs::remove_dir_all(dir.parent().unwrap().parent().unwrap());
    }

    #[test]
    fn test_multiple_frames_roundtrip() {
        let dir = temp_dir();
        let layer = PersistenceLayer::new(dir.clone()).unwrap();

        let frames: Vec<StoredFrame> = (0..5)
            .map(|i| StoredFrame {
                frame: CapturedFrame::new_screen(
                    "App",
                    &format!("Window {}", i),
                    &format!("Content {}", i),
                    0,
                ),
                safe_text: if i % 2 == 0 {
                    Some(format!("Redacted {}", i))
                } else {
                    None
                },
            })
            .collect();

        layer.save_frames(&frames).unwrap();
        let loaded = layer.load_frames().unwrap();

        assert_eq!(loaded.len(), 5);
        for (i, sf) in loaded.iter().enumerate() {
            assert_eq!(sf.frame.text_content(), &format!("Content {}", i));
            if i % 2 == 0 {
                assert_eq!(sf.safe_text, Some(format!("Redacted {}", i)));
            } else {
                assert!(sf.safe_text.is_none());
            }
        }

        let _ = std::fs::remove_dir_all(&dir);
    }
}