use std::collections::BTreeSet;
use snafu::Snafu;
use crate::name::{ProjectName, TagName, TaskName};
use crate::project::Project;
use crate::task_id::TaskId;
use crate::task_ref::TaskRef;
use crate::workspace::Workspace;
#[derive(Debug, Clone, PartialEq, Eq, Snafu)]
pub enum ResolutionError {
#[snafu(display("bearing project does not define task `{task}`"))]
BearingTaskNotFound {
task: TaskName,
},
#[snafu(display("project `{project}` not found in workspace"))]
ProjectNotFound {
project: ProjectName,
},
#[snafu(display("project `{project}` does not define task `{task}`"))]
TaskNotInProject {
project: ProjectName,
task: TaskName,
},
#[snafu(display("no project defines task `{task}`"))]
FanOutEmpty {
task: TaskName,
},
#[snafu(display("no sibling project defines task `{task}` (bearing project excluded)"))]
SiblingFanOutEmpty {
task: TaskName,
},
#[snafu(display("no project carries tag `{tag}`"))]
TagNotInWorkspace {
tag: TagName,
},
#[snafu(display("no project with tag `{tag}` defines task `{task}`"))]
TagEmpty {
tag: TagName,
task: TaskName,
},
#[snafu(display(
"no sibling project with tag `{tag}` defines task `{task}` (bearing project excluded)"
))]
SiblingTagEmpty {
tag: TagName,
task: TaskName,
},
}
pub fn resolve_task_ref(
reference: &TaskRef,
bearing: &Project,
workspace: &Workspace,
) -> Result<BTreeSet<TaskId>, ResolutionError> {
match reference {
TaskRef::SameProject(task) => resolve_same_project(task, bearing),
TaskRef::Project(project, task) => resolve_project(project, task, workspace),
TaskRef::FanOut(task) => resolve_fan_out(task, workspace, None),
TaskRef::SiblingFanOut(task) => resolve_fan_out(task, workspace, Some(&bearing.name)),
TaskRef::Tag(tag, task) => resolve_tag(tag, task, workspace, None),
TaskRef::SiblingTag(tag, task) => resolve_tag(tag, task, workspace, Some(&bearing.name)),
}
}
fn resolve_same_project(
task: &TaskName,
bearing: &Project,
) -> Result<BTreeSet<TaskId>, ResolutionError> {
if bearing.tasks.contains_key(task) {
Ok(BTreeSet::from([TaskId {
project: bearing.name.clone(),
task: task.clone(),
}]))
} else {
Err(ResolutionError::BearingTaskNotFound { task: task.clone() })
}
}
fn resolve_project(
project: &ProjectName,
task: &TaskName,
workspace: &Workspace,
) -> Result<BTreeSet<TaskId>, ResolutionError> {
let Some(found) = workspace.projects.get(project) else {
return Err(ResolutionError::ProjectNotFound {
project: project.clone(),
});
};
if found.tasks.contains_key(task) {
Ok(BTreeSet::from([TaskId {
project: project.clone(),
task: task.clone(),
}]))
} else {
Err(ResolutionError::TaskNotInProject {
project: project.clone(),
task: task.clone(),
})
}
}
fn resolve_fan_out(
task: &TaskName,
workspace: &Workspace,
exclude_bearing: Option<&ProjectName>,
) -> Result<BTreeSet<TaskId>, ResolutionError> {
let mut result = BTreeSet::new();
for (project_name, project) in &workspace.projects {
if exclude_bearing == Some(project_name) {
continue;
}
if project.tasks.contains_key(task) {
result.insert(TaskId {
project: project_name.clone(),
task: task.clone(),
});
}
}
if result.is_empty() {
Err(if exclude_bearing.is_some() {
ResolutionError::SiblingFanOutEmpty { task: task.clone() }
} else {
ResolutionError::FanOutEmpty { task: task.clone() }
})
} else {
Ok(result)
}
}
fn resolve_tag(
tag: &TagName,
task: &TaskName,
workspace: &Workspace,
exclude_bearing: Option<&ProjectName>,
) -> Result<BTreeSet<TaskId>, ResolutionError> {
let tag_in_use = workspace
.projects
.values()
.any(|project| project.tags.contains(tag));
if !tag_in_use {
return Err(ResolutionError::TagNotInWorkspace { tag: tag.clone() });
}
let mut result = BTreeSet::new();
for (project_name, project) in &workspace.projects {
if exclude_bearing == Some(project_name) {
continue;
}
if project.tags.contains(tag) && project.tasks.contains_key(task) {
result.insert(TaskId {
project: project_name.clone(),
task: task.clone(),
});
}
}
if result.is_empty() {
Err(if exclude_bearing.is_some() {
ResolutionError::SiblingTagEmpty {
tag: tag.clone(),
task: task.clone(),
}
} else {
ResolutionError::TagEmpty {
tag: tag.clone(),
task: task.clone(),
}
})
} else {
Ok(result)
}
}
#[cfg(test)]
mod tests {
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use std::str::FromStr;
use nonempty::NonEmpty;
use crate::action::TaskAction;
use crate::env::EnvSettings;
use crate::name::{ProjectName, TagName, TaskName};
use crate::path::{CanonicalPath, HazPath, ProjectRoot, WorkspaceRootPath};
use crate::project::Project;
use crate::resolution::{ResolutionError, resolve_task_ref};
use crate::settings::WorkspaceSettings;
use crate::task::Task;
use crate::task_id::TaskId;
use crate::task_ref::TaskRef;
use crate::workspace::Workspace;
fn project_name(s: &str) -> ProjectName {
ProjectName::from_str(s).unwrap()
}
fn tag_name(s: &str) -> TagName {
TagName::from_str(s).unwrap()
}
fn task_name(s: &str) -> TaskName {
TaskName::from_str(s).unwrap()
}
fn project_root_at(s: &str) -> ProjectRoot {
ProjectRoot::Nested(CanonicalPath::from_absolute(&HazPath::parse(s).unwrap()).unwrap())
}
fn empty_task(name: &str) -> Task {
Task {
name: task_name(name),
action: TaskAction::Command(NonEmpty::from_vec(vec!["true".to_owned()]).unwrap()),
inputs: vec![],
outputs: vec![],
deps: vec![],
weak_deps: vec![],
mutex: None,
env: EnvSettings::default(),
}
}
fn make_project(name: &str, root: &str, tags: &[&str], tasks: &[&str]) -> Project {
Project {
name: project_name(name),
root: project_root_at(root),
tags: tags.iter().map(|t| tag_name(t)).collect(),
tasks: tasks
.iter()
.map(|t| (task_name(t), empty_task(t)))
.collect(),
}
}
fn make_workspace(projects: Vec<Project>) -> Workspace {
let mut map = BTreeMap::new();
for project in projects {
map.insert(project.name.clone(), project);
}
Workspace {
root: WorkspaceRootPath::try_new(PathBuf::from("/abs/ws")).unwrap(),
projects: map,
overlays: BTreeMap::new(),
settings: WorkspaceSettings::default(),
}
}
fn id(project: &str, task: &str) -> TaskId {
TaskId {
project: project_name(project),
task: task_name(task),
}
}
fn parse(s: &str) -> TaskRef {
TaskRef::parse(s).unwrap()
}
#[test]
fn ref_002_same_project_resolves_to_bearing_task() {
let bearing = make_project("lib_core", "/lib_core", &[], &["build", "test"]);
let workspace = make_workspace(vec![bearing.clone()]);
let resolved = resolve_task_ref(&parse("~:build"), &bearing, &workspace).unwrap();
assert_eq!(resolved, BTreeSet::from([id("lib_core", "build")]));
}
#[test]
fn ref_002_same_project_unresolved_when_task_missing() {
let bearing = make_project("lib_core", "/lib_core", &[], &["build"]);
let workspace = make_workspace(vec![bearing.clone()]);
let err = resolve_task_ref(&parse("~:test"), &bearing, &workspace).unwrap_err();
assert!(matches!(
err,
ResolutionError::BearingTaskNotFound { ref task }
if task == &task_name("test")
));
}
#[test]
fn ref_003_project_resolves() {
let bearing = make_project("a", "/a", &[], &["build"]);
let other = make_project("b", "/b", &[], &["compile"]);
let workspace = make_workspace(vec![bearing.clone(), other]);
let resolved = resolve_task_ref(&parse("b:compile"), &bearing, &workspace).unwrap();
assert_eq!(resolved, BTreeSet::from([id("b", "compile")]));
}
#[test]
fn ref_003_project_unresolved_project_missing() {
let bearing = make_project("a", "/a", &[], &["build"]);
let workspace = make_workspace(vec![bearing.clone()]);
let err = resolve_task_ref(&parse("ghost:compile"), &bearing, &workspace).unwrap_err();
assert!(matches!(
err,
ResolutionError::ProjectNotFound { ref project }
if project == &project_name("ghost")
));
}
#[test]
fn ref_003_project_unresolved_task_missing() {
let bearing = make_project("a", "/a", &[], &["build"]);
let other = make_project("b", "/b", &[], &["compile"]);
let workspace = make_workspace(vec![bearing.clone(), other]);
let err = resolve_task_ref(&parse("b:bundle"), &bearing, &workspace).unwrap_err();
assert!(matches!(
err,
ResolutionError::TaskNotInProject { ref project, ref task }
if project == &project_name("b") && task == &task_name("bundle")
));
}
#[test]
fn ref_004_fan_out_resolves_across_all_matching_projects() {
let bearing = make_project("a", "/a", &[], &["compile"]);
let b = make_project("b", "/b", &[], &["compile", "build"]);
let c = make_project("c", "/c", &[], &["compile"]);
let d = make_project("d", "/d", &[], &["test"]);
let workspace = make_workspace(vec![bearing.clone(), b, c, d]);
let resolved = resolve_task_ref(&parse("^:compile"), &bearing, &workspace).unwrap();
assert_eq!(
resolved,
BTreeSet::from([id("a", "compile"), id("b", "compile"), id("c", "compile"),])
);
}
#[test]
fn ref_004_fan_out_includes_bearing() {
let bearing = make_project("a", "/a", &[], &["compile"]);
let workspace = make_workspace(vec![bearing.clone()]);
let resolved = resolve_task_ref(&parse("^:compile"), &bearing, &workspace).unwrap();
assert_eq!(resolved, BTreeSet::from([id("a", "compile")]));
}
#[test]
fn ref_004_fan_out_unresolved_when_no_match() {
let bearing = make_project("a", "/a", &[], &["build"]);
let workspace = make_workspace(vec![bearing.clone()]);
let err = resolve_task_ref(&parse("^:compile"), &bearing, &workspace).unwrap_err();
assert!(matches!(
err,
ResolutionError::FanOutEmpty { ref task }
if task == &task_name("compile")
));
}
#[test]
fn ref_008_sibling_fan_out_excludes_bearing() {
let bearing = make_project("a", "/a", &[], &["compile"]);
let b = make_project("b", "/b", &[], &["compile"]);
let c = make_project("c", "/c", &[], &["compile"]);
let workspace = make_workspace(vec![bearing.clone(), b, c]);
let resolved = resolve_task_ref(&parse("^~:compile"), &bearing, &workspace).unwrap();
assert_eq!(
resolved,
BTreeSet::from([id("b", "compile"), id("c", "compile")])
);
}
#[test]
fn ref_008_sibling_fan_out_unresolved_when_only_bearing_matches() {
let bearing = make_project("a", "/a", &[], &["compile"]);
let b = make_project("b", "/b", &[], &["build"]);
let workspace = make_workspace(vec![bearing.clone(), b]);
let err = resolve_task_ref(&parse("^~:compile"), &bearing, &workspace).unwrap_err();
assert!(matches!(
err,
ResolutionError::SiblingFanOutEmpty { ref task }
if task == &task_name("compile")
));
}
#[test]
fn ref_008_sibling_fan_out_unresolved_when_no_one_matches() {
let bearing = make_project("a", "/a", &[], &["build"]);
let b = make_project("b", "/b", &[], &["test"]);
let workspace = make_workspace(vec![bearing.clone(), b]);
let err = resolve_task_ref(&parse("^~:compile"), &bearing, &workspace).unwrap_err();
assert!(matches!(
err,
ResolutionError::SiblingFanOutEmpty { ref task }
if task == &task_name("compile")
));
}
#[test]
fn ref_005_tag_resolves_across_tagged_projects() {
let bearing = make_project("a", "/a", &["typescript"], &["compile"]);
let b = make_project("b", "/b", &["typescript"], &["compile"]);
let c = make_project("c", "/c", &["rust"], &["compile"]);
let workspace = make_workspace(vec![bearing.clone(), b, c]);
let resolved =
resolve_task_ref(&parse("[typescript]:compile"), &bearing, &workspace).unwrap();
assert_eq!(
resolved,
BTreeSet::from([id("a", "compile"), id("b", "compile")])
);
}
#[test]
fn ref_005_tag_includes_bearing_when_bearing_carries_tag() {
let bearing = make_project("a", "/a", &["typescript"], &["compile"]);
let workspace = make_workspace(vec![bearing.clone()]);
let resolved =
resolve_task_ref(&parse("[typescript]:compile"), &bearing, &workspace).unwrap();
assert_eq!(resolved, BTreeSet::from([id("a", "compile")]));
}
#[test]
fn ref_005_tag_unresolved_tag_not_in_workspace() {
let bearing = make_project("a", "/a", &["rust"], &["compile"]);
let workspace = make_workspace(vec![bearing.clone()]);
let err = resolve_task_ref(&parse("[ghost]:compile"), &bearing, &workspace).unwrap_err();
assert!(matches!(
err,
ResolutionError::TagNotInWorkspace { ref tag }
if tag == &tag_name("ghost")
));
}
#[test]
fn ref_005_tag_unresolved_when_tagged_projects_lack_task() {
let bearing = make_project("a", "/a", &["typescript"], &["bundle"]);
let b = make_project("b", "/b", &["typescript"], &["build"]);
let workspace = make_workspace(vec![bearing.clone(), b]);
let err =
resolve_task_ref(&parse("[typescript]:compile"), &bearing, &workspace).unwrap_err();
assert!(matches!(
err,
ResolutionError::TagEmpty { ref tag, ref task }
if tag == &tag_name("typescript") && task == &task_name("compile")
));
}
#[test]
fn ref_008_sibling_tag_excludes_bearing() {
let bearing = make_project("a", "/a", &["typescript"], &["compile"]);
let b = make_project("b", "/b", &["typescript"], &["compile"]);
let c = make_project("c", "/c", &["typescript"], &["compile"]);
let workspace = make_workspace(vec![bearing.clone(), b, c]);
let resolved =
resolve_task_ref(&parse("[typescript]~:compile"), &bearing, &workspace).unwrap();
assert_eq!(
resolved,
BTreeSet::from([id("b", "compile"), id("c", "compile")])
);
}
#[test]
fn ref_008_sibling_tag_unresolved_when_only_bearing_carries_tag_and_task() {
let bearing = make_project("a", "/a", &["typescript"], &["compile"]);
let b = make_project("b", "/b", &["rust"], &["compile"]);
let workspace = make_workspace(vec![bearing.clone(), b]);
let err =
resolve_task_ref(&parse("[typescript]~:compile"), &bearing, &workspace).unwrap_err();
assert!(matches!(
err,
ResolutionError::SiblingTagEmpty { ref tag, ref task }
if tag == &tag_name("typescript") && task == &task_name("compile")
));
}
#[test]
fn ref_008_sibling_tag_unresolved_when_tag_not_in_workspace() {
let bearing = make_project("a", "/a", &["rust"], &["compile"]);
let workspace = make_workspace(vec![bearing.clone()]);
let err = resolve_task_ref(&parse("[ghost]~:compile"), &bearing, &workspace).unwrap_err();
assert!(matches!(
err,
ResolutionError::TagNotInWorkspace { ref tag }
if tag == &tag_name("ghost")
));
}
#[test]
fn postcondition_successful_resolution_is_non_empty_and_internally_consistent() {
let bearing = make_project("a", "/a", &["typescript"], &["compile", "test"]);
let b = make_project("b", "/b", &["typescript"], &["compile"]);
let c = make_project("c", "/c", &["rust"], &["compile"]);
let workspace = make_workspace(vec![bearing.clone(), b, c]);
let references = [
"~:compile",
"b:compile",
"^:compile",
"^~:compile",
"[typescript]:compile",
"[typescript]~:compile",
"[rust]:compile",
];
for raw in &references {
let reference = parse(raw);
let resolved = resolve_task_ref(&reference, &bearing, &workspace)
.unwrap_or_else(|e| panic!("{raw}: expected Ok, got {e:?}"));
assert!(
!resolved.is_empty(),
"{raw}: resolved set must be non-empty"
);
for resolved_id in &resolved {
let p = workspace
.projects
.get(&resolved_id.project)
.unwrap_or_else(|| panic!("{raw}: project missing"));
assert!(
p.tasks.contains_key(&resolved_id.task),
"{raw}: task missing in project",
);
}
}
}
}