haz_cli/commands/init/
mod.rs1pub mod commit;
25pub mod model;
26pub mod tui;
27
28use std::fs;
29use std::io::Write;
30use std::path::{Path, PathBuf};
31
32use miette::{IntoDiagnostic, Result, WrapErr, miette};
33
34use crate::cli::InitArgs;
35use commit::{CommitPlan, apply_commit};
36use tui::{ScaffoldOutcome, run_interactive_scaffold};
37
38const MINIMAL_WORKSPACE_BODY: &str = "projects: []\n";
42
43pub fn execute(args: &InitArgs, cwd: &Path) -> Result<i32> {
58 let haz_dir = cwd.join(".haz");
59 if haz_dir.exists() {
60 return Err(miette!(
61 "haz init refuses to overlay an existing workspace at `{}` (AUX-002)",
62 haz_dir.display(),
63 ));
64 }
65
66 if !args.yes {
67 return run_interactive_scaffold_and_apply(cwd);
68 }
69
70 write_minimal_workspace(cwd)?;
71 Ok(0)
72}
73
74fn run_interactive_scaffold_and_apply(cwd: &Path) -> Result<i32> {
82 match run_interactive_scaffold(cwd)? {
83 ScaffoldOutcome::Confirmed(plan) => {
84 apply_commit(&plan, cwd).map_err(|err| miette!("{err}"))?;
85 Ok(0)
86 }
87 ScaffoldOutcome::Declined => Ok(0),
88 ScaffoldOutcome::Aborted => Ok(130),
89 }
90}
91
92fn write_minimal_workspace(cwd: &Path) -> Result<()> {
98 let plan = CommitPlan {
99 workspace_yml_body: MINIMAL_WORKSPACE_BODY.to_owned(),
100 project_ymls: Vec::new(),
101 };
102 apply_commit(&plan, cwd)
103}
104
105pub(super) fn atomic_write(target: &Path, contents: &[u8]) -> Result<()> {
113 let tmp = tmp_sibling_path(target)?;
114
115 let result = (|| -> Result<()> {
116 let mut file = fs::File::create(&tmp)
117 .into_diagnostic()
118 .wrap_err_with(|| format!("failed to create tmp file `{}`", tmp.display()))?;
119 file.write_all(contents)
120 .into_diagnostic()
121 .wrap_err_with(|| format!("failed to write tmp file `{}`", tmp.display()))?;
122 file.sync_all()
123 .into_diagnostic()
124 .wrap_err_with(|| format!("failed to fsync tmp file `{}`", tmp.display()))?;
125 drop(file);
126 fs::rename(&tmp, target)
127 .into_diagnostic()
128 .wrap_err_with(|| {
129 format!(
130 "failed to rename `{}` to `{}`",
131 tmp.display(),
132 target.display(),
133 )
134 })?;
135 Ok(())
136 })();
137
138 if result.is_err() {
139 let _ = fs::remove_file(&tmp);
144 }
145 result
146}
147
148fn tmp_sibling_path(target: &Path) -> Result<PathBuf> {
153 let parent = target
154 .parent()
155 .ok_or_else(|| miette!("target `{}` has no parent directory", target.display()))?;
156 let file_name = target
157 .file_name()
158 .ok_or_else(|| miette!("target `{}` has no file name", target.display()))?;
159 let suffix: u64 = rand::random();
160 let tmp_name = format!(".{}.tmp-{suffix:016x}", file_name.to_string_lossy());
161 Ok(parent.join(tmp_name))
162}
163
164#[cfg(test)]
165mod tests {
166 use std::fs;
167 use std::path::Path;
168
169 use tempfile::TempDir;
170
171 use super::{MINIMAL_WORKSPACE_BODY, atomic_write, execute};
172 use crate::cli::InitArgs;
173
174 #[test]
177 fn aux_007_yes_writes_minimal_workspace_yml() {
178 let tmp = TempDir::new().unwrap();
179 let args = InitArgs { yes: true };
180 let code = execute(&args, tmp.path()).unwrap();
181 assert_eq!(code, 0);
182 let body = fs::read_to_string(tmp.path().join(".haz/workspace.yml")).unwrap();
183 assert_eq!(body, MINIMAL_WORKSPACE_BODY);
184 }
185
186 #[test]
187 fn aux_007_yes_creates_only_workspace_yml_and_haz_dir() {
188 let tmp = TempDir::new().unwrap();
189 let args = InitArgs { yes: true };
190 execute(&args, tmp.path()).unwrap();
191 let entries: Vec<_> = fs::read_dir(tmp.path().join(".haz"))
193 .unwrap()
194 .map(|e| e.unwrap().file_name())
195 .collect();
196 assert_eq!(entries, vec![std::ffi::OsString::from("workspace.yml")]);
197 }
198
199 #[test]
202 fn aux_002_refuses_when_haz_dir_already_exists() {
203 let tmp = TempDir::new().unwrap();
204 fs::create_dir(tmp.path().join(".haz")).unwrap();
205 let args = InitArgs { yes: true };
206 let err = execute(&args, tmp.path()).unwrap_err();
207 let rendered = format!("{err}");
208 assert!(
209 rendered.contains("existing workspace"),
210 "diagnostic should name the conflict: {rendered}",
211 );
212 }
213
214 #[test]
215 fn aux_002_refuses_even_when_yes_is_unset() {
216 let tmp = TempDir::new().unwrap();
217 fs::create_dir(tmp.path().join(".haz")).unwrap();
218 let args = InitArgs { yes: false };
219 assert!(execute(&args, tmp.path()).is_err());
220 }
221
222 #[test]
225 fn aux_006_atomic_write_creates_target_with_expected_bytes() {
226 let tmp = TempDir::new().unwrap();
227 let target = tmp.path().join("hello.txt");
228 atomic_write(&target, b"hello, haz\n").unwrap();
229 let body = fs::read(&target).unwrap();
230 assert_eq!(body, b"hello, haz\n");
231 }
232
233 #[test]
234 fn aux_006_atomic_write_leaves_no_tmp_file_on_success() {
235 let tmp = TempDir::new().unwrap();
236 let target = tmp.path().join("hello.txt");
237 atomic_write(&target, b"hello, haz\n").unwrap();
238 let entries: Vec<_> = fs::read_dir(tmp.path())
242 .unwrap()
243 .map(|e| e.unwrap().file_name())
244 .collect();
245 assert_eq!(entries, vec![std::ffi::OsString::from("hello.txt")]);
246 }
247
248 #[test]
249 fn aux_006_atomic_write_fails_when_parent_is_not_a_directory() {
250 let tmp = TempDir::new().unwrap();
255 let target = tmp.path().join("missing-dir/hello.txt");
256 let err = atomic_write(&target, b"x").unwrap_err();
257 let rendered = format!("{err}");
258 assert!(
259 rendered.contains("failed to create tmp file"),
260 "diagnostic should pinpoint the failed step: {rendered}",
261 );
262 let entries: Vec<_> = fs::read_dir(tmp.path())
266 .unwrap()
267 .map(|e| e.unwrap().file_name())
268 .collect();
269 assert!(
270 entries.is_empty(),
271 "expected empty tempdir, got: {entries:?}"
272 );
273 }
274
275 fn read_to_string(path: &Path) -> String {
278 String::from_utf8(fs::read(path).unwrap()).unwrap()
279 }
280
281 #[test]
282 fn aux_007_workspace_yml_body_is_exactly_projects_empty_array() {
283 let tmp = TempDir::new().unwrap();
284 let args = InitArgs { yes: true };
285 execute(&args, tmp.path()).unwrap();
286 let body = read_to_string(&tmp.path().join(".haz/workspace.yml"));
287 assert_eq!(body, "projects: []\n");
288 }
289}