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)> {
load_with(starting_dir, discovery_error_to_report)
}
pub fn load_workspace_accumulating(starting_dir: &Path) -> Result<(Workspace, TaskGraph)> {
load_with(starting_dir, discovery_error_to_aggregated_report)
}
fn load_with(
starting_dir: &Path,
discovery_to_report: impl FnOnce(DiscoveryError, &Path) -> Report,
) -> Result<(Workspace, TaskGraph)> {
let fs = StdFilesystem::new();
let discovered = Discovery::new(&fs)
.discover_workspace(starting_dir)
.map_err(|e| discovery_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 discovery_error_to_aggregated_report(err: DiscoveryError, starting_dir: &Path) -> Report {
match err {
DiscoveryError::LoadProjects { source } if source.len() >= 2 => aggregate_reports(
source
.into_iter()
.map(project_load_error_to_report)
.collect(),
),
DiscoveryError::LoadOverlays { source } if source.errors.len() >= 2 => aggregate_reports(
source
.errors
.into_iter()
.map(overlay_load_error_to_report)
.collect(),
),
single => discovery_error_to_report(single, starting_dir),
}
}
fn project_load_error_to_report(e: LoadError) -> Report {
match e {
LoadError::Parse {
path,
source_text,
source,
..
} => rich_parse_report(&path, source_text, *source),
other => Report::new(other),
}
}
fn overlay_load_error_to_report(e: LoadOverlayError) -> Report {
match e {
LoadOverlayError::Parse {
path,
source_text,
source,
..
} => rich_parse_report(&path, source_text, *source),
other => Report::new(other),
}
}
fn aggregate_reports(reports: Vec<Report>) -> Report {
Report::new(AggregatedLoadErrors {
count: reports.len(),
reports,
})
}
#[derive(Debug)]
struct AggregatedLoadErrors {
count: usize,
reports: Vec<Report>,
}
impl std::fmt::Display for AggregatedLoadErrors {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} configuration error(s) found", self.count)
}
}
impl std::error::Error for AggregatedLoadErrors {}
impl miette::Diagnostic for AggregatedLoadErrors {
fn related<'a>(&'a self) -> Option<Box<dyn Iterator<Item = &'a dyn miette::Diagnostic> + 'a>> {
Some(Box::new(
self.reports
.iter()
.map(AsRef::<dyn miette::Diagnostic>::as_ref),
))
}
}
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, load_workspace_accumulating};
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}",
);
}
#[test]
fn accumulating_load_surfaces_every_project_parse_error() {
let tempdir = tempfile::TempDir::new().unwrap();
let root = tempdir.path();
write_file(
&root.join(".haz/workspace.yml"),
"projects:\n - /alpha\n - /beta\n",
);
write_file(
&root.join("alpha/haz.yml"),
"name: alpha\ntasks:\n build:\n command: \"AAA\"\n",
);
write_file(
&root.join("beta/haz.yml"),
"name: beta\ntasks:\n build:\n command: \"BBB\"\n",
);
let err = load_workspace_accumulating(root).unwrap_err();
let rendered = format!("{err:?}");
assert!(
rendered.contains("AAA") && rendered.contains("BBB"),
"both files' snippets must appear (no truncation per AUX-011): {rendered}",
);
assert!(
rendered.contains("configuration error(s) found"),
"the aggregate header must report the count: {rendered}",
);
assert!(
rendered.contains("haz::cfg::"),
"each member must carry its rich CFG-family diagnostic code: {rendered}",
);
}
#[test]
fn accumulating_load_surfaces_every_overlay_parse_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/one.yml"),
"tasks:\n one:\n command: \"AAA\"\n",
);
write_file(
&root.join(".haz/tasks/two.yml"),
"tasks:\n two:\n command: \"BBB\"\n",
);
let err = load_workspace_accumulating(root).unwrap_err();
let rendered = format!("{err:?}");
assert!(
rendered.contains("AAA") && rendered.contains("BBB"),
"both overlays' snippets must appear: {rendered}",
);
assert!(
rendered.contains("configuration error(s) found"),
"the aggregate header must report the count: {rendered}",
);
}
#[test]
fn nonaccumulating_load_still_promotes_only_first_parse_error() {
let tempdir = tempfile::TempDir::new().unwrap();
let root = tempdir.path();
write_file(
&root.join(".haz/workspace.yml"),
"projects:\n - /alpha\n - /beta\n",
);
write_file(
&root.join("alpha/haz.yml"),
"name: alpha\ntasks:\n build:\n command: \"AAA\"\n",
);
write_file(
&root.join("beta/haz.yml"),
"name: beta\ntasks:\n build:\n command: \"BBB\"\n",
);
let err = load_workspace(root).unwrap_err();
let rendered = format!("{err:?}");
assert!(
rendered.contains("AAA") ^ rendered.contains("BBB"),
"exactly one file's snippet must appear (short-circuit): {rendered}",
);
assert!(
!rendered.contains("configuration error(s) found"),
"the short-circuit path must not build an aggregate: {rendered}",
);
}
}