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}