cssforge-core 0.2.1

Core semantic analysis and lossless refactoring engine for CSSForge
Documentation
use crate::{engine::unified_diff, model::OutputMode};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::{
    fs,
    io::Write,
    path::{Path, PathBuf},
};

#[derive(Debug, Clone)]
pub struct OutputOptions {
    pub mode: OutputMode,
    pub root: PathBuf,
    pub out_dir: Option<PathBuf>,
    pub suffix: String,
}

impl Default for OutputOptions {
    fn default() -> Self {
        Self {
            mode: OutputMode::DryRun,
            root: PathBuf::from("."),
            out_dir: None,
            suffix: ".modern.css".to_string(),
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WriteResult {
    pub mode: OutputMode,
    pub source: PathBuf,
    pub written: Option<PathBuf>,
    pub backup: Option<PathBuf>,
    pub stdout: Option<String>,
    pub message: String,
}

pub fn write_result(
    path: &Path,
    original: &str,
    transformed: &str,
    options: &OutputOptions,
) -> Result<WriteResult> {
    match options.mode {
        OutputMode::DryRun => Ok(WriteResult {
            mode: options.mode,
            source: path.to_path_buf(),
            written: None,
            backup: None,
            stdout: None,
            message: "dry run: no file written".into(),
        }),
        OutputMode::NewFile => {
            let target = modern_path(path, &options.suffix);
            write_atomic(&target, transformed)?;
            Ok(written(
                options.mode,
                path,
                target,
                None,
                "modernized file written",
            ))
        }
        OutputMode::OutDir => {
            let base = options
                .out_dir
                .clone()
                .unwrap_or_else(|| options.root.join("cssforge-out"));
            let relative = path
                .strip_prefix(&options.root)
                .map(PathBuf::from)
                .unwrap_or_else(|_| {
                    path.file_name()
                        .map(PathBuf::from)
                        .unwrap_or_else(|| PathBuf::from("styles.css"))
                });
            let target = base.join(relative);
            if let Some(parent) = target.parent() {
                fs::create_dir_all(parent)
                    .with_context(|| format!("failed to create {}", parent.display()))?;
            }
            write_atomic(&target, transformed)?;
            Ok(written(
                options.mode,
                path,
                target,
                None,
                "file written to output directory",
            ))
        }
        OutputMode::OverwriteWithBackup => {
            let backup = backup_path(path);
            fs::copy(path, &backup)
                .with_context(|| format!("failed to create backup {}", backup.display()))?;
            write_atomic(path, transformed)?;
            Ok(written(
                options.mode,
                path,
                path.to_path_buf(),
                Some(backup),
                "source overwritten after backup",
            ))
        }
        OutputMode::Overwrite => {
            write_atomic(path, transformed)?;
            Ok(written(
                options.mode,
                path,
                path.to_path_buf(),
                None,
                "source overwritten",
            ))
        }
        OutputMode::Patch => {
            let target = patch_path(path);
            let diff = unified_diff(
                original,
                transformed,
                &path.display().to_string(),
                &format!("{}.modern", path.display()),
            );
            write_atomic(&target, &diff)?;
            Ok(written(
                options.mode,
                path,
                target,
                None,
                "unified patch written",
            ))
        }
        OutputMode::Stdout => Ok(WriteResult {
            mode: options.mode,
            source: path.to_path_buf(),
            written: None,
            backup: None,
            stdout: Some(transformed.to_string()),
            message: "transformed CSS returned for stdout".into(),
        }),
    }
}

fn written(
    mode: OutputMode,
    source: &Path,
    target: PathBuf,
    backup: Option<PathBuf>,
    message: &str,
) -> WriteResult {
    WriteResult {
        mode,
        source: source.to_path_buf(),
        written: Some(target),
        backup,
        stdout: None,
        message: message.into(),
    }
}

fn modern_path(path: &Path, suffix: &str) -> PathBuf {
    let stem = path
        .file_stem()
        .and_then(|s| s.to_str())
        .unwrap_or("styles");
    path.with_file_name(format!("{stem}{suffix}"))
}

fn backup_path(path: &Path) -> PathBuf {
    let file = path
        .file_name()
        .and_then(|s| s.to_str())
        .unwrap_or("styles.css");
    path.with_file_name(format!("{file}.bak"))
}

fn patch_path(path: &Path) -> PathBuf {
    let file = path
        .file_name()
        .and_then(|s| s.to_str())
        .unwrap_or("styles.css");
    path.with_file_name(format!("{file}.patch"))
}

fn write_atomic(path: &Path, content: &str) -> Result<()> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)
            .with_context(|| format!("failed to create {}", parent.display()))?;
    }
    let name = path
        .file_name()
        .and_then(|s| s.to_str())
        .unwrap_or("output.css");
    let tmp = path.with_file_name(format!(".{name}.cssforge-tmp-{}", std::process::id()));
    {
        let mut file = fs::File::create(&tmp)
            .with_context(|| format!("failed to create temporary file {}", tmp.display()))?;
        file.write_all(content.as_bytes())
            .with_context(|| format!("failed to write temporary file {}", tmp.display()))?;
        file.sync_all()
            .with_context(|| format!("failed to sync {}", tmp.display()))?;
    }

    #[cfg(windows)]
    if path.exists() {
        fs::remove_file(path).with_context(|| format!("failed to replace {}", path.display()))?;
    }

    fs::rename(&tmp, path)
        .with_context(|| format!("failed to move {} to {}", tmp.display(), path.display()))?;
    Ok(())
}