1use std::fs;
6use std::path::Path;
7use std::process::Command;
8
9use anyhow::{Context, Result};
10
11use crate::plan::{OpKind, Plan};
12
13#[derive(Debug, Clone, Copy, Default)]
15pub struct Options {
16 pub force: bool,
18 pub dry_run: bool,
20 pub format: bool,
24}
25
26#[derive(Debug, Clone, Default, PartialEq, Eq)]
28pub struct Report {
29 pub created: Vec<String>,
30 pub overwritten: Vec<String>,
31 pub skipped: Vec<String>,
32}
33
34impl Report {
35 fn is_empty(&self) -> bool {
36 self.created.is_empty() && self.overwritten.is_empty() && self.skipped.is_empty()
37 }
38
39 pub fn render(&self, dry_run: bool) -> String {
41 if self.is_empty() {
42 return "nothing to do".to_string();
43 }
44 let mut out = String::new();
45 if dry_run {
46 out.push_str("dry-run: no files written\n");
47 }
48 for p in &self.created {
49 out.push_str(&format!(" create {p}\n"));
50 }
51 for p in &self.overwritten {
52 out.push_str(&format!(" force {p}\n"));
53 }
54 for p in &self.skipped {
55 out.push_str(&format!(
56 " skip {p} (exists; use --force to overwrite)\n"
57 ));
58 }
59 out
60 }
61}
62
63#[derive(Debug, Clone, Copy, Default)]
65pub struct Writer {
66 opts: Options,
67}
68
69impl Writer {
70 pub fn new(opts: Options) -> Self {
71 Self { opts }
72 }
73
74 pub fn apply(&self, root: &Path, plan: &Plan) -> Result<Report> {
76 let mut report = Report::default();
77
78 for op in &plan.ops {
79 let path = root.join(&op.path);
80 let display = op.path.display().to_string();
81
82 match op.kind {
83 OpKind::Mkdir => {
84 if !self.opts.dry_run {
85 fs::create_dir_all(&path)
86 .with_context(|| format!("creating directory {display}"))?;
87 }
88 }
89 OpKind::Create => {
90 let exists = path.exists();
91 if exists && !self.opts.force {
92 report.skipped.push(display);
93 continue;
94 }
95
96 if !self.opts.dry_run {
97 if let Some(parent) = path.parent() {
98 fs::create_dir_all(parent)
99 .with_context(|| format!("creating parent for {display}"))?;
100 }
101 let contents = if self.opts.format {
102 format_for_path(&op.path, &op.contents)
103 } else {
104 op.contents.clone()
105 };
106 fs::write(&path, contents).with_context(|| format!("writing {display}"))?;
107 }
108
109 if exists {
110 report.overwritten.push(display);
111 } else {
112 report.created.push(display);
113 }
114 }
115 }
116 }
117
118 Ok(report)
119 }
120}
121
122pub fn format_for_path(path: &Path, contents: &str) -> String {
131 if path.extension().is_some_and(|ext| ext == "rs") {
132 format_rust_str(contents)
133 } else {
134 contents.to_string()
135 }
136}
137
138pub fn format_rust_str(src: &str) -> String {
146 use std::io::Write;
147 use std::process::Stdio;
148
149 let mut child = match Command::new("rustfmt")
150 .arg("--edition")
151 .arg("2024")
152 .stdin(Stdio::piped())
153 .stdout(Stdio::piped())
154 .stderr(Stdio::null())
155 .spawn()
156 {
157 Ok(child) => child,
158 Err(_) => return src.to_string(),
159 };
160
161 if let Some(mut stdin) = child.stdin.take() {
163 if stdin.write_all(src.as_bytes()).is_err() {
164 return src.to_string();
165 }
166 }
167 match child.wait_with_output() {
168 Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).into_owned(),
169 _ => src.to_string(),
170 }
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176 use crate::plan::Plan;
177
178 fn tmpdir() -> std::path::PathBuf {
179 use std::sync::atomic::{AtomicUsize, Ordering};
180 static COUNTER: AtomicUsize = AtomicUsize::new(0);
181 let base = std::env::temp_dir().join(format!("gize-writer-{}", std::process::id()));
182 let unique = base.join(COUNTER.fetch_add(1, Ordering::Relaxed).to_string());
183 fs::create_dir_all(&unique).unwrap();
184 unique
185 }
186
187 #[test]
188 fn creates_new_files() {
189 let root = tmpdir();
190 let plan = Plan::new().create("a.txt", "hello");
191 let report = Writer::new(Options::default()).apply(&root, &plan).unwrap();
192 assert_eq!(report.created, vec!["a.txt".to_string()]);
193 assert_eq!(fs::read_to_string(root.join("a.txt")).unwrap(), "hello");
194 }
195
196 #[test]
197 fn skips_existing_without_force() {
198 let root = tmpdir();
199 fs::write(root.join("a.txt"), "original").unwrap();
200 let plan = Plan::new().create("a.txt", "new");
201 let report = Writer::new(Options::default()).apply(&root, &plan).unwrap();
202 assert_eq!(report.skipped, vec!["a.txt".to_string()]);
203 assert_eq!(fs::read_to_string(root.join("a.txt")).unwrap(), "original");
205 }
206
207 #[test]
208 fn overwrites_with_force() {
209 let root = tmpdir();
210 fs::write(root.join("a.txt"), "original").unwrap();
211 let plan = Plan::new().create("a.txt", "new");
212 let opts = Options {
213 force: true,
214 dry_run: false,
215 format: false,
216 };
217 let report = Writer::new(opts).apply(&root, &plan).unwrap();
218 assert_eq!(report.overwritten, vec!["a.txt".to_string()]);
219 assert_eq!(fs::read_to_string(root.join("a.txt")).unwrap(), "new");
220 }
221
222 #[test]
223 fn dry_run_writes_nothing() {
224 let root = tmpdir();
225 let plan = Plan::new().create("a.txt", "hello");
226 let opts = Options {
227 force: false,
228 dry_run: true,
229 format: false,
230 };
231 let report = Writer::new(opts).apply(&root, &plan).unwrap();
232 assert_eq!(report.created, vec!["a.txt".to_string()]);
233 assert!(!root.join("a.txt").exists());
234 }
235
236 #[test]
239 fn formatting_touches_only_the_files_written() {
240 let rustfmt = Command::new("rustfmt").arg("--version").output();
241 if !rustfmt.is_ok_and(|o| o.status.success()) {
242 return;
243 }
244 let root = tmpdir();
245 let kept = "pub fn hand_edited( )->i32{ 42 }\n";
246 fs::write(root.join("child.rs"), kept).unwrap();
247 let plan = Plan::new().create("mod.rs", "mod child;\nfn wired( ) {}\n");
248 let opts = Options {
249 force: false,
250 dry_run: false,
251 format: true,
252 };
253 Writer::new(opts).apply(&root, &plan).unwrap();
254 assert_eq!(
255 fs::read_to_string(root.join("mod.rs")).unwrap(),
256 "mod child;\nfn wired() {}\n"
257 );
258 assert_eq!(fs::read_to_string(root.join("child.rs")).unwrap(), kept);
259 }
260}