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}