1use 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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
36pub struct ProjectsEntry {
37 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#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct ScaffoldedHazYml {
59 pub project_root: PathBuf,
61 pub sanitised_name: String,
64 pub body: String,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Default)]
70pub struct CommitPlan {
71 pub workspace_yml_body: String,
73 pub project_ymls: Vec<ScaffoldedHazYml>,
77}
78
79#[derive(Debug, Snafu)]
85pub enum CommitPlanError {
86 #[snafu(display(
90 "could not derive a valid project name from directory `{directory}` (sanitised: `{sanitised}`)"
91 ))]
92 SanitisationFailed {
93 directory: String,
95 sanitised: String,
97 },
98}
99
100pub 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
144pub 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
180fn 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
199fn 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
222fn 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 _ => {}
239 }
240 }
241 if out.is_empty() {
242 out.push('/');
243 }
244 out
245}
246
247fn 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
259fn sanitise_project_name(raw: &str) -> String {
264 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 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 collapsed
293 .trim_start_matches(|c: char| !c.is_ascii_alphanumeric())
294 .to_owned()
295}
296
297fn 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#[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 #[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 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 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 #[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 #[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 #[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 assert_eq!(
480 entries.iter().map(|e| e.text.clone()).collect::<Vec<_>>(),
481 vec!["/apps/*".to_owned(), "/lib".to_owned()],
482 );
483 }
484
485 #[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 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 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 #[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 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 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 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}