memrecd 0.3.0

Local-first AI memory with project isolation — for terminal, for private use
Documentation
use std::fmt;
use std::path::PathBuf;
use std::fs;
use chrono::{DateTime, Utc};
use uuid::Uuid;
use anyhow::Result;

pub struct ProjectIdFile {
    pub project_id: Uuid,
    pub created_at: DateTime<Utc>,
    pub project_name: Option<String>,
}

impl ProjectIdFile {
    pub fn new(project_name: Option<String>) -> Self {
        Self {
            project_id: Uuid::new_v4(),
            created_at: Utc::now(),
            project_name,
        }
    }
    
    pub fn parse(content: &str) -> Result<Self> {
        let mut project_id: Option<Uuid> = None;
        let mut created_at: Option<DateTime<Utc>> = None;
        let mut project_name: Option<String> = None;
        
        for line in content.lines() {
            if let Some((key, value)) = line.split_once('=') {
                match key.trim() {
                    "memrec_project_id" => {
                        project_id = Some(Uuid::parse_str(value.trim())?);
                    }
                    "created_at" => {
                        created_at = Some(DateTime::parse_from_rfc3339(value.trim())?.with_timezone(&Utc));
                    }
                    "project_name" => {
                        project_name = Some(value.trim().to_string());
                    }
                    _ => {}
                }
            }
        }
        
        match (project_id, created_at) {
            (Some(id), Some(at)) => Ok(Self {
                project_id: id,
                created_at: at,
                project_name,
            }),
            _ => anyhow::bail!("Invalid .mr_pid file format"),
        }
    }
}

impl fmt::Display for ProjectIdFile {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "memrec_project_id={}\ncreated_at={}", self.project_id, self.created_at.to_rfc3339())?;
        if let Some(name) = &self.project_name {
            write!(f, "\nproject_name={}", name)?;
        }
        Ok(())
    }
}

pub fn find_project_root(working_dir: Option<&str>) -> Result<PathBuf> {
    let start_dir = if let Some(dir) = working_dir {
        PathBuf::from(dir)
    } else {
        std::env::current_dir()?
    };
    
    if let Ok(output) = std::process::Command::new("git")
        .args(["rev-parse", "--show-toplevel"])
        .current_dir(&start_dir)
        .output()
    {
        if output.status.success() {
            let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
            if !path.is_empty() {
                return Ok(PathBuf::from(path));
            }
        }
    }
    
    Ok(start_dir)
}

pub fn detect_project_id(working_dir: Option<&str>) -> Result<Uuid> {
    let project_root = find_project_root(working_dir)?;
    let mr_pid_path = project_root.join(".mr_pid");
    
    if mr_pid_path.exists() {
        let content = fs::read_to_string(&mr_pid_path)?;
        let file = ProjectIdFile::parse(&content)?;
        Ok(file.project_id)
    } else {
        let file = ProjectIdFile::new(None);
        fs::write(&mr_pid_path, file.to_string())?;
        Ok(file.project_id)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::tempdir;
    
    #[test]
    fn test_project_id_file_creation() {
        let file = ProjectIdFile::new(Some("test-project".to_string()));
        assert!(!file.project_id.is_nil());
        assert_eq!(file.project_name, Some("test-project".to_string()));
    }
    
    #[test]
    fn test_project_id_file_parse() {
        let original = ProjectIdFile::new(None);
        let content = original.to_string();
        let parsed = ProjectIdFile::parse(&content).unwrap();
        
        assert_eq!(original.project_id, parsed.project_id);
    }
    
    #[test]
    fn test_project_id_file_roundtrip() {
        let original = ProjectIdFile::new(Some("my-project".to_string()));
        let content = original.to_string();
        let parsed = ProjectIdFile::parse(&content).unwrap();
        
        assert_eq!(original.project_id, parsed.project_id);
        assert_eq!(original.project_name, parsed.project_name);
    }
}