Skip to main content

haz_cli/commands/init/
mod.rs

1//! `haz init` per `AUX-001`..`AUX-008`.
2//!
3//! Two invocation shapes are normative:
4//!
5//! - `haz init` (bare): the interactive scaffold flow
6//!   (`AUX-003`..`AUX-005`). The pure state-machine layer of the
7//!   interactive tree lives in [`model`], the AUX-005 / AUX-006
8//!   commit pass in [`commit`], and the ratatui driver in
9//!   [`tui`]. On confirmation, [`commit::apply_commit`] writes
10//!   the planned files; declines exit `0` without changes per
11//!   `AUX-005` step 4; `Ctrl+C` aborts with the EXEC-021
12//!   signal-interrupt code.
13//! - `haz init --yes`: writes a minimal
14//!   `<cwd>/.haz/workspace.yml` containing exactly
15//!   `projects: []` per `AUX-007`. The body is constructed as an
16//!   empty [`commit::CommitPlan`] and shipped through the same
17//!   [`commit::apply_commit`] code path the interactive arm uses,
18//!   so both shapes share the rename-into-place pattern
19//!   (`AUX-006`).
20//!
21//! Both shapes refuse to overlay an existing `.haz/` per
22//! `AUX-002`.
23
24pub 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
38/// Body written by `--yes` per `AUX-007`. The trailing newline is
39/// intentional: it leaves the file POSIX-text-shaped and matches
40/// the literal example the spec quotes.
41const MINIMAL_WORKSPACE_BODY: &str = "projects: []\n";
42
43/// Execute `haz init` against `cwd`.
44///
45/// Returns `0` on a successful scaffold per `AUX-008`. Conflicts
46/// (`AUX-002`) and filesystem write failures map to a non-zero
47/// return through the internal-or-configuration-error class.
48///
49/// # Errors
50///
51/// Returns a [`miette::Report`] when `<cwd>/.haz/` already
52/// exists, when the interactive flow surfaces a commit-plan
53/// failure (e.g. AUX-005 name sanitisation), or when any
54/// filesystem step (mkdir, tmp write, fsync, rename) fails. The
55/// binary maps the report to
56/// [`crate::EXIT_INTERNAL_OR_CONFIG_ERROR`].
57pub 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
74/// Drive the interactive scaffold flow and apply the plan on
75/// confirmation. Exit codes follow `AUX-008`:
76///
77/// - confirmed and applied successfully -> `0`,
78/// - user declined the confirmation -> `0` with no changes,
79/// - user aborted via `Ctrl+C` -> `130` per the EXEC-021 signal
80///   class.
81fn 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
92/// Write the `AUX-007` minimal workspace: create `<cwd>/.haz/`
93/// and drop a `workspace.yml` containing `projects: []` inside,
94/// using the shared [`commit::apply_commit`] path so the
95/// rename-into-place pattern of `AUX-006` lives in exactly one
96/// place.
97fn 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
105/// Write `contents` to `target` atomically per `AUX-006`: write a
106/// sibling temp file, fsync it, then rename onto `target`.
107///
108/// A crash before the rename leaves the temp file in place; this
109/// function removes it on its own failure path so `haz init`
110/// never leaks state inside `.haz/`. A crash *during* the rename
111/// is bounded by `rename(2)`'s atomicity on a single filesystem.
112pub(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        // `AUX-006` requires removing leftover temp paths before
140        // returning. Best-effort cleanup: the underlying error is
141        // already surfaced and a stranded tmp file is not worse
142        // than the original failure.
143        let _ = fs::remove_file(&tmp);
144    }
145    result
146}
147
148/// Pick a sibling-of-`target` path under the same parent that no
149/// other process should be holding. 64 bits of randomness is
150/// overwhelming for the collision domain (a single `haz init`
151/// invocation on a single host).
152fn 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    // --- AUX-007: --yes writes the minimal workspace ---------
175
176    #[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        // `.haz/` exists and contains exactly one entry (workspace.yml).
192        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    // --- AUX-002: refuse on an existing workspace -----------
200
201    #[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    // --- AUX-006: atomic write ------------------------------
223
224    #[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        // After a successful rename, the parent directory contains
239        // exactly one entry (the target). No `.hello.txt.tmp-*`
240        // siblings remain.
241        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        // Use a path whose parent directory doesn't exist: the
251        // tmp `fs::File::create` cannot make it, so atomic_write
252        // returns Err and (per AUX-006) leaves no leftover tmp
253        // file.
254        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        // Tempdir parent should still be empty (no orphan
263        // `missing-dir/` directory left behind by us, no orphan
264        // tmp files).
265        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    // --- aux_007_yes path produces a parsable workspace.yml ---
276
277    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}