Skip to main content

haz_cli/commands/init/
commit.rs

1//! Commit pass for `haz init` per `AUX-005`..`AUX-006`.
2//!
3//! Once the interactive flow has finalised a selection in
4//! [`super::model::Tree`], the commit pass:
5//!
6//! 1. Computes the project-root set (`AUX-005` step 1, delegated
7//!    to the tree).
8//! 2. Sanitises each project root's basename to an
9//!    `ID-001..ID-005`-valid `ProjectName` (`AUX-005` step 2).
10//! 3. Renders the final `.haz/workspace.yml` body and the per-
11//!    project `haz.yml` bodies (`AUX-005` step 3 listing data;
12//!    the UI prompt for confirmation lives in the TUI driver).
13//! 4. Applies the plan to disk via `AUX-006` rename-into-place,
14//!    writing every `haz.yml` first and `.haz/workspace.yml`
15//!    last so an interrupted run never leaves a workspace
16//!    referencing a non-existent `haz.yml`.
17//!
18//! Existing `haz.yml` files are not overwritten per `AUX-005`
19//! step 2 ("for each path... that does not contain a `haz.yml`").
20
21use std::collections::BTreeSet;
22use std::fs;
23use std::path::{Component, Path, PathBuf};
24
25use miette::{IntoDiagnostic, Result, WrapErr};
26use snafu::Snafu;
27
28use super::atomic_write;
29use super::model::{Node, SelectionState, Tree};
30
31/// A single entry that will land in `.haz/workspace.yml`'s
32/// `projects:` list. Ordering is byte-ascending on
33/// [`Self::text`] so the AUX-005 step 3 listing and the eventual
34/// `workspace.yml` are byte-stable across runs.
35#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
36pub struct ProjectsEntry {
37    /// The workspace-absolute string form (a literal `/<dir>`
38    /// per [`SelectionState::Project`] or a glob `/<dir>/*` per
39    /// [`SelectionState::ProjectsParent`]).
40    pub text: String,
41}
42
43impl ProjectsEntry {
44    fn literal(path: &Path) -> Self {
45        Self {
46            text: workspace_absolute_str(path),
47        }
48    }
49    fn glob_under(path: &Path) -> Self {
50        let mut text = workspace_absolute_str(path);
51        text.push_str("/*");
52        Self { text }
53    }
54}
55
56/// A new `haz.yml` file to scaffold per `AUX-005` step 2.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct ScaffoldedHazYml {
59    /// Workspace-absolute directory the file will land under.
60    pub project_root: PathBuf,
61    /// Sanitised `ProjectName` per `AUX-005` step 2 and
62    /// `ID-001..ID-005`.
63    pub sanitised_name: String,
64    /// Complete file body.
65    pub body: String,
66}
67
68/// The full commit plan derived from a finalised [`Tree`].
69#[derive(Debug, Clone, PartialEq, Eq, Default)]
70pub struct CommitPlan {
71    /// Final `.haz/workspace.yml` body (already-rendered YAML).
72    pub workspace_yml_body: String,
73    /// `haz.yml` files to scaffold, ordered by `project_root`.
74    /// Projects whose root already contains a `haz.yml` are
75    /// omitted per `AUX-005` step 2.
76    pub project_ymls: Vec<ScaffoldedHazYml>,
77}
78
79/// Sanitisation failure surfaced during [`plan_commit`].
80///
81/// `AUX-005` step 2 names this as the one user-facing failure
82/// mode of the planning step; filesystem I/O failures live in
83/// [`apply_commit`].
84#[derive(Debug, Snafu)]
85pub enum CommitPlanError {
86    /// `AUX-005` step 2: sanitising a directory's basename
87    /// produced an empty string or a name that violates one of
88    /// the `ID-001`..`ID-005` rules.
89    #[snafu(display(
90        "could not derive a valid project name from directory `{directory}` (sanitised: `{sanitised}`)"
91    ))]
92    SanitisationFailed {
93        /// Workspace-absolute directory string for diagnostics.
94        directory: String,
95        /// The sanitised string that failed validation.
96        sanitised: String,
97    },
98}
99
100/// Compute the [`CommitPlan`] for `tree`, anchoring relative
101/// paths against `cwd_host_root`.
102///
103/// The function consults the filesystem only to test whether a
104/// `haz.yml` already exists under each project root; it never
105/// writes anything (that's [`apply_commit`]'s job).
106///
107/// # Errors
108///
109/// Returns [`CommitPlanError::SanitisationFailed`] when a project
110/// root's basename cannot be sanitised to a valid `ProjectName`.
111pub fn plan_commit(tree: &Tree, cwd_host_root: &Path) -> Result<CommitPlan, CommitPlanError> {
112    let workspace_yml_body = render_workspace_yml(&projects_yml_entries(tree));
113
114    let mut project_ymls: Vec<ScaffoldedHazYml> = Vec::new();
115    for project_root_ws in &tree.project_root_set() {
116        let host_dir = host_join(cwd_host_root, project_root_ws);
117        if host_dir.join("haz.yml").exists() {
118            continue;
119        }
120        let basename = project_root_ws
121            .file_name()
122            .map(|n| n.to_string_lossy().into_owned())
123            .unwrap_or_default();
124        let sanitised = sanitise_project_name(&basename);
125        if !is_valid_project_name(&sanitised) {
126            return Err(CommitPlanError::SanitisationFailed {
127                directory: workspace_absolute_str(project_root_ws),
128                sanitised,
129            });
130        }
131        let body = format!("name: {sanitised}\ntasks: {{}}\n");
132        project_ymls.push(ScaffoldedHazYml {
133            project_root: project_root_ws.clone(),
134            sanitised_name: sanitised,
135            body,
136        });
137    }
138    Ok(CommitPlan {
139        workspace_yml_body,
140        project_ymls,
141    })
142}
143
144/// Apply `plan` to disk per `AUX-006`: write every `haz.yml`
145/// first, then `.haz/workspace.yml` last, each via the
146/// rename-into-place pattern.
147///
148/// `cwd_host_root` is the host filesystem location that becomes
149/// the workspace root; the function joins workspace-absolute
150/// paths from `plan` against it to compute host paths.
151///
152/// # Errors
153///
154/// Returns a [`miette::Report`] on any filesystem failure (mkdir,
155/// tmp write, fsync, rename). The function makes no attempt to
156/// roll back files written earlier in the sequence; the
157/// `haz.yml`-then-`workspace.yml` ordering is itself the
158/// safety property `AUX-006` names: an interrupted run leaves a
159/// consistent state because workspace.yml never references
160/// missing `haz.yml`s.
161pub fn apply_commit(plan: &CommitPlan, cwd_host_root: &Path) -> Result<()> {
162    for scaffold in &plan.project_ymls {
163        let host_dir = host_join(cwd_host_root, &scaffold.project_root);
164        fs::create_dir_all(&host_dir)
165            .into_diagnostic()
166            .wrap_err_with(|| format!("failed to create directory `{}`", host_dir.display()))?;
167        atomic_write(&host_dir.join("haz.yml"), scaffold.body.as_bytes())?;
168    }
169    let haz_dir = cwd_host_root.join(".haz");
170    fs::create_dir_all(&haz_dir)
171        .into_diagnostic()
172        .wrap_err_with(|| format!("failed to create directory `{}`", haz_dir.display()))?;
173    atomic_write(
174        &haz_dir.join("workspace.yml"),
175        plan.workspace_yml_body.as_bytes(),
176    )?;
177    Ok(())
178}
179
180/// Render the `projects:` block of `.haz/workspace.yml` from a
181/// pre-collected, ordered set of entries.
182///
183/// Empty input yields the literal `projects: []\n` form per
184/// `AUX-007`. Non-empty input yields one indented list item per
185/// entry.
186fn render_workspace_yml(entries: &BTreeSet<ProjectsEntry>) -> String {
187    if entries.is_empty() {
188        return "projects: []\n".to_owned();
189    }
190    let mut out = String::from("projects:\n");
191    for entry in entries {
192        out.push_str("  - ");
193        out.push_str(&entry.text);
194        out.push('\n');
195    }
196    out
197}
198
199/// Collect every `Project` and `ProjectsParent` node in `tree`
200/// into the ordered set of `projects:` entries.
201fn projects_yml_entries(tree: &Tree) -> BTreeSet<ProjectsEntry> {
202    let mut out: BTreeSet<ProjectsEntry> = BTreeSet::new();
203    collect_entries(tree.root(), &mut out);
204    out
205}
206
207fn collect_entries(node: &Node, out: &mut BTreeSet<ProjectsEntry>) {
208    match node.state {
209        SelectionState::None => {}
210        SelectionState::Project => {
211            out.insert(ProjectsEntry::literal(&node.path));
212        }
213        SelectionState::ProjectsParent => {
214            out.insert(ProjectsEntry::glob_under(&node.path));
215        }
216    }
217    for child in &node.children {
218        collect_entries(child, out);
219    }
220}
221
222/// Render a workspace-absolute path (a [`PathBuf`] starting with
223/// `RootDir`) as its `/<segment>/<segment>...` string form.
224fn workspace_absolute_str(path: &Path) -> String {
225    let mut out = String::new();
226    for component in path.components() {
227        match component {
228            Component::RootDir => out.push('/'),
229            Component::Normal(seg) => {
230                if !out.ends_with('/') {
231                    out.push('/');
232                }
233                out.push_str(&seg.to_string_lossy());
234            }
235            // `CurDir` / `ParentDir` / `Prefix` are excluded by
236            // the path grammar of PATH-002; the tree model never
237            // produces them. Skip defensively.
238            _ => {}
239        }
240    }
241    if out.is_empty() {
242        out.push('/');
243    }
244    out
245}
246
247/// Join a workspace-absolute path onto `cwd_host_root` to recover
248/// the host filesystem location.
249fn host_join(cwd_host_root: &Path, workspace_absolute: &Path) -> PathBuf {
250    let mut host = cwd_host_root.to_path_buf();
251    for component in workspace_absolute.components() {
252        if let Component::Normal(seg) = component {
253            host.push(seg);
254        }
255    }
256    host
257}
258
259/// Sanitise `raw` to an `ID-001..ID-005` candidate per `AUX-005`
260/// step 2: replace non-alphabet runs with `-` and trim leading
261/// non-`[A-Za-z0-9]` characters. The caller validates the
262/// result with [`is_valid_project_name`].
263fn sanitise_project_name(raw: &str) -> String {
264    // Per ID-001 the identifier alphabet is [A-Za-z0-9_-]. Any
265    // codepoint outside that set is replaced with `-`.
266    let replaced: String = raw
267        .chars()
268        .map(|c| {
269            if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
270                c
271            } else {
272                '-'
273            }
274        })
275        .collect();
276    // Collapse runs of `-` so a long stretch of forbidden chars
277    // becomes a single separator instead of `------`.
278    let mut collapsed = String::with_capacity(replaced.len());
279    let mut prev_dash = false;
280    for ch in replaced.chars() {
281        if ch == '-' {
282            if !prev_dash {
283                collapsed.push('-');
284            }
285            prev_dash = true;
286        } else {
287            collapsed.push(ch);
288            prev_dash = false;
289        }
290    }
291    // Trim leading non-[A-Za-z0-9] (i.e., '-' and '_').
292    collapsed
293        .trim_start_matches(|c: char| !c.is_ascii_alphanumeric())
294        .to_owned()
295}
296
297/// Validate `s` against `ID-001..ID-005` for use as a
298/// `ProjectName`.
299fn is_valid_project_name(s: &str) -> bool {
300    if s.is_empty() || s.len() > 64 {
301        return false;
302    }
303    let mut chars = s.chars();
304    let first = chars.next().expect("non-empty");
305    if !first.is_ascii_alphanumeric() {
306        return false;
307    }
308    chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
309}
310
311/// Convenience accessor for tests / callers that want to inspect
312/// the `projects:` entries without forcing a string render.
313#[must_use]
314pub fn projects_entries(tree: &Tree) -> Vec<ProjectsEntry> {
315    projects_yml_entries(tree).into_iter().collect()
316}
317
318#[cfg(test)]
319mod tests {
320    use std::ffi::OsString;
321    use std::fs;
322    use std::path::{Path, PathBuf};
323
324    use tempfile::TempDir;
325
326    use super::{
327        CommitPlanError, ProjectsEntry, apply_commit, host_join, is_valid_project_name,
328        plan_commit, projects_entries, render_workspace_yml, sanitise_project_name,
329        workspace_absolute_str,
330    };
331    use crate::commands::init::model::{Node, SelectionState, Tree};
332
333    fn node(name: &str, path: &str, state: SelectionState, children: Vec<Node>) -> Node {
334        let mut n = Node::new(OsString::from(name), PathBuf::from(path));
335        n.state = state;
336        n.children = children;
337        n.children_loaded = true;
338        n
339    }
340
341    // --- sanitise_project_name --------------------------------
342
343    #[test]
344    fn sanitise_preserves_valid_ascii_identifier_unchanged() {
345        assert_eq!(sanitise_project_name("lib_core"), "lib_core");
346        assert_eq!(sanitise_project_name("web-frontend"), "web-frontend");
347        assert_eq!(sanitise_project_name("X42"), "X42");
348    }
349
350    #[test]
351    fn sanitise_replaces_non_alphabet_runs_with_single_dash() {
352        assert_eq!(sanitise_project_name("My Project"), "My-Project");
353        assert_eq!(sanitise_project_name("a.b.c"), "a-b-c");
354        // A run of forbidden chars collapses to a single `-`.
355        assert_eq!(sanitise_project_name("a   b"), "a-b");
356        assert_eq!(sanitise_project_name("a!@#b"), "a-b");
357    }
358
359    #[test]
360    fn sanitise_trims_leading_dash_and_underscore() {
361        assert_eq!(sanitise_project_name("-lib"), "lib");
362        assert_eq!(sanitise_project_name("__lib"), "lib");
363        assert_eq!(sanitise_project_name("--lib"), "lib");
364    }
365
366    #[test]
367    fn sanitise_replaces_unicode_with_dashes() {
368        // Non-ASCII codepoints are forbidden per ID-001; they
369        // become `-`. Trailing dashes survive (only leading
370        // non-alphanumeric characters are trimmed per AUX-005
371        // step 2), and ID-002 only constrains the leading char.
372        assert_eq!(sanitise_project_name("café"), "caf-");
373        assert_eq!(sanitise_project_name("naïve-name"), "na-ve-name");
374    }
375
376    #[test]
377    fn sanitise_yields_empty_when_basename_is_pure_punctuation() {
378        assert_eq!(sanitise_project_name("..."), "");
379        assert_eq!(sanitise_project_name("---"), "");
380    }
381
382    // --- is_valid_project_name --------------------------------
383
384    #[test]
385    fn is_valid_accepts_alphanumeric_underscore_hyphen() {
386        assert!(is_valid_project_name("lib"));
387        assert!(is_valid_project_name("lib_core"));
388        assert!(is_valid_project_name("web-frontend"));
389        assert!(is_valid_project_name("0snapshot"));
390    }
391
392    #[test]
393    fn is_valid_rejects_empty_and_overlong() {
394        assert!(!is_valid_project_name(""));
395        let long = "a".repeat(65);
396        assert!(!is_valid_project_name(&long));
397    }
398
399    #[test]
400    fn is_valid_rejects_leading_dash_or_underscore() {
401        assert!(!is_valid_project_name("-lib"));
402        assert!(!is_valid_project_name("_lib"));
403    }
404
405    #[test]
406    fn is_valid_rejects_forbidden_characters() {
407        assert!(!is_valid_project_name("a.b"));
408        assert!(!is_valid_project_name("a b"));
409        assert!(!is_valid_project_name("a/b"));
410    }
411
412    // --- workspace_absolute_str / host_join -------------------
413
414    #[test]
415    fn workspace_absolute_str_renders_root_slash_segments() {
416        assert_eq!(workspace_absolute_str(Path::new("/lib")), "/lib");
417        assert_eq!(workspace_absolute_str(Path::new("/apps/web")), "/apps/web",);
418    }
419
420    #[test]
421    fn host_join_appends_segments_to_cwd_root() {
422        let cwd = PathBuf::from("/tmp/workspace");
423        assert_eq!(
424            host_join(&cwd, Path::new("/lib")),
425            PathBuf::from("/tmp/workspace/lib"),
426        );
427        assert_eq!(
428            host_join(&cwd, Path::new("/apps/web")),
429            PathBuf::from("/tmp/workspace/apps/web"),
430        );
431    }
432
433    // --- projects_entries on tree shapes -----------------------
434
435    #[test]
436    fn projects_entries_collects_literal_for_project_state() {
437        let mut root = node("root", "/", SelectionState::None, Vec::new());
438        root.children
439            .push(node("lib", "/lib", SelectionState::Project, Vec::new()));
440        let tree = Tree::new(root);
441        let entries = projects_entries(&tree);
442        assert_eq!(entries.len(), 1);
443        assert_eq!(entries[0].text, "/lib");
444    }
445
446    #[test]
447    fn projects_entries_collects_glob_for_projects_parent_state() {
448        let mut apps = node(
449            "apps",
450            "/apps",
451            SelectionState::ProjectsParent,
452            vec![node("api", "/apps/api", SelectionState::None, Vec::new())],
453        );
454        apps.expanded = true;
455        let mut root = node("root", "/", SelectionState::None, vec![apps]);
456        root.expanded = true;
457        let tree = Tree::new(root);
458        let entries = projects_entries(&tree);
459        assert_eq!(entries.len(), 1);
460        assert_eq!(entries[0].text, "/apps/*");
461    }
462
463    #[test]
464    fn projects_entries_mix_sorts_byte_ascending() {
465        let mut apps = node(
466            "apps",
467            "/apps",
468            SelectionState::ProjectsParent,
469            vec![node("api", "/apps/api", SelectionState::None, Vec::new())],
470        );
471        apps.expanded = true;
472        let mut lib = node("lib", "/lib", SelectionState::Project, Vec::new());
473        lib.children_loaded = true;
474        let mut root = node("root", "/", SelectionState::None, vec![apps, lib]);
475        root.expanded = true;
476        let tree = Tree::new(root);
477        let entries = projects_entries(&tree);
478        // `/apps/*` sorts before `/lib` lexicographically.
479        assert_eq!(
480            entries.iter().map(|e| e.text.clone()).collect::<Vec<_>>(),
481            vec!["/apps/*".to_owned(), "/lib".to_owned()],
482        );
483    }
484
485    // --- render_workspace_yml --------------------------------
486
487    #[test]
488    fn render_workspace_yml_empty_set_is_inline_empty_array() {
489        let empty = std::collections::BTreeSet::new();
490        assert_eq!(render_workspace_yml(&empty), "projects: []\n");
491    }
492
493    #[test]
494    fn render_workspace_yml_one_entry_is_yaml_list() {
495        let mut s = std::collections::BTreeSet::new();
496        s.insert(ProjectsEntry {
497            text: "/lib".to_owned(),
498        });
499        assert_eq!(render_workspace_yml(&s), "projects:\n  - /lib\n");
500    }
501
502    #[test]
503    fn render_workspace_yml_multiple_entries_are_canonically_ordered() {
504        let mut s = std::collections::BTreeSet::new();
505        s.insert(ProjectsEntry {
506            text: "/lib".to_owned(),
507        });
508        s.insert(ProjectsEntry {
509            text: "/apps/*".to_owned(),
510        });
511        assert_eq!(
512            render_workspace_yml(&s),
513            "projects:\n  - /apps/*\n  - /lib\n",
514        );
515    }
516
517    // --- plan_commit ------------------------------------------
518
519    fn tree_with_one_project(name: &str, ws_path: &str) -> Tree {
520        let mut root = node(
521            "root",
522            "/",
523            SelectionState::None,
524            vec![node(name, ws_path, SelectionState::Project, Vec::new())],
525        );
526        root.expanded = true;
527        Tree::new(root)
528    }
529
530    #[test]
531    fn plan_commit_scaffolds_haz_yml_for_each_project_root() {
532        let tmp = TempDir::new().unwrap();
533        fs::create_dir_all(tmp.path().join("lib")).unwrap();
534        let tree = tree_with_one_project("lib", "/lib");
535        let plan = plan_commit(&tree, tmp.path()).unwrap();
536        assert_eq!(plan.workspace_yml_body, "projects:\n  - /lib\n");
537        assert_eq!(plan.project_ymls.len(), 1);
538        assert_eq!(plan.project_ymls[0].sanitised_name, "lib");
539        assert_eq!(plan.project_ymls[0].body, "name: lib\ntasks: {}\n");
540    }
541
542    #[test]
543    fn plan_commit_skips_project_root_with_pre_existing_haz_yml() {
544        let tmp = TempDir::new().unwrap();
545        fs::create_dir_all(tmp.path().join("lib")).unwrap();
546        fs::write(tmp.path().join("lib/haz.yml"), "name: hand_written\n").unwrap();
547        let tree = tree_with_one_project("lib", "/lib");
548        let plan = plan_commit(&tree, tmp.path()).unwrap();
549        assert_eq!(plan.workspace_yml_body, "projects:\n  - /lib\n");
550        assert!(
551            plan.project_ymls.is_empty(),
552            "existing haz.yml must not be re-scaffolded: {:?}",
553            plan.project_ymls,
554        );
555    }
556
557    #[test]
558    fn plan_commit_sanitises_basename_to_valid_project_name() {
559        let tmp = TempDir::new().unwrap();
560        fs::create_dir_all(tmp.path().join("My App")).unwrap();
561        let mut root = node(
562            "root",
563            "/",
564            SelectionState::None,
565            vec![node(
566                "My App",
567                "/My App",
568                SelectionState::Project,
569                Vec::new(),
570            )],
571        );
572        root.expanded = true;
573        let tree = Tree::new(root);
574        let plan = plan_commit(&tree, tmp.path()).unwrap();
575        assert_eq!(plan.project_ymls.len(), 1);
576        assert_eq!(plan.project_ymls[0].sanitised_name, "My-App");
577    }
578
579    #[test]
580    fn plan_commit_errors_when_basename_sanitises_to_empty() {
581        let tmp = TempDir::new().unwrap();
582        // A directory whose basename is pure punctuation
583        // sanitises to "" and fails validation.
584        fs::create_dir_all(tmp.path().join("---")).unwrap();
585        let mut root = node(
586            "root",
587            "/",
588            SelectionState::None,
589            vec![node("---", "/---", SelectionState::Project, Vec::new())],
590        );
591        root.expanded = true;
592        let tree = Tree::new(root);
593        let err = plan_commit(&tree, tmp.path()).unwrap_err();
594        match err {
595            CommitPlanError::SanitisationFailed {
596                directory,
597                sanitised,
598            } => {
599                assert_eq!(directory, "/---");
600                assert_eq!(sanitised, "");
601            }
602        }
603    }
604
605    // --- apply_commit -----------------------------------------
606
607    #[test]
608    fn apply_commit_writes_haz_yml_then_workspace_yml() {
609        let tmp = TempDir::new().unwrap();
610        fs::create_dir_all(tmp.path().join("lib")).unwrap();
611        let tree = tree_with_one_project("lib", "/lib");
612        let plan = plan_commit(&tree, tmp.path()).unwrap();
613        apply_commit(&plan, tmp.path()).unwrap();
614
615        let haz_yml = fs::read_to_string(tmp.path().join("lib/haz.yml")).unwrap();
616        assert_eq!(haz_yml, "name: lib\ntasks: {}\n");
617
618        let workspace_yml = fs::read_to_string(tmp.path().join(".haz/workspace.yml")).unwrap();
619        assert_eq!(workspace_yml, "projects:\n  - /lib\n");
620    }
621
622    #[test]
623    fn apply_commit_creates_missing_haz_directory() {
624        let tmp = TempDir::new().unwrap();
625        fs::create_dir_all(tmp.path().join("lib")).unwrap();
626        let tree = tree_with_one_project("lib", "/lib");
627        let plan = plan_commit(&tree, tmp.path()).unwrap();
628        assert!(!tmp.path().join(".haz").exists());
629        apply_commit(&plan, tmp.path()).unwrap();
630        assert!(tmp.path().join(".haz").exists());
631    }
632
633    #[test]
634    fn apply_commit_creates_missing_project_root_directory() {
635        // `projects-parent` semantics could in principle expand
636        // to a subdirectory that the user named but doesn't yet
637        // exist on disk; the executor MUST create it before
638        // writing haz.yml.
639        let tmp = TempDir::new().unwrap();
640        let tree = tree_with_one_project("lib", "/lib");
641        let plan = plan_commit(&tree, tmp.path()).unwrap();
642        apply_commit(&plan, tmp.path()).unwrap();
643        assert!(tmp.path().join("lib/haz.yml").exists());
644    }
645
646    #[test]
647    fn apply_commit_with_empty_plan_writes_only_workspace_yml() {
648        // `--yes`'s `projects: []` shape: no haz.ymls to
649        // scaffold, just the workspace.yml.
650        let tmp = TempDir::new().unwrap();
651        let plan = super::CommitPlan {
652            workspace_yml_body: "projects: []\n".to_owned(),
653            project_ymls: Vec::new(),
654        };
655        apply_commit(&plan, tmp.path()).unwrap();
656        let body = fs::read_to_string(tmp.path().join(".haz/workspace.yml")).unwrap();
657        assert_eq!(body, "projects: []\n");
658        // Only the `.haz/` directory was created.
659        let top_entries: Vec<_> = fs::read_dir(tmp.path())
660            .unwrap()
661            .map(|e| e.unwrap().file_name())
662            .collect();
663        assert_eq!(top_entries, vec![std::ffi::OsString::from(".haz")]);
664    }
665}