1use std::fs;
6use std::path::Path;
7
8use anyhow::{Context, Result};
9
10use crate::plan::{OpKind, Plan};
11
12#[derive(Debug, Clone, Copy, Default)]
14pub struct Options {
15 pub force: bool,
17 pub dry_run: bool,
19}
20
21#[derive(Debug, Clone, Default, PartialEq, Eq)]
23pub struct Report {
24 pub created: Vec<String>,
25 pub overwritten: Vec<String>,
26 pub skipped: Vec<String>,
27}
28
29impl Report {
30 fn is_empty(&self) -> bool {
31 self.created.is_empty() && self.overwritten.is_empty() && self.skipped.is_empty()
32 }
33
34 pub fn render(&self, dry_run: bool) -> String {
36 if self.is_empty() {
37 return "nothing to do".to_string();
38 }
39 let mut out = String::new();
40 if dry_run {
41 out.push_str("dry-run: no files written\n");
42 }
43 for p in &self.created {
44 out.push_str(&format!(" create {p}\n"));
45 }
46 for p in &self.overwritten {
47 out.push_str(&format!(" force {p}\n"));
48 }
49 for p in &self.skipped {
50 out.push_str(&format!(
51 " skip {p} (exists; use --force to overwrite)\n"
52 ));
53 }
54 out
55 }
56}
57
58#[derive(Debug, Clone, Copy, Default)]
60pub struct Writer {
61 opts: Options,
62}
63
64impl Writer {
65 pub fn new(opts: Options) -> Self {
66 Self { opts }
67 }
68
69 pub fn apply(&self, root: &Path, plan: &Plan) -> Result<Report> {
71 let mut report = Report::default();
72
73 for op in &plan.ops {
74 let path = root.join(&op.path);
75 let display = op.path.display().to_string();
76
77 match op.kind {
78 OpKind::Mkdir => {
79 if !self.opts.dry_run {
80 fs::create_dir_all(&path)
81 .with_context(|| format!("creating directory {display}"))?;
82 }
83 }
84 OpKind::Create => {
85 let exists = path.exists();
86 if exists && !self.opts.force {
87 report.skipped.push(display);
88 continue;
89 }
90
91 if !self.opts.dry_run {
92 if let Some(parent) = path.parent() {
93 fs::create_dir_all(parent)
94 .with_context(|| format!("creating parent for {display}"))?;
95 }
96 fs::write(&path, &op.contents)
97 .with_context(|| format!("writing {display}"))?;
98 }
99
100 if exists {
101 report.overwritten.push(display);
102 } else {
103 report.created.push(display);
104 }
105 }
106 }
107 }
108
109 Ok(report)
110 }
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116 use crate::plan::Plan;
117
118 fn tmpdir() -> std::path::PathBuf {
119 let base = std::env::temp_dir().join(format!("gize-writer-{}", std::process::id()));
120 let unique = base.join(format!(
121 "{}",
122 std::time::SystemTime::now()
123 .duration_since(std::time::UNIX_EPOCH)
124 .unwrap()
125 .as_nanos()
126 ));
127 fs::create_dir_all(&unique).unwrap();
128 unique
129 }
130
131 #[test]
132 fn creates_new_files() {
133 let root = tmpdir();
134 let plan = Plan::new().create("a.txt", "hello");
135 let report = Writer::new(Options::default()).apply(&root, &plan).unwrap();
136 assert_eq!(report.created, vec!["a.txt".to_string()]);
137 assert_eq!(fs::read_to_string(root.join("a.txt")).unwrap(), "hello");
138 }
139
140 #[test]
141 fn skips_existing_without_force() {
142 let root = tmpdir();
143 fs::write(root.join("a.txt"), "original").unwrap();
144 let plan = Plan::new().create("a.txt", "new");
145 let report = Writer::new(Options::default()).apply(&root, &plan).unwrap();
146 assert_eq!(report.skipped, vec!["a.txt".to_string()]);
147 assert_eq!(fs::read_to_string(root.join("a.txt")).unwrap(), "original");
149 }
150
151 #[test]
152 fn overwrites_with_force() {
153 let root = tmpdir();
154 fs::write(root.join("a.txt"), "original").unwrap();
155 let plan = Plan::new().create("a.txt", "new");
156 let opts = Options {
157 force: true,
158 dry_run: false,
159 };
160 let report = Writer::new(opts).apply(&root, &plan).unwrap();
161 assert_eq!(report.overwritten, vec!["a.txt".to_string()]);
162 assert_eq!(fs::read_to_string(root.join("a.txt")).unwrap(), "new");
163 }
164
165 #[test]
166 fn dry_run_writes_nothing() {
167 let root = tmpdir();
168 let plan = Plan::new().create("a.txt", "hello");
169 let opts = Options {
170 force: false,
171 dry_run: true,
172 };
173 let report = Writer::new(opts).apply(&root, &plan).unwrap();
174 assert_eq!(report.created, vec!["a.txt".to_string()]);
175 assert!(!root.join("a.txt").exists());
176 }
177}