Skip to main content

cssforge_core/
output.rs

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