wlk 0.1.0

File-centric, event-sourced version control system with implicit branching
Documentation
use crate::config::Config;
use crate::metalog::{MetaEvent, MetaLog};
use crate::shadow::ShadowFile;
use crate::utils;
use crate::wal::{WalOperation, WriteAheadLog};
use anyhow::{anyhow, Context, Result};
use std::fs;
use std::path::{Path, PathBuf};

#[derive(Debug, Clone, PartialEq)]
pub enum FileStatus {
    Tracked,
    Modified,
    Untracked,
}

pub struct FileStatusInfo {
    pub path: PathBuf,
    pub status: FileStatus,
}

/// WLK Repository manages the .wlk structure
pub struct Repository {
    /// Root path of the repository
    root: PathBuf,

    /// Write-ahead log for atomic operations
    wal: WriteAheadLog,
}

impl Repository {
    /// Initialize a new WLK repository in the given directory
    pub fn init(path: &Path) -> Result<Self> {
        let root = path.canonicalize().context("Failed to canonicalize path")?;

        let wlk_dir = root.join(".wlk");

        // Create .wlk directory if it doesn't exist
        if !wlk_dir.exists() {
            fs::create_dir_all(&wlk_dir).context("Failed to create .wlk directory")?;
        }

        // Create config file
        let config_path = wlk_dir.join("config");
        if !config_path.exists() {
            let default_config = Config::default();
            default_config
                .save(&wlk_dir)
                .context("Failed to create config file")?;
        }

        // Initialize WAL
        let mut wal = WriteAheadLog::new(&wlk_dir)?;
        wal.recover()?; // Recover from any previous crash

        // Create root metalog
        let mut metalog = MetaLog::new(&root.to_string_lossy());
        metalog.record_event(MetaEvent::DirectoryCreated);
        metalog.save(&MetaLog::metalog_path(&root)?)?;

        Ok(Repository { root, wal })
    }

    /// Open an existing WLK repository
    pub fn open(path: &Path) -> Result<Self> {
        let root = path.canonicalize().context("Failed to canonicalize path")?;

        let wlk_dir = root.join(".wlk");

        if !wlk_dir.exists() {
            return Err(anyhow!(
                "Not a wlk repository (no .wlk directory found at {:?})",
                root
            ));
        }

        // Initialize WAL and recover if needed
        let mut wal = WriteAheadLog::new(&wlk_dir)?;
        wal.recover()?;

        Ok(Repository { root, wal })
    }

    /// Start tracking a file
    pub fn track_file(&mut self, file_path: &Path) -> Result<()> {
        let absolute_path = if file_path.is_absolute() {
            file_path.to_path_buf()
        } else {
            self.root.join(file_path)
        };

        if !absolute_path.exists() {
            return Err(anyhow!("File does not exist: {:?}", absolute_path));
        }

        if !absolute_path.is_file() {
            return Err(anyhow!("Path is not a file: {:?}", absolute_path));
        }

        // Read current file content
        let content = fs::read_to_string(&absolute_path).context("Failed to read file content")?;

        // Create shadow file
        let filename = absolute_path
            .file_name()
            .ok_or_else(|| anyhow!("Invalid filename"))?
            .to_string_lossy()
            .to_string();

        let shadow = ShadowFile::new(&filename, content);
        let shadow_path = ShadowFile::shadow_path(&absolute_path)?;

        // Ensure .wlk directory exists
        if let Some(parent) = shadow_path.parent() {
            fs::create_dir_all(parent).context("Failed to create .wlk directory")?;
        }

        // Use WAL for atomic operation
        self.wal.begin()?;

        let shadow_json =
            serde_json::to_string_pretty(&shadow).context("Failed to serialize shadow file")?;

        self.wal.append(WalOperation::WriteShadowFile {
            path: shadow_path.clone(),
            content: shadow_json,
        })?;

        // Record in metalog
        let dir_path = absolute_path
            .parent()
            .ok_or_else(|| anyhow!("File has no parent directory"))?;

        let mut metalog = MetaLog::get_or_create(dir_path)?;
        metalog.record_event(MetaEvent::FileCreated { file: filename });

        let metalog_path = MetaLog::metalog_path(dir_path)?;
        let metalog_json =
            serde_json::to_string_pretty(&metalog).context("Failed to serialize metalog")?;

        self.wal.append(WalOperation::WriteMetaLog {
            path: metalog_path,
            content: metalog_json,
        })?;

        self.wal.commit()?;

        Ok(())
    }

    /// Stop tracking a file
    pub fn untrack_file(&mut self, file_path: &Path) -> Result<()> {
        let absolute_path = if file_path.is_absolute() {
            file_path.to_path_buf()
        } else {
            self.root.join(file_path)
        };

        let shadow_path = ShadowFile::shadow_path(&absolute_path)?;

        if !shadow_path.exists() {
            return Err(anyhow!("File is not tracked: {:?}", absolute_path));
        }

        // Use WAL for atomic operation
        self.wal.begin()?;

        self.wal
            .append(WalOperation::DeleteFile { path: shadow_path })?;

        // Record in metalog
        let dir_path = absolute_path
            .parent()
            .ok_or_else(|| anyhow!("File has no parent directory"))?;

        let filename = absolute_path
            .file_name()
            .ok_or_else(|| anyhow!("Invalid filename"))?
            .to_string_lossy()
            .to_string();

        let mut metalog = MetaLog::get_or_create(dir_path)?;
        metalog.record_event(MetaEvent::FileDeleted { file: filename });

        let metalog_path = MetaLog::metalog_path(dir_path)?;
        let metalog_json =
            serde_json::to_string_pretty(&metalog).context("Failed to serialize metalog")?;

        self.wal.append(WalOperation::WriteMetaLog {
            path: metalog_path,
            content: metalog_json,
        })?;

        self.wal.commit()?;

        Ok(())
    }

    /// Check if a file is being tracked
    pub fn is_tracked(&self, file_path: &Path) -> bool {
        let absolute_path = if file_path.is_absolute() {
            file_path.to_path_buf()
        } else {
            self.root.join(file_path)
        };

        if let Ok(shadow_path) = ShadowFile::shadow_path(&absolute_path) {
            shadow_path.exists()
        } else {
            false
        }
    }

    /// List all tracked files in the repository
    pub fn list_tracked_files(&self) -> Result<Vec<PathBuf>> {
        let mut tracked_files = vec![];

        self.walk_directory(&self.root, &mut tracked_files)?;

        Ok(tracked_files)
    }

    fn walk_directory(&self, dir: &Path, tracked_files: &mut Vec<PathBuf>) -> Result<()> {
        for entry in fs::read_dir(dir).context("Failed to read directory")? {
            let entry = entry.context("Failed to read directory entry")?;
            let path = entry.path();

            // Skip .wlk directories
            if path.is_dir() {
                if path.file_name() != Some(std::ffi::OsStr::new(".wlk")) {
                    self.walk_directory(&path, tracked_files)?;
                }
            } else if path.is_file() && self.is_tracked(&path) {
                tracked_files.push(path);
            }
        }

        Ok(())
    }

    /// Get the repository root path
    pub fn root(&self) -> &Path {
        &self.root
    }

    /// Check if a file has been modified since last snapshot
    pub fn file_has_changes(&self, file_path: &Path) -> Result<bool> {
        let absolute_path = if file_path.is_absolute() {
            file_path.to_path_buf()
        } else {
            self.root.join(file_path)
        };

        if !absolute_path.exists() {
            return Ok(true); // File deleted
        }

        let shadow_path = ShadowFile::shadow_path(&absolute_path)?;
        if !shadow_path.exists() {
            return Ok(false); // Not tracked
        }

        let shadow = ShadowFile::load(&shadow_path)?;
        let current_content = fs::read_to_string(&absolute_path).context("Failed to read file")?;
        let head_content = shadow.current_content()?;

        Ok(utils::hash_file_content(&current_content) != utils::hash_file_content(&head_content))
    }

    /// Get status of all files in repository
    pub fn get_status(&self) -> Result<Vec<FileStatusInfo>> {
        let mut status_list = vec![];

        // Get all tracked files
        let tracked_files = self.list_tracked_files()?;

        for file in &tracked_files {
            let has_changes = self.file_has_changes(file)?;
            let status = if has_changes {
                FileStatus::Modified
            } else {
                FileStatus::Tracked
            };

            status_list.push(FileStatusInfo {
                path: file.clone(),
                status,
            });
        }

        // Find untracked files
        let untracked = self.find_untracked_files()?;
        for file in untracked {
            status_list.push(FileStatusInfo {
                path: file,
                status: FileStatus::Untracked,
            });
        }

        Ok(status_list)
    }

    /// Find all untracked files in the repository
    fn find_untracked_files(&self) -> Result<Vec<PathBuf>> {
        let mut untracked = vec![];
        self.find_untracked_recursive(&self.root, &mut untracked)?;
        Ok(untracked)
    }

    fn find_untracked_recursive(&self, dir: &Path, untracked: &mut Vec<PathBuf>) -> Result<()> {
        for entry in fs::read_dir(dir).context("Failed to read directory")? {
            let entry = entry.context("Failed to read directory entry")?;
            let path = entry.path();

            // Skip .wlk directories
            if path.is_dir() {
                if path.file_name() != Some(std::ffi::OsStr::new(".wlk")) {
                    self.find_untracked_recursive(&path, untracked)?;
                }
            } else if path.is_file() && !self.is_tracked(&path) {
                untracked.push(path);
            }
        }

        Ok(())
    }
}

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

    #[test]
    fn test_init_repository() {
        let temp_dir = TempDir::new().unwrap();
        let _repo = Repository::init(temp_dir.path()).unwrap();

        assert!(temp_dir.path().join(".wlk").exists());
        assert!(temp_dir.path().join(".wlk/config").exists());
        assert!(temp_dir.path().join(".wlk/metalog").exists());
    }

    #[test]
    fn test_track_file() {
        let temp_dir = TempDir::new().unwrap();
        let mut repo = Repository::init(temp_dir.path()).unwrap();

        let test_file = temp_dir.path().join("test.txt");
        fs::write(&test_file, "initial content").unwrap();

        repo.track_file(&test_file).unwrap();

        assert!(repo.is_tracked(&test_file));

        let shadow_path = ShadowFile::shadow_path(&test_file).unwrap();
        assert!(shadow_path.exists());
    }

    #[test]
    fn test_untrack_file() {
        let temp_dir = TempDir::new().unwrap();
        let mut repo = Repository::init(temp_dir.path()).unwrap();

        let test_file = temp_dir.path().join("test.txt");
        fs::write(&test_file, "content").unwrap();

        repo.track_file(&test_file).unwrap();
        assert!(repo.is_tracked(&test_file));

        repo.untrack_file(&test_file).unwrap();
        assert!(!repo.is_tracked(&test_file));
    }

    #[test]
    fn test_list_tracked_files() {
        let temp_dir = TempDir::new().unwrap();
        let mut repo = Repository::init(temp_dir.path()).unwrap();

        let file1 = temp_dir.path().join("file1.txt");
        let file2 = temp_dir.path().join("file2.txt");

        fs::write(&file1, "content 1").unwrap();
        fs::write(&file2, "content 2").unwrap();

        repo.track_file(&file1).unwrap();
        repo.track_file(&file2).unwrap();

        let tracked = repo.list_tracked_files().unwrap();
        assert_eq!(tracked.len(), 2);
    }
}