grimoire_css 1.9.0

A magical CSS engine for all environments
Documentation
//! File change tracking and cleanup for generated CSS files.
//!
//! This module tracks which CSS files are generated during builds and cleans up
//! old files that are no longer needed. Uses a lock file to maintain the state.
//!
//! # Example
//! ```ignore
//! FileTracker::track(cwd, &[path1, path2])?; // Tracks new files and removes old ones
//! ```

use serde::{Deserialize, Serialize};
use std::{collections::HashSet, fs, path::Path};

use super::{Filesystem, GrimoireCssError};

#[derive(Serialize, Deserialize, Debug, Clone)]
struct GrimoireLock {
    paths: Vec<String>,
}

pub struct FileTracker;

impl FileTracker {
    pub fn track<'a>(
        cwd: &Path,
        builded_files: impl IntoIterator<Item = &'a Path>,
    ) -> Result<(), GrimoireCssError> {
        let prev_lock_path =
            Filesystem::get_or_create_grimoire_path(cwd)?.join("grimoire.lock.json");

        let root = fs::canonicalize(cwd)?;
        let lock_path = |path: &Path| {
            // Preserve the filename so cleanup removes a symlink, not its target.
            let normalized = match (path.parent(), path.file_name()) {
                (Some(parent), Some(name)) => fs::canonicalize(parent)
                    .map(|parent| parent.join(name))
                    .unwrap_or_else(|_| path.to_path_buf()),
                _ => path.to_path_buf(),
            };
            let path = normalized.as_path();
            path.strip_prefix(&root)
                .unwrap_or(path)
                .to_string_lossy()
                .into_owned()
        };
        let current_files_set: HashSet<String> = builded_files
            .into_iter()
            .map(|path| std::path::absolute(path).map(|path| lock_path(&path)))
            .collect::<Result<_, _>>()?;

        if prev_lock_path.exists() {
            let content = fs::read_to_string(&prev_lock_path)?;
            let lock_json: GrimoireLock = serde_json::from_str(&content)?;

            let prev_files_set: HashSet<String> = lock_json
                .paths
                .into_iter()
                .map(|path| lock_path(&root.join(path)))
                .collect();
            let files_to_delete = prev_files_set.difference(&current_files_set);

            for file in files_to_delete {
                let file_path = cwd.join(file);
                if file_path.exists() {
                    fs::remove_file(&file_path)?;
                } else {
                    eprintln!("Warning: File {file} does not exist and cannot be deleted.");
                }
            }
        }

        let lock = GrimoireLock {
            paths: current_files_set.into_iter().collect(),
        };
        let new_lock_content = serde_json::to_string_pretty(&lock)?;
        fs::write(&prev_lock_path, new_lock_content)?;

        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs::{self, File};
    use tempfile::tempdir;

    #[test]
    fn test_track_creates_lock_file() {
        let temp_dir = tempdir().unwrap();
        let cwd = temp_dir.path();

        let file1 = cwd.join("file1.css");
        let file2 = cwd.join("file2.css");
        File::create(&file1).unwrap();
        File::create(&file2).unwrap();

        let builded_files = vec![file1.as_path(), file2.as_path()];

        FileTracker::track(cwd, builded_files).expect("Failed to track files");

        let lock_file_path = cwd.join("grimoire/grimoire.lock.json");
        assert!(
            lock_file_path.exists(),
            "Lock file was not created at expected path: {lock_file_path:?}"
        );
    }

    #[test]
    fn test_track_removes_old_files() {
        let temp_dir = tempdir().unwrap();
        let cwd = temp_dir.path();

        let old_file1 = cwd.join("old_file1.css");
        let old_file2 = cwd.join("old_file2.css");
        File::create(&old_file1).unwrap();
        File::create(&old_file2).unwrap();

        let lock = GrimoireLock {
            paths: vec!["old_file1.css".to_string(), "old_file2.css".to_string()],
        };
        let lock_file_path = cwd.join("grimoire/grimoire.lock.json");
        fs::create_dir_all(lock_file_path.parent().unwrap()).unwrap();
        fs::write(
            &lock_file_path,
            serde_json::to_string_pretty(&lock).unwrap(),
        )
        .unwrap();

        let new_file = cwd.join("new_file.css");
        File::create(&new_file).unwrap();
        let builded_files = vec![new_file.as_path()];

        FileTracker::track(cwd, builded_files).expect("Failed to track files");

        assert!(
            !old_file1.exists(),
            "Old file was not deleted: {old_file1:?}"
        );
        assert!(
            !old_file2.exists(),
            "Old file was not deleted: {old_file2:?}"
        );

        assert!(
            new_file.exists(),
            "New file was unexpectedly deleted: {new_file:?}"
        );
    }

    #[test]
    fn test_track_handles_missing_lock_file_gracefully() {
        let temp_dir = tempdir().unwrap();
        let cwd = temp_dir.path();

        let file1 = cwd.join("file1.css");
        File::create(&file1).unwrap();
        let builded_files = vec![file1.as_path()];

        FileTracker::track(cwd, builded_files).expect("Failed to track files");

        let lock_file_path = cwd.join("grimoire/grimoire.lock.json");
        assert!(
            lock_file_path.exists(),
            "Lock file was not created at expected path: {lock_file_path:?}"
        );
    }
}