Skip to main content

cssforge_core/
output.rs

1use crate::{engine::unified_diff, model::OutputMode};
2use anyhow::{Context, Result};
3use serde::{Deserialize, Serialize};
4use std::{
5    fs,
6    io::Write,
7    path::{Path, PathBuf},
8};
9
10#[derive(Debug, Clone)]
11pub struct OutputOptions {
12    pub mode: OutputMode,
13    pub root: PathBuf,
14    pub out_dir: Option<PathBuf>,
15    pub suffix: String,
16}
17
18impl Default for OutputOptions {
19    fn default() -> Self {
20        Self {
21            mode: OutputMode::DryRun,
22            root: PathBuf::from("."),
23            out_dir: None,
24            suffix: ".modern.css".to_string(),
25        }
26    }
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct WriteResult {
31    pub mode: OutputMode,
32    pub source: PathBuf,
33    pub written: Option<PathBuf>,
34    pub backup: Option<PathBuf>,
35    pub stdout: Option<String>,
36    pub message: String,
37}
38
39pub fn write_result(
40    path: &Path,
41    original: &str,
42    transformed: &str,
43    options: &OutputOptions,
44) -> Result<WriteResult> {
45    match options.mode {
46        OutputMode::DryRun => Ok(WriteResult {
47            mode: options.mode,
48            source: path.to_path_buf(),
49            written: None,
50            backup: None,
51            stdout: None,
52            message: "dry run: no file written".into(),
53        }),
54        OutputMode::NewFile => {
55            let target = modern_path(path, &options.suffix);
56            write_atomic(&target, transformed)?;
57            Ok(written(
58                options.mode,
59                path,
60                target,
61                None,
62                "modernized file written",
63            ))
64        }
65        OutputMode::OutDir => {
66            let base = options
67                .out_dir
68                .clone()
69                .unwrap_or_else(|| options.root.join("cssforge-out"));
70            let relative = path
71                .strip_prefix(&options.root)
72                .map(PathBuf::from)
73                .unwrap_or_else(|_| {
74                    path.file_name()
75                        .map(PathBuf::from)
76                        .unwrap_or_else(|| PathBuf::from("styles.css"))
77                });
78            let target = base.join(relative);
79            if let Some(parent) = target.parent() {
80                fs::create_dir_all(parent)
81                    .with_context(|| format!("failed to create {}", parent.display()))?;
82            }
83            write_atomic(&target, transformed)?;
84            Ok(written(
85                options.mode,
86                path,
87                target,
88                None,
89                "file written to output directory",
90            ))
91        }
92        OutputMode::OverwriteWithBackup => {
93            let backup = backup_path(path);
94            fs::copy(path, &backup)
95                .with_context(|| format!("failed to create backup {}", backup.display()))?;
96            write_atomic(path, transformed)?;
97            Ok(written(
98                options.mode,
99                path,
100                path.to_path_buf(),
101                Some(backup),
102                "source overwritten after backup",
103            ))
104        }
105        OutputMode::Overwrite => {
106            write_atomic(path, transformed)?;
107            Ok(written(
108                options.mode,
109                path,
110                path.to_path_buf(),
111                None,
112                "source overwritten",
113            ))
114        }
115        OutputMode::Patch => {
116            let target = patch_path(path);
117            let diff = unified_diff(
118                original,
119                transformed,
120                &path.display().to_string(),
121                &format!("{}.modern", path.display()),
122            );
123            write_atomic(&target, &diff)?;
124            Ok(written(
125                options.mode,
126                path,
127                target,
128                None,
129                "unified patch written",
130            ))
131        }
132        OutputMode::Stdout => Ok(WriteResult {
133            mode: options.mode,
134            source: path.to_path_buf(),
135            written: None,
136            backup: None,
137            stdout: Some(transformed.to_string()),
138            message: "transformed CSS returned for stdout".into(),
139        }),
140    }
141}
142
143fn written(
144    mode: OutputMode,
145    source: &Path,
146    target: PathBuf,
147    backup: Option<PathBuf>,
148    message: &str,
149) -> WriteResult {
150    WriteResult {
151        mode,
152        source: source.to_path_buf(),
153        written: Some(target),
154        backup,
155        stdout: None,
156        message: message.into(),
157    }
158}
159
160fn modern_path(path: &Path, suffix: &str) -> PathBuf {
161    let stem = path
162        .file_stem()
163        .and_then(|s| s.to_str())
164        .unwrap_or("styles");
165    path.with_file_name(format!("{stem}{suffix}"))
166}
167
168fn backup_path(path: &Path) -> PathBuf {
169    let file = path
170        .file_name()
171        .and_then(|s| s.to_str())
172        .unwrap_or("styles.css");
173    path.with_file_name(format!("{file}.bak"))
174}
175
176fn patch_path(path: &Path) -> PathBuf {
177    let file = path
178        .file_name()
179        .and_then(|s| s.to_str())
180        .unwrap_or("styles.css");
181    path.with_file_name(format!("{file}.patch"))
182}
183
184fn write_atomic(path: &Path, content: &str) -> Result<()> {
185    if let Some(parent) = path.parent() {
186        fs::create_dir_all(parent)
187            .with_context(|| format!("failed to create {}", parent.display()))?;
188    }
189    let name = path
190        .file_name()
191        .and_then(|s| s.to_str())
192        .unwrap_or("output.css");
193    let tmp = path.with_file_name(format!(".{name}.cssforge-tmp-{}", std::process::id()));
194    {
195        let mut file = fs::File::create(&tmp)
196            .with_context(|| format!("failed to create temporary file {}", tmp.display()))?;
197        file.write_all(content.as_bytes())
198            .with_context(|| format!("failed to write temporary file {}", tmp.display()))?;
199        file.sync_all()
200            .with_context(|| format!("failed to sync {}", tmp.display()))?;
201    }
202
203    #[cfg(windows)]
204    if path.exists() {
205        fs::remove_file(path).with_context(|| format!("failed to replace {}", path.display()))?;
206    }
207
208    fs::rename(&tmp, path)
209        .with_context(|| format!("failed to move {} to {}", tmp.display(), path.display()))?;
210    Ok(())
211}