use std::collections::BTreeSet;
use std::fs;
use std::path::{Component, Path, PathBuf};
use miette::{IntoDiagnostic, Result, WrapErr};
use snafu::Snafu;
use super::atomic_write;
use super::model::{Node, SelectionState, Tree};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct ProjectsEntry {
pub text: String,
}
impl ProjectsEntry {
fn literal(path: &Path) -> Self {
Self {
text: workspace_absolute_str(path),
}
}
fn glob_under(path: &Path) -> Self {
let mut text = workspace_absolute_str(path);
text.push_str("/*");
Self { text }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScaffoldedHazYml {
pub project_root: PathBuf,
pub sanitised_name: String,
pub body: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct CommitPlan {
pub workspace_yml_body: String,
pub project_ymls: Vec<ScaffoldedHazYml>,
}
#[derive(Debug, Snafu)]
pub enum CommitPlanError {
#[snafu(display(
"could not derive a valid project name from directory `{directory}` (sanitised: `{sanitised}`)"
))]
SanitisationFailed {
directory: String,
sanitised: String,
},
}
pub fn plan_commit(tree: &Tree, cwd_host_root: &Path) -> Result<CommitPlan, CommitPlanError> {
let workspace_yml_body = render_workspace_yml(&projects_yml_entries(tree));
let mut project_ymls: Vec<ScaffoldedHazYml> = Vec::new();
for project_root_ws in &tree.project_root_set() {
let host_dir = host_join(cwd_host_root, project_root_ws);
if host_dir.join("haz.yml").exists() {
continue;
}
let basename = project_root_ws
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let sanitised = sanitise_project_name(&basename);
if !is_valid_project_name(&sanitised) {
return Err(CommitPlanError::SanitisationFailed {
directory: workspace_absolute_str(project_root_ws),
sanitised,
});
}
let body = format!("name: {sanitised}\ntasks: {{}}\n");
project_ymls.push(ScaffoldedHazYml {
project_root: project_root_ws.clone(),
sanitised_name: sanitised,
body,
});
}
Ok(CommitPlan {
workspace_yml_body,
project_ymls,
})
}
pub fn apply_commit(plan: &CommitPlan, cwd_host_root: &Path) -> Result<()> {
for scaffold in &plan.project_ymls {
let host_dir = host_join(cwd_host_root, &scaffold.project_root);
fs::create_dir_all(&host_dir)
.into_diagnostic()
.wrap_err_with(|| format!("failed to create directory `{}`", host_dir.display()))?;
atomic_write(&host_dir.join("haz.yml"), scaffold.body.as_bytes())?;
}
let haz_dir = cwd_host_root.join(".haz");
fs::create_dir_all(&haz_dir)
.into_diagnostic()
.wrap_err_with(|| format!("failed to create directory `{}`", haz_dir.display()))?;
atomic_write(
&haz_dir.join("workspace.yml"),
plan.workspace_yml_body.as_bytes(),
)?;
Ok(())
}
fn render_workspace_yml(entries: &BTreeSet<ProjectsEntry>) -> String {
if entries.is_empty() {
return "projects: []\n".to_owned();
}
let mut out = String::from("projects:\n");
for entry in entries {
out.push_str(" - ");
out.push_str(&entry.text);
out.push('\n');
}
out
}
fn projects_yml_entries(tree: &Tree) -> BTreeSet<ProjectsEntry> {
let mut out: BTreeSet<ProjectsEntry> = BTreeSet::new();
collect_entries(tree.root(), &mut out);
out
}
fn collect_entries(node: &Node, out: &mut BTreeSet<ProjectsEntry>) {
match node.state {
SelectionState::None => {}
SelectionState::Project => {
out.insert(ProjectsEntry::literal(&node.path));
}
SelectionState::ProjectsParent => {
out.insert(ProjectsEntry::glob_under(&node.path));
}
}
for child in &node.children {
collect_entries(child, out);
}
}
fn workspace_absolute_str(path: &Path) -> String {
let mut out = String::new();
for component in path.components() {
match component {
Component::RootDir => out.push('/'),
Component::Normal(seg) => {
if !out.ends_with('/') {
out.push('/');
}
out.push_str(&seg.to_string_lossy());
}
_ => {}
}
}
if out.is_empty() {
out.push('/');
}
out
}
fn host_join(cwd_host_root: &Path, workspace_absolute: &Path) -> PathBuf {
let mut host = cwd_host_root.to_path_buf();
for component in workspace_absolute.components() {
if let Component::Normal(seg) = component {
host.push(seg);
}
}
host
}
fn sanitise_project_name(raw: &str) -> String {
let replaced: String = raw
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
c
} else {
'-'
}
})
.collect();
let mut collapsed = String::with_capacity(replaced.len());
let mut prev_dash = false;
for ch in replaced.chars() {
if ch == '-' {
if !prev_dash {
collapsed.push('-');
}
prev_dash = true;
} else {
collapsed.push(ch);
prev_dash = false;
}
}
collapsed
.trim_start_matches(|c: char| !c.is_ascii_alphanumeric())
.to_owned()
}
fn is_valid_project_name(s: &str) -> bool {
if s.is_empty() || s.len() > 64 {
return false;
}
let mut chars = s.chars();
let first = chars.next().expect("non-empty");
if !first.is_ascii_alphanumeric() {
return false;
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}
#[must_use]
pub fn projects_entries(tree: &Tree) -> Vec<ProjectsEntry> {
projects_yml_entries(tree).into_iter().collect()
}
#[cfg(test)]
mod tests {
use std::ffi::OsString;
use std::fs;
use std::path::{Path, PathBuf};
use tempfile::TempDir;
use super::{
CommitPlanError, ProjectsEntry, apply_commit, host_join, is_valid_project_name,
plan_commit, projects_entries, render_workspace_yml, sanitise_project_name,
workspace_absolute_str,
};
use crate::commands::init::model::{Node, SelectionState, Tree};
fn node(name: &str, path: &str, state: SelectionState, children: Vec<Node>) -> Node {
let mut n = Node::new(OsString::from(name), PathBuf::from(path));
n.state = state;
n.children = children;
n.children_loaded = true;
n
}
#[test]
fn sanitise_preserves_valid_ascii_identifier_unchanged() {
assert_eq!(sanitise_project_name("lib_core"), "lib_core");
assert_eq!(sanitise_project_name("web-frontend"), "web-frontend");
assert_eq!(sanitise_project_name("X42"), "X42");
}
#[test]
fn sanitise_replaces_non_alphabet_runs_with_single_dash() {
assert_eq!(sanitise_project_name("My Project"), "My-Project");
assert_eq!(sanitise_project_name("a.b.c"), "a-b-c");
assert_eq!(sanitise_project_name("a b"), "a-b");
assert_eq!(sanitise_project_name("a!@#b"), "a-b");
}
#[test]
fn sanitise_trims_leading_dash_and_underscore() {
assert_eq!(sanitise_project_name("-lib"), "lib");
assert_eq!(sanitise_project_name("__lib"), "lib");
assert_eq!(sanitise_project_name("--lib"), "lib");
}
#[test]
fn sanitise_replaces_unicode_with_dashes() {
assert_eq!(sanitise_project_name("café"), "caf-");
assert_eq!(sanitise_project_name("naïve-name"), "na-ve-name");
}
#[test]
fn sanitise_yields_empty_when_basename_is_pure_punctuation() {
assert_eq!(sanitise_project_name("..."), "");
assert_eq!(sanitise_project_name("---"), "");
}
#[test]
fn is_valid_accepts_alphanumeric_underscore_hyphen() {
assert!(is_valid_project_name("lib"));
assert!(is_valid_project_name("lib_core"));
assert!(is_valid_project_name("web-frontend"));
assert!(is_valid_project_name("0snapshot"));
}
#[test]
fn is_valid_rejects_empty_and_overlong() {
assert!(!is_valid_project_name(""));
let long = "a".repeat(65);
assert!(!is_valid_project_name(&long));
}
#[test]
fn is_valid_rejects_leading_dash_or_underscore() {
assert!(!is_valid_project_name("-lib"));
assert!(!is_valid_project_name("_lib"));
}
#[test]
fn is_valid_rejects_forbidden_characters() {
assert!(!is_valid_project_name("a.b"));
assert!(!is_valid_project_name("a b"));
assert!(!is_valid_project_name("a/b"));
}
#[test]
fn workspace_absolute_str_renders_root_slash_segments() {
assert_eq!(workspace_absolute_str(Path::new("/lib")), "/lib");
assert_eq!(workspace_absolute_str(Path::new("/apps/web")), "/apps/web",);
}
#[test]
fn host_join_appends_segments_to_cwd_root() {
let cwd = PathBuf::from("/tmp/workspace");
assert_eq!(
host_join(&cwd, Path::new("/lib")),
PathBuf::from("/tmp/workspace/lib"),
);
assert_eq!(
host_join(&cwd, Path::new("/apps/web")),
PathBuf::from("/tmp/workspace/apps/web"),
);
}
#[test]
fn projects_entries_collects_literal_for_project_state() {
let mut root = node("root", "/", SelectionState::None, Vec::new());
root.children
.push(node("lib", "/lib", SelectionState::Project, Vec::new()));
let tree = Tree::new(root);
let entries = projects_entries(&tree);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].text, "/lib");
}
#[test]
fn projects_entries_collects_glob_for_projects_parent_state() {
let mut apps = node(
"apps",
"/apps",
SelectionState::ProjectsParent,
vec![node("api", "/apps/api", SelectionState::None, Vec::new())],
);
apps.expanded = true;
let mut root = node("root", "/", SelectionState::None, vec![apps]);
root.expanded = true;
let tree = Tree::new(root);
let entries = projects_entries(&tree);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].text, "/apps/*");
}
#[test]
fn projects_entries_mix_sorts_byte_ascending() {
let mut apps = node(
"apps",
"/apps",
SelectionState::ProjectsParent,
vec![node("api", "/apps/api", SelectionState::None, Vec::new())],
);
apps.expanded = true;
let mut lib = node("lib", "/lib", SelectionState::Project, Vec::new());
lib.children_loaded = true;
let mut root = node("root", "/", SelectionState::None, vec![apps, lib]);
root.expanded = true;
let tree = Tree::new(root);
let entries = projects_entries(&tree);
assert_eq!(
entries.iter().map(|e| e.text.clone()).collect::<Vec<_>>(),
vec!["/apps/*".to_owned(), "/lib".to_owned()],
);
}
#[test]
fn render_workspace_yml_empty_set_is_inline_empty_array() {
let empty = std::collections::BTreeSet::new();
assert_eq!(render_workspace_yml(&empty), "projects: []\n");
}
#[test]
fn render_workspace_yml_one_entry_is_yaml_list() {
let mut s = std::collections::BTreeSet::new();
s.insert(ProjectsEntry {
text: "/lib".to_owned(),
});
assert_eq!(render_workspace_yml(&s), "projects:\n - /lib\n");
}
#[test]
fn render_workspace_yml_multiple_entries_are_canonically_ordered() {
let mut s = std::collections::BTreeSet::new();
s.insert(ProjectsEntry {
text: "/lib".to_owned(),
});
s.insert(ProjectsEntry {
text: "/apps/*".to_owned(),
});
assert_eq!(
render_workspace_yml(&s),
"projects:\n - /apps/*\n - /lib\n",
);
}
fn tree_with_one_project(name: &str, ws_path: &str) -> Tree {
let mut root = node(
"root",
"/",
SelectionState::None,
vec![node(name, ws_path, SelectionState::Project, Vec::new())],
);
root.expanded = true;
Tree::new(root)
}
#[test]
fn plan_commit_scaffolds_haz_yml_for_each_project_root() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join("lib")).unwrap();
let tree = tree_with_one_project("lib", "/lib");
let plan = plan_commit(&tree, tmp.path()).unwrap();
assert_eq!(plan.workspace_yml_body, "projects:\n - /lib\n");
assert_eq!(plan.project_ymls.len(), 1);
assert_eq!(plan.project_ymls[0].sanitised_name, "lib");
assert_eq!(plan.project_ymls[0].body, "name: lib\ntasks: {}\n");
}
#[test]
fn plan_commit_skips_project_root_with_pre_existing_haz_yml() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join("lib")).unwrap();
fs::write(tmp.path().join("lib/haz.yml"), "name: hand_written\n").unwrap();
let tree = tree_with_one_project("lib", "/lib");
let plan = plan_commit(&tree, tmp.path()).unwrap();
assert_eq!(plan.workspace_yml_body, "projects:\n - /lib\n");
assert!(
plan.project_ymls.is_empty(),
"existing haz.yml must not be re-scaffolded: {:?}",
plan.project_ymls,
);
}
#[test]
fn plan_commit_sanitises_basename_to_valid_project_name() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join("My App")).unwrap();
let mut root = node(
"root",
"/",
SelectionState::None,
vec![node(
"My App",
"/My App",
SelectionState::Project,
Vec::new(),
)],
);
root.expanded = true;
let tree = Tree::new(root);
let plan = plan_commit(&tree, tmp.path()).unwrap();
assert_eq!(plan.project_ymls.len(), 1);
assert_eq!(plan.project_ymls[0].sanitised_name, "My-App");
}
#[test]
fn plan_commit_errors_when_basename_sanitises_to_empty() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join("---")).unwrap();
let mut root = node(
"root",
"/",
SelectionState::None,
vec![node("---", "/---", SelectionState::Project, Vec::new())],
);
root.expanded = true;
let tree = Tree::new(root);
let err = plan_commit(&tree, tmp.path()).unwrap_err();
match err {
CommitPlanError::SanitisationFailed {
directory,
sanitised,
} => {
assert_eq!(directory, "/---");
assert_eq!(sanitised, "");
}
}
}
#[test]
fn apply_commit_writes_haz_yml_then_workspace_yml() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join("lib")).unwrap();
let tree = tree_with_one_project("lib", "/lib");
let plan = plan_commit(&tree, tmp.path()).unwrap();
apply_commit(&plan, tmp.path()).unwrap();
let haz_yml = fs::read_to_string(tmp.path().join("lib/haz.yml")).unwrap();
assert_eq!(haz_yml, "name: lib\ntasks: {}\n");
let workspace_yml = fs::read_to_string(tmp.path().join(".haz/workspace.yml")).unwrap();
assert_eq!(workspace_yml, "projects:\n - /lib\n");
}
#[test]
fn apply_commit_creates_missing_haz_directory() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join("lib")).unwrap();
let tree = tree_with_one_project("lib", "/lib");
let plan = plan_commit(&tree, tmp.path()).unwrap();
assert!(!tmp.path().join(".haz").exists());
apply_commit(&plan, tmp.path()).unwrap();
assert!(tmp.path().join(".haz").exists());
}
#[test]
fn apply_commit_creates_missing_project_root_directory() {
let tmp = TempDir::new().unwrap();
let tree = tree_with_one_project("lib", "/lib");
let plan = plan_commit(&tree, tmp.path()).unwrap();
apply_commit(&plan, tmp.path()).unwrap();
assert!(tmp.path().join("lib/haz.yml").exists());
}
#[test]
fn apply_commit_with_empty_plan_writes_only_workspace_yml() {
let tmp = TempDir::new().unwrap();
let plan = super::CommitPlan {
workspace_yml_body: "projects: []\n".to_owned(),
project_ymls: Vec::new(),
};
apply_commit(&plan, tmp.path()).unwrap();
let body = fs::read_to_string(tmp.path().join(".haz/workspace.yml")).unwrap();
assert_eq!(body, "projects: []\n");
let top_entries: Vec<_> = fs::read_dir(tmp.path())
.unwrap()
.map(|e| e.unwrap().file_name())
.collect();
assert_eq!(top_entries, vec![std::ffi::OsString::from(".haz")]);
}
}