1use std::fs;
6use std::path::{Path, PathBuf};
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 let mut written_rs: Vec<PathBuf> = Vec::new();
78
79 for op in &plan.ops {
80 let path = root.join(&op.path);
81 let display = op.path.display().to_string();
82
83 match op.kind {
84 OpKind::Mkdir => {
85 if !self.opts.dry_run {
86 fs::create_dir_all(&path)
87 .with_context(|| format!("creating directory {display}"))?;
88 }
89 }
90 OpKind::Create => {
91 let exists = path.exists();
92 if exists && !self.opts.force {
93 report.skipped.push(display);
94 continue;
95 }
96
97 if !self.opts.dry_run {
98 if let Some(parent) = path.parent() {
99 fs::create_dir_all(parent)
100 .with_context(|| format!("creating parent for {display}"))?;
101 }
102 fs::write(&path, &op.contents)
103 .with_context(|| format!("writing {display}"))?;
104 if display.ends_with(".rs") {
105 written_rs.push(path.clone());
106 }
107 }
108
109 if exists {
110 report.overwritten.push(display);
111 } else {
112 report.created.push(display);
113 }
114 }
115 }
116 }
117
118 if self.opts.format {
119 format_rust_files(&written_rs);
120 }
121
122 Ok(report)
123 }
124}
125
126pub fn format_rust_files(files: &[PathBuf]) {
133 if files.is_empty() {
134 return;
135 }
136 let _ = Command::new("rustfmt")
137 .arg("--edition")
138 .arg("2024")
139 .args(files)
140 .status();
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146 use crate::plan::Plan;
147
148 fn tmpdir() -> std::path::PathBuf {
149 use std::sync::atomic::{AtomicUsize, Ordering};
150 static COUNTER: AtomicUsize = AtomicUsize::new(0);
151 let base = std::env::temp_dir().join(format!("gize-writer-{}", std::process::id()));
152 let unique = base.join(COUNTER.fetch_add(1, Ordering::Relaxed).to_string());
153 fs::create_dir_all(&unique).unwrap();
154 unique
155 }
156
157 #[test]
158 fn creates_new_files() {
159 let root = tmpdir();
160 let plan = Plan::new().create("a.txt", "hello");
161 let report = Writer::new(Options::default()).apply(&root, &plan).unwrap();
162 assert_eq!(report.created, vec!["a.txt".to_string()]);
163 assert_eq!(fs::read_to_string(root.join("a.txt")).unwrap(), "hello");
164 }
165
166 #[test]
167 fn skips_existing_without_force() {
168 let root = tmpdir();
169 fs::write(root.join("a.txt"), "original").unwrap();
170 let plan = Plan::new().create("a.txt", "new");
171 let report = Writer::new(Options::default()).apply(&root, &plan).unwrap();
172 assert_eq!(report.skipped, vec!["a.txt".to_string()]);
173 assert_eq!(fs::read_to_string(root.join("a.txt")).unwrap(), "original");
175 }
176
177 #[test]
178 fn overwrites_with_force() {
179 let root = tmpdir();
180 fs::write(root.join("a.txt"), "original").unwrap();
181 let plan = Plan::new().create("a.txt", "new");
182 let opts = Options {
183 force: true,
184 dry_run: false,
185 format: false,
186 };
187 let report = Writer::new(opts).apply(&root, &plan).unwrap();
188 assert_eq!(report.overwritten, vec!["a.txt".to_string()]);
189 assert_eq!(fs::read_to_string(root.join("a.txt")).unwrap(), "new");
190 }
191
192 #[test]
193 fn dry_run_writes_nothing() {
194 let root = tmpdir();
195 let plan = Plan::new().create("a.txt", "hello");
196 let opts = Options {
197 force: false,
198 dry_run: true,
199 format: false,
200 };
201 let report = Writer::new(opts).apply(&root, &plan).unwrap();
202 assert_eq!(report.created, vec!["a.txt".to_string()]);
203 assert!(!root.join("a.txt").exists());
204 }
205}