remem-ai 0.6.50

Local-first coding agent memory for Claude Code and OpenAI Codex
Documentation
use std::fs;
use std::path::{Path, PathBuf};

use anyhow::Result;
use sha2::{Digest, Sha256};

use crate::rules::artifact::CompiledRulesArtifact;

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ArtifactLoad {
    Loaded(CompiledRulesArtifact),
    FailOpen {
        kind: ArtifactLoadErrorKind,
        message: String,
    },
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArtifactLoadErrorKind {
    Missing,
    Read,
    Parse,
    Validate,
}

pub fn artifact_path_for_project(data_dir: impl AsRef<Path>, project: &str) -> PathBuf {
    data_dir
        .as_ref()
        .join("compiled_rules")
        .join(format!("{}.json", project_hash(project)))
}

pub fn write_artifact_atomic(
    path: impl AsRef<Path>,
    artifact: &CompiledRulesArtifact,
) -> Result<()> {
    artifact.validate()?;
    let mut contents = serde_json::to_vec_pretty(artifact)?;
    contents.push(b'\n');
    crate::atomic_file::write_atomic(path, contents)
}

pub fn load_artifact_fail_open(path: impl AsRef<Path>) -> ArtifactLoad {
    let path = path.as_ref();
    let text = match fs::read_to_string(path) {
        Ok(text) => text,
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
            return ArtifactLoad::FailOpen {
                kind: ArtifactLoadErrorKind::Missing,
                message: format!("compiled rules artifact missing: {}", path.display()),
            };
        }
        Err(err) => {
            return ArtifactLoad::FailOpen {
                kind: ArtifactLoadErrorKind::Read,
                message: format!(
                    "read compiled rules artifact {} failed: {err}",
                    path.display()
                ),
            };
        }
    };

    let artifact = match serde_json::from_str::<CompiledRulesArtifact>(&text) {
        Ok(artifact) => artifact,
        Err(err) => {
            return ArtifactLoad::FailOpen {
                kind: ArtifactLoadErrorKind::Parse,
                message: format!(
                    "parse compiled rules artifact {} failed: {err}",
                    path.display()
                ),
            };
        }
    };

    match artifact.validate() {
        Ok(()) => ArtifactLoad::Loaded(artifact),
        Err(err) => ArtifactLoad::FailOpen {
            kind: ArtifactLoadErrorKind::Validate,
            message: format!(
                "validate compiled rules artifact {} failed: {err}",
                path.display()
            ),
        },
    }
}

fn project_hash(project: &str) -> String {
    let digest = Sha256::digest(project.as_bytes());
    digest.iter().map(|byte| format!("{byte:02x}")).collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::rules::artifact::ARTIFACT_VERSION;
    use crate::rules::test_support::{package_manager_artifact, test_dir};

    #[test]
    fn artifact_path_uses_stable_project_hash() {
        let left = artifact_path_for_project("/tmp/remem", "/workspace/project");
        let right = artifact_path_for_project("/tmp/remem", "/workspace/project");
        let other = artifact_path_for_project("/tmp/remem", "/workspace/other");

        assert_eq!(left, right);
        assert_ne!(left, other);
        assert_eq!(
            left.parent().and_then(Path::file_name),
            Some("compiled_rules".as_ref())
        );
        assert_eq!(left.extension().and_then(|ext| ext.to_str()), Some("json"));
    }

    #[test]
    fn write_and_load_artifact_round_trip() -> Result<()> {
        let dir = test_dir("round-trip");
        let path = dir.join("artifact.json");
        let artifact = package_manager_artifact();

        write_artifact_atomic(&path, &artifact)?;
        let loaded = load_artifact_fail_open(&path);

        assert_eq!(loaded, ArtifactLoad::Loaded(artifact));
        fs::remove_dir_all(dir)?;
        Ok(())
    }

    #[test]
    fn load_missing_artifact_fails_open() {
        let path = test_dir("missing").join("artifact.json");

        let loaded = load_artifact_fail_open(&path);

        assert!(matches!(
            loaded,
            ArtifactLoad::FailOpen {
                kind: ArtifactLoadErrorKind::Missing,
                ..
            }
        ));
    }

    #[test]
    fn load_corrupt_artifact_fails_open() -> Result<()> {
        let dir = test_dir("corrupt");
        let path = dir.join("artifact.json");
        fs::create_dir_all(&dir)?;
        fs::write(&path, "{not-json")?;

        let loaded = load_artifact_fail_open(&path);

        assert!(matches!(
            loaded,
            ArtifactLoad::FailOpen {
                kind: ArtifactLoadErrorKind::Parse,
                ..
            }
        ));
        fs::remove_dir_all(dir)?;
        Ok(())
    }

    #[test]
    fn load_wrong_version_artifact_fails_open() -> Result<()> {
        let dir = test_dir("wrong-version");
        let path = dir.join("artifact.json");
        fs::create_dir_all(&dir)?;
        fs::write(
            &path,
            format!(
                r#"{{"version":{},"compiled_at_epoch":1,"rules":[]}}"#,
                ARTIFACT_VERSION + 1
            ),
        )?;

        let loaded = load_artifact_fail_open(&path);

        assert!(matches!(
            loaded,
            ArtifactLoad::FailOpen {
                kind: ArtifactLoadErrorKind::Validate,
                ..
            }
        ));
        fs::remove_dir_all(dir)?;
        Ok(())
    }

    #[test]
    fn atomic_writer_preserves_existing_artifact_on_rename_failure() -> Result<()> {
        let dir = test_dir("atomic-failure");
        let path = dir.join("artifact.json");
        let original = CompiledRulesArtifact::new(1, Vec::new());
        write_artifact_atomic(&path, &original)?;
        let replacement = package_manager_artifact();

        let _guard = crate::atomic_file::failpoint_test_lock();
        crate::atomic_file::fail_next_rename_for_path_for_test(&path);
        let err = write_artifact_atomic(&path, &replacement)
            .expect_err("injected rename failure must surface");
        crate::atomic_file::clear_failpoints_for_test();

        assert!(err.to_string().contains("injected atomic write failure"));
        assert_eq!(
            load_artifact_fail_open(&path),
            ArtifactLoad::Loaded(original)
        );
        let temp_entries = fs::read_dir(&dir)?
            .filter_map(|entry| entry.ok())
            .filter(|entry| entry.file_name().to_string_lossy().contains(".tmp."))
            .count();
        assert_eq!(temp_entries, 0);
        fs::remove_dir_all(dir)?;
        Ok(())
    }
}