use std::collections::BTreeMap;
use std::path::Path;
use haz_dag::construction::build_task_graph;
use haz_dag::graph::TaskGraph;
use haz_discovery::discover::{DiscoveredWorkspace, DiscoveryError};
use haz_discovery::discovery::Discovery;
use haz_discovery::overlays::load::{LoadOverlayError, LoadOverlayErrors, overlays_map};
use haz_discovery::projects::load::{LoadError, LoadErrors};
use haz_discovery::workspace::load::WorkspaceLoadError;
use haz_domain::name::ProjectName;
use haz_domain::project::Project;
use haz_domain::workspace::Workspace;
use haz_vfs::{Filesystem, StdFilesystem};
use miette::{IntoDiagnostic, NamedSource, Report, Result, WrapErr};
pub fn load_workspace(starting_dir: &Path) -> Result<(Workspace, TaskGraph)> {
let fs = StdFilesystem::new();
let discovered = Discovery::new(&fs)
.discover_workspace(starting_dir)
.map_err(|e| discovery_error_to_report(e, starting_dir))?;
let workspace = assemble_domain_workspace(discovered);
let (graph, effective_workspace) = build_task_graph(&workspace)
.into_diagnostic()
.wrap_err("task-graph construction failed")?;
Ok((effective_workspace, graph))
}
fn discovery_error_to_report(err: DiscoveryError, starting_dir: &Path) -> Report {
let wrap = || {
format!(
"workspace discovery failed starting from {}",
starting_dir.display(),
)
};
match err {
DiscoveryError::LoadProjects { source } => match take_first_parse(source) {
Ok((path, text, inner)) => rich_parse_report(&path, text, *inner),
Err(load_err) => Report::new(load_err).wrap_err(wrap()),
},
DiscoveryError::LoadImplicitProject { source } => match source {
LoadError::Parse {
path,
source_text,
source,
..
} => rich_parse_report(&path, source_text, *source),
other => Report::new(other).wrap_err(wrap()),
},
DiscoveryError::LoadWorkspaceConfig { source } => match source {
WorkspaceLoadError::Parse {
path,
source_text,
source,
} => rich_parse_report(&path, source_text, *source),
other => Report::new(other).wrap_err(wrap()),
},
DiscoveryError::LoadOverlays { source } => match take_first_overlay_parse(source) {
Ok((path, text, inner)) => rich_parse_report(&path, text, *inner),
Err(load_err) => Report::new(load_err).wrap_err(wrap()),
},
other => Report::new(other).wrap_err(wrap()),
}
}
type ParseTriple = (
std::path::PathBuf,
String,
Box<haz_config::mapper::project::ProjectMapError>,
);
fn take_first_parse(errors: LoadErrors) -> Result<ParseTriple, LoadError> {
let mut other: Option<LoadError> = None;
for e in errors {
match e {
LoadError::Parse {
path,
source_text,
source,
..
} => return Ok((path, source_text, source)),
non_parse => {
if other.is_none() {
other = Some(non_parse);
}
}
}
}
match other {
Some(e) => Err(e),
None => unreachable!("LoadErrors is non-empty by construction"),
}
}
type OverlayParseTriple = (
std::path::PathBuf,
String,
Box<haz_config::mapper::overlay::OverlayMapError>,
);
fn take_first_overlay_parse(
errors: LoadOverlayErrors,
) -> Result<OverlayParseTriple, LoadOverlayError> {
let mut other: Option<LoadOverlayError> = None;
for e in errors {
match e {
LoadOverlayError::Parse {
path,
source_text,
source,
..
} => return Ok((path, source_text, source)),
non_parse => {
if other.is_none() {
other = Some(non_parse);
}
}
}
}
match other {
Some(e) => Err(e),
None => unreachable!("LoadOverlayErrors is non-empty by construction"),
}
}
fn rich_parse_report<D>(path: &Path, source_text: String, inner: D) -> Report
where
D: miette::Diagnostic + Send + Sync + 'static,
{
Report::new(inner).with_source_code(NamedSource::new(path.display().to_string(), source_text))
}
fn assemble_domain_workspace<F: Filesystem>(dw: DiscoveredWorkspace<F>) -> Workspace {
let root = dw.workspace.lexical().clone();
let projects: BTreeMap<ProjectName, Project> = dw
.projects
.iter()
.map(|lp| (lp.project().name.clone(), lp.project().clone()))
.collect();
let overlays = overlays_map(&dw.overlays);
let settings = dw
.workspace_config
.map(|c| c.config.settings)
.unwrap_or_default();
Workspace {
root,
projects,
overlays,
settings,
}
}
#[cfg(test)]
mod tests {
use super::load_workspace;
use std::fs;
use std::path::Path;
fn write_file(path: &Path, contents: &str) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, contents).unwrap();
}
#[test]
fn explicit_mode_two_projects_load_cleanly() {
let tempdir = tempfile::TempDir::new().unwrap();
let root = tempdir.path();
write_file(
&root.join(".haz/workspace.yml"),
"projects:\n - /lib_core\n - /apps/web\n",
);
write_file(
&root.join("lib_core/haz.yml"),
"name: lib_core\ntasks:\n build:\n command: [\"true\"]\n",
);
write_file(
&root.join("apps/web/haz.yml"),
"name: web\ntasks:\n bundle:\n command: [\"true\"]\n deps:\n - lib_core:build\n",
);
let (workspace, graph) = load_workspace(root).unwrap();
assert_eq!(workspace.projects.len(), 2);
assert_eq!(graph.nodes.len(), 2);
assert_eq!(graph.edges.len(), 1);
}
#[test]
fn implicit_mode_single_project_loads_cleanly() {
let tempdir = tempfile::TempDir::new().unwrap();
let root = tempdir.path();
fs::create_dir_all(root.join(".git")).unwrap();
write_file(
&root.join("haz.yml"),
"name: only_proj\ntasks:\n build:\n command: [\"true\"]\n",
);
let (workspace, graph) = load_workspace(root).unwrap();
assert_eq!(workspace.projects.len(), 1);
assert_eq!(graph.nodes.len(), 1);
assert!(graph.edges.is_empty());
}
#[test]
fn no_workspace_marker_surfaces_discovery_error() {
let tempdir = tempfile::TempDir::new().unwrap();
let err = load_workspace(tempdir.path()).unwrap_err();
let rendered = format!("{err:?}");
assert!(
rendered.contains("discovery failed"),
"diagnostic should mention discovery failure: {rendered}",
);
}
#[test]
fn malformed_workspace_yml_surfaces_load_error() {
let tempdir = tempfile::TempDir::new().unwrap();
let root = tempdir.path();
write_file(&root.join(".haz/workspace.yml"), ":\n");
let err = load_workspace(root).unwrap_err();
let rendered = format!("{err:?}");
assert!(
rendered.contains("haz::cfg::"),
"rendered report must include a CFG-family code; got: {rendered}",
);
}
#[test]
fn malformed_overlay_yml_surfaces_rich_load_error() {
let tempdir = tempfile::TempDir::new().unwrap();
let root = tempdir.path();
write_file(&root.join(".haz/workspace.yml"), "projects:\n - /lib\n");
write_file(
&root.join("lib/haz.yml"),
"name: lib\ntasks:\n build:\n command: [\"true\"]\n",
);
write_file(
&root.join(".haz/tasks/lint.yml"),
"tasks:\n lint:\n command: \"eslint .\"\n",
);
let err = load_workspace(root).unwrap_err();
let rendered = format!("{err:?}");
assert!(
rendered.contains("haz::cfg::021"),
"rendered report must promote the inner overlay-mapper code; got: {rendered}",
);
assert!(
rendered.contains("tasks.lint.command"),
"rendered report must include the resolved overlay key path; got: {rendered}",
);
assert!(
rendered.contains("eslint ."),
"rendered report must include the offending overlay snippet; got: {rendered}",
);
}
#[test]
fn unresolved_task_ref_surfaces_graph_build_error() {
let tempdir = tempfile::TempDir::new().unwrap();
let root = tempdir.path();
write_file(&root.join(".haz/workspace.yml"), "projects:\n - /lib\n");
write_file(
&root.join("lib/haz.yml"),
"name: lib\ntasks:\n build:\n command: [\"true\"]\n deps:\n - lib:nonexistent\n",
);
let err = load_workspace(root).unwrap_err();
let rendered = format!("{err:?}");
assert!(
rendered.contains("task-graph construction failed"),
"diagnostic should mention graph construction failure: {rendered}",
);
}
}