wlk 0.1.0

File-centric, event-sourced version control system with implicit branching
Documentation
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use uuid::Uuid;

/// Unique identifier for a metalog event
pub type EventId = String;

/// Events that occur at the directory level
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type")]
pub enum MetaEvent {
    /// A file was created in this directory
    FileCreated { file: String },

    /// A file was deleted from this directory
    FileDeleted { file: String },

    /// A file moved out of this directory
    FileMovedFrom { file: String, destination: String },

    /// A file moved into this directory
    FileMovedTo { file: String, source: String },

    /// File permissions changed
    PermissionChanged { file: String, mode: u32 },

    /// Directory was created
    DirectoryCreated,

    /// Directory was deleted
    DirectoryDeleted,
}

/// A single entry in the metalog
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetaLogEntry {
    /// Unique identifier for this event
    pub id: EventId,

    /// When the event occurred
    pub timestamp: DateTime<Utc>,

    /// The event that occurred
    pub event: MetaEvent,

    /// Optional author information
    #[serde(skip_serializing_if = "Option::is_none")]
    pub author: Option<String>,
}

impl MetaLogEntry {
    /// Create a new metalog entry
    pub fn new(event: MetaEvent) -> Self {
        MetaLogEntry {
            id: format!("m-{}", Uuid::new_v4()),
            timestamp: Utc::now(),
            event,
            author: None,
        }
    }
}

/// Metalog tracks directory-level events
#[derive(Debug, Serialize, Deserialize)]
pub struct MetaLog {
    /// Path to the directory being tracked
    pub directory: String,

    /// All events in chronological order
    pub events: Vec<MetaLogEntry>,
}

impl MetaLog {
    /// Create a new metalog for a directory
    pub fn new(directory: &str) -> Self {
        MetaLog {
            directory: directory.to_string(),
            events: vec![],
        }
    }

    /// Record a new event
    pub fn record_event(&mut self, event: MetaEvent) -> EventId {
        let entry = MetaLogEntry::new(event);
        let event_id = entry.id.clone();
        self.events.push(entry);
        event_id
    }

    /// Get all events of a specific type
    pub fn events_of_type(&self, event_type: &str) -> Vec<&MetaLogEntry> {
        self.events
            .iter()
            .filter(|e| self.event_type_matches(&e.event, event_type))
            .collect()
    }

    fn event_type_matches(&self, event: &MetaEvent, event_type: &str) -> bool {
        matches!(
            (event, event_type),
            (MetaEvent::FileCreated { .. }, "FileCreated")
                | (MetaEvent::FileDeleted { .. }, "FileDeleted")
                | (MetaEvent::FileMovedFrom { .. }, "FileMovedFrom")
                | (MetaEvent::FileMovedTo { .. }, "FileMovedTo")
                | (MetaEvent::PermissionChanged { .. }, "PermissionChanged")
                | (MetaEvent::DirectoryCreated, "DirectoryCreated")
                | (MetaEvent::DirectoryDeleted, "DirectoryDeleted")
        )
    }

    /// Get all events related to a specific file
    pub fn events_for_file(&self, filename: &str) -> Vec<&MetaLogEntry> {
        self.events
            .iter()
            .filter(|e| self.event_involves_file(&e.event, filename))
            .collect()
    }

    fn event_involves_file(&self, event: &MetaEvent, filename: &str) -> bool {
        match event {
            MetaEvent::FileCreated { file } => file == filename,
            MetaEvent::FileDeleted { file } => file == filename,
            MetaEvent::FileMovedFrom { file, .. } => file == filename,
            MetaEvent::FileMovedTo { file, .. } => file == filename,
            MetaEvent::PermissionChanged { file, .. } => file == filename,
            _ => false,
        }
    }

    /// Save metalog to disk as JSON
    pub fn save(&self, path: &Path) -> Result<()> {
        let json = serde_json::to_string_pretty(self).context("Failed to serialize metalog")?;
        fs::write(path, json).context(format!("Failed to write metalog to {:?}", path))?;
        Ok(())
    }

    /// Load metalog from disk
    pub fn load(path: &Path) -> Result<Self> {
        let json =
            fs::read_to_string(path).context(format!("Failed to read metalog from {:?}", path))?;
        let metalog: MetaLog =
            serde_json::from_str(&json).context("Failed to deserialize metalog")?;
        Ok(metalog)
    }

    /// Get or create metalog for a directory
    pub fn get_or_create(directory: &Path) -> Result<Self> {
        let metalog_path = Self::metalog_path(directory)?;

        if metalog_path.exists() {
            Self::load(&metalog_path)
        } else {
            Ok(Self::new(&directory.to_string_lossy()))
        }
    }

    /// Get path to metalog file for a directory
    pub fn metalog_path(directory: &Path) -> Result<PathBuf> {
        let wlk_dir = directory.join(".wlk");
        Ok(wlk_dir.join("metalog"))
    }
}

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

    #[test]
    fn test_new_metalog() {
        let metalog = MetaLog::new("src/");
        assert_eq!(metalog.directory, "src/");
        assert_eq!(metalog.events.len(), 0);
    }

    #[test]
    fn test_record_event() {
        let mut metalog = MetaLog::new("src/");

        let id = metalog.record_event(MetaEvent::FileCreated {
            file: "main.rs".to_string(),
        });

        assert_eq!(metalog.events.len(), 1);
        assert_eq!(metalog.events[0].id, id);
        assert!(matches!(
            metalog.events[0].event,
            MetaEvent::FileCreated { .. }
        ));
    }

    #[test]
    fn test_file_lifecycle() {
        let mut metalog = MetaLog::new("src/");

        // Create
        metalog.record_event(MetaEvent::FileCreated {
            file: "temp.rs".to_string(),
        });

        // Permission change
        metalog.record_event(MetaEvent::PermissionChanged {
            file: "temp.rs".to_string(),
            mode: 0o755,
        });

        // Move out
        metalog.record_event(MetaEvent::FileMovedFrom {
            file: "temp.rs".to_string(),
            destination: "../lib/temp.rs".to_string(),
        });

        assert_eq!(metalog.events.len(), 3);

        let temp_events = metalog.events_for_file("temp.rs");
        assert_eq!(temp_events.len(), 3);
    }

    #[test]
    fn test_events_of_type() {
        let mut metalog = MetaLog::new("src/");

        metalog.record_event(MetaEvent::FileCreated {
            file: "a.rs".to_string(),
        });
        metalog.record_event(MetaEvent::FileCreated {
            file: "b.rs".to_string(),
        });
        metalog.record_event(MetaEvent::FileDeleted {
            file: "c.rs".to_string(),
        });

        let created_events = metalog.events_of_type("FileCreated");
        assert_eq!(created_events.len(), 2);

        let deleted_events = metalog.events_of_type("FileDeleted");
        assert_eq!(deleted_events.len(), 1);
    }

    #[test]
    fn test_move_tracking() {
        let mut src_metalog = MetaLog::new("src/");
        let mut lib_metalog = MetaLog::new("lib/");

        // File moves from src/ to lib/
        src_metalog.record_event(MetaEvent::FileMovedFrom {
            file: "utils.rs".to_string(),
            destination: "lib/utils.rs".to_string(),
        });

        lib_metalog.record_event(MetaEvent::FileMovedTo {
            file: "utils.rs".to_string(),
            source: "src/utils.rs".to_string(),
        });

        // Verify both metalogs recorded the move
        let src_events = src_metalog.events_for_file("utils.rs");
        assert_eq!(src_events.len(), 1);
        assert!(matches!(
            src_events[0].event,
            MetaEvent::FileMovedFrom { .. }
        ));

        let lib_events = lib_metalog.events_for_file("utils.rs");
        assert_eq!(lib_events.len(), 1);
        assert!(matches!(lib_events[0].event, MetaEvent::FileMovedTo { .. }));
    }
}