use std::collections::BTreeSet;
use haz_domain::path::{PathAnchor, PathPattern, ProjectRoot};
use haz_domain::task::Task;
use haz_domain::task_id::TaskId;
use haz_domain::workspace::Workspace;
use crate::edge::{Edge, EdgeKind};
#[must_use]
pub fn compute_producer_edges(workspace: &Workspace) -> BTreeSet<Edge> {
let mut edges: BTreeSet<Edge> = BTreeSet::new();
for (producer_proj_name, producer_proj) in &workspace.projects {
for (producer_task_name, producer_task) in &producer_proj.tasks {
if producer_task.outputs.is_empty() {
continue;
}
let producer_id = TaskId {
project: producer_proj_name.clone(),
task: producer_task_name.clone(),
};
for (consumer_proj_name, consumer_proj) in &workspace.projects {
for (consumer_task_name, consumer_task) in &consumer_proj.tasks {
if consumer_task.inputs.is_empty() {
continue;
}
if task_pair_produces_match(
producer_task,
&producer_proj.root,
consumer_task,
&consumer_proj.root,
) {
edges.insert(Edge {
from: producer_id.clone(),
to: TaskId {
project: consumer_proj_name.clone(),
task: consumer_task_name.clone(),
},
kind: EdgeKind::ProducerMatching,
});
}
}
}
}
}
edges
}
#[must_use]
pub fn producers_of_path(workspace: &Workspace, workspace_absolute_path: &str) -> BTreeSet<TaskId> {
let mut producers: BTreeSet<TaskId> = BTreeSet::new();
for (project_name, project) in &workspace.projects {
for (task_name, task) in &project.tasks {
if task_produces_path(task, &project.root, workspace_absolute_path) {
producers.insert(TaskId {
project: project_name.clone(),
task: task_name.clone(),
});
}
}
}
producers
}
fn task_produces_path(task: &Task, project_root: &ProjectRoot, path_str: &str) -> bool {
for output in &task.outputs {
let pattern = output.pattern();
let pattern_str = anchor_to_workspace_absolute(pattern, project_root);
let matched = if pattern.is_literal() {
pattern_str == path_str
} else {
glob_matches(&pattern_str, path_str)
};
if matched {
return true;
}
}
false
}
fn task_pair_produces_match(
producer: &Task,
producer_root: &ProjectRoot,
consumer: &Task,
consumer_root: &ProjectRoot,
) -> bool {
for output in &producer.outputs {
for input in &consumer.inputs {
if pattern_pair_matches(
output.pattern(),
producer_root,
input.pattern(),
consumer_root,
) {
return true;
}
}
}
false
}
fn pattern_pair_matches(
output: &PathPattern,
output_root: &ProjectRoot,
input: &PathPattern,
input_root: &ProjectRoot,
) -> bool {
let output_str = anchor_to_workspace_absolute(output, output_root);
let input_str = anchor_to_workspace_absolute(input, input_root);
match (output.is_literal(), input.is_literal()) {
(true, true) => output_str == input_str,
(true, false) => glob_matches(&input_str, &output_str),
(false, true) => glob_matches(&output_str, &input_str),
(false, false) => false,
}
}
#[must_use]
pub fn anchor_to_workspace_absolute(pattern: &PathPattern, project_root: &ProjectRoot) -> String {
match pattern.anchor() {
PathAnchor::WorkspaceAbsolute => pattern.to_string(),
PathAnchor::ProjectRelative => {
let prefix = match project_root {
ProjectRoot::WorkspaceRoot => String::new(),
ProjectRoot::Nested(c) => c.to_string(),
};
format!("{prefix}/{pattern}")
}
}
}
fn glob_matches(glob_str: &str, literal_str: &str) -> bool {
let Ok(glob) = globset::GlobBuilder::new(glob_str)
.literal_separator(true)
.case_insensitive(false)
.build()
else {
return false;
};
glob.compile_matcher().is_match(literal_str)
}
#[cfg(test)]
mod tests {
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use std::str::FromStr;
use haz_domain::action::TaskAction;
use haz_domain::env::EnvSettings;
use haz_domain::name::{ProjectName, TaskName};
use haz_domain::path::{
CanonicalPath, HazPath, InputSpec, OutputSpec, ProjectRoot, WorkspaceRootPath,
};
use haz_domain::project::Project;
use haz_domain::settings::WorkspaceSettings;
use haz_domain::task::Task;
use haz_domain::task_id::TaskId;
use haz_domain::workspace::Workspace;
use nonempty::NonEmpty;
use crate::edge::{Edge, EdgeKind};
use crate::producer::compute_producer_edges;
fn project_name(s: &str) -> ProjectName {
ProjectName::from_str(s).unwrap()
}
fn task_name(s: &str) -> TaskName {
TaskName::from_str(s).unwrap()
}
fn nested_root(path: &str) -> ProjectRoot {
ProjectRoot::Nested(CanonicalPath::from_absolute(&HazPath::parse(path).unwrap()).unwrap())
}
fn input(s: &str) -> InputSpec {
InputSpec::parse(s).unwrap()
}
fn output(s: &str) -> OutputSpec {
OutputSpec::parse(s).unwrap()
}
fn task_with(name: &str, inputs: Vec<InputSpec>, outputs: Vec<OutputSpec>) -> Task {
Task {
name: task_name(name),
action: TaskAction::Command(NonEmpty::from_vec(vec!["true".to_owned()]).unwrap()),
inputs,
outputs,
deps: vec![],
weak_deps: vec![],
mutex: None,
env: EnvSettings::default(),
}
}
fn project_with(name: &str, root: ProjectRoot, tasks: Vec<Task>) -> Project {
Project {
name: project_name(name),
root,
tags: BTreeSet::new(),
tasks: tasks.into_iter().map(|t| (t.name.clone(), t)).collect(),
}
}
fn workspace_with(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 producer_edge(from: TaskId, to: TaskId) -> Edge {
Edge {
from,
to,
kind: EdgeKind::ProducerMatching,
}
}
#[test]
fn dag_013_literal_output_matches_identical_literal_input_same_project() {
let p = project_with(
"p",
nested_root("/p"),
vec![
task_with("gen", vec![], vec![output("dist/main.js")]),
task_with("use", vec![input("dist/main.js")], vec![]),
],
);
let workspace = workspace_with(vec![p]);
let edges = compute_producer_edges(&workspace);
assert_eq!(
edges,
BTreeSet::from([producer_edge(id("p", "gen"), id("p", "use"))])
);
}
#[test]
fn dag_013_literal_output_does_not_match_different_literal_input() {
let p = project_with(
"p",
nested_root("/p"),
vec![
task_with("gen", vec![], vec![output("dist/main.js")]),
task_with("use", vec![input("dist/other.js")], vec![]),
],
);
let workspace = workspace_with(vec![p]);
assert!(compute_producer_edges(&workspace).is_empty());
}
#[test]
fn dag_013_literal_output_matches_glob_input() {
let p = project_with(
"p",
nested_root("/p"),
vec![
task_with("gen", vec![], vec![output("dist/main.js")]),
task_with("use", vec![input("dist/*.js")], vec![]),
],
);
let workspace = workspace_with(vec![p]);
let edges = compute_producer_edges(&workspace);
assert!(edges.contains(&producer_edge(id("p", "gen"), id("p", "use"))));
}
#[test]
fn dag_013_glob_output_matches_literal_input() {
let p = project_with(
"p",
nested_root("/p"),
vec![
task_with("gen", vec![], vec![output("dist/*.js")]),
task_with("use", vec![input("dist/main.js")], vec![]),
],
);
let workspace = workspace_with(vec![p]);
let edges = compute_producer_edges(&workspace);
assert!(edges.contains(&producer_edge(id("p", "gen"), id("p", "use"))));
}
#[test]
fn dag_013_glob_does_not_match_literal_outside_its_set() {
let p = project_with(
"p",
nested_root("/p"),
vec![
task_with("gen", vec![], vec![output("dist/*.js")]),
task_with("use", vec![input("other/main.js")], vec![]),
],
);
let workspace = workspace_with(vec![p]);
assert!(compute_producer_edges(&workspace).is_empty());
}
#[test]
fn dag_013_glob_output_does_not_match_glob_input_statically() {
let p = project_with(
"p",
nested_root("/p"),
vec![
task_with("gen", vec![], vec![output("dist/*.js")]),
task_with("use", vec![input("dist/**/*.js")], vec![]),
],
);
let workspace = workspace_with(vec![p]);
assert!(compute_producer_edges(&workspace).is_empty());
}
#[test]
fn dag_013_formatter_self_edge_when_outputs_and_inputs_intersect() {
let p = project_with(
"p",
nested_root("/p"),
vec![task_with(
"format",
vec![input("src/**/*.rs")],
vec![output("src/**/*.rs")],
)],
);
let workspace = workspace_with(vec![p]);
assert!(compute_producer_edges(&workspace).is_empty());
}
#[test]
fn dag_013_formatter_self_edge_when_literal_output_matches_glob_input() {
let p = project_with(
"p",
nested_root("/p"),
vec![task_with(
"fix_lib",
vec![input("src/*.rs")],
vec![output("src/lib.rs")],
)],
);
let workspace = workspace_with(vec![p]);
let edges = compute_producer_edges(&workspace);
assert_eq!(
edges,
BTreeSet::from([producer_edge(id("p", "fix_lib"), id("p", "fix_lib"))])
);
}
#[test]
fn dag_013_cross_project_match_uses_workspace_absolute_anchoring() {
let producer_proj = project_with(
"gen_pkg",
nested_root("/gen_pkg"),
vec![task_with("emit", vec![], vec![output("dist/api.js")])],
);
let consumer_proj = project_with(
"web",
nested_root("/web"),
vec![task_with(
"build",
vec![input("/gen_pkg/dist/api.js")],
vec![],
)],
);
let workspace = workspace_with(vec![producer_proj, consumer_proj]);
let edges = compute_producer_edges(&workspace);
assert_eq!(
edges,
BTreeSet::from([producer_edge(id("gen_pkg", "emit"), id("web", "build"))])
);
}
#[test]
fn workspace_absolute_glob_consumer_matches_relative_producer_output() {
let producer_proj = project_with(
"lib_a",
nested_root("/lib_a"),
vec![task_with("gen", vec![], vec![output("out/v1.json")])],
);
let consumer_proj = project_with(
"lib_b",
nested_root("/lib_b"),
vec![task_with("use", vec![input("/lib_a/out/*.json")], vec![])],
);
let workspace = workspace_with(vec![producer_proj, consumer_proj]);
let edges = compute_producer_edges(&workspace);
assert_eq!(
edges,
BTreeSet::from([producer_edge(id("lib_a", "gen"), id("lib_b", "use"))])
);
}
#[test]
fn implicit_project_root_anchors_relative_pattern_at_workspace_root() {
let p = project_with(
"root_proj",
ProjectRoot::WorkspaceRoot,
vec![
task_with("gen", vec![], vec![output("build/main.o")]),
task_with("link", vec![input("/build/main.o")], vec![]),
],
);
let workspace = workspace_with(vec![p]);
let edges = compute_producer_edges(&workspace);
assert_eq!(
edges,
BTreeSet::from([producer_edge(
id("root_proj", "gen"),
id("root_proj", "link")
)])
);
}
#[test]
fn empty_workspace_has_no_edges() {
let workspace = workspace_with(vec![]);
assert!(compute_producer_edges(&workspace).is_empty());
}
use crate::producer::producers_of_path;
#[test]
fn producers_of_path_returns_empty_when_no_task_outputs_match() {
let p = project_with(
"lib",
nested_root("/lib"),
vec![task_with("gen", vec![], vec![output("dist/main.js")])],
);
let workspace = workspace_with(vec![p]);
let producers = producers_of_path(&workspace, "/lib/dist/other.js");
assert!(producers.is_empty());
}
#[test]
fn producers_of_path_returns_single_producer_for_literal_match() {
let p = project_with(
"lib",
nested_root("/lib"),
vec![task_with("gen", vec![], vec![output("dist/main.js")])],
);
let workspace = workspace_with(vec![p]);
let producers = producers_of_path(&workspace, "/lib/dist/main.js");
assert_eq!(producers, BTreeSet::from([id("lib", "gen")]));
}
#[test]
fn producers_of_path_returns_single_producer_for_glob_match() {
let p = project_with(
"lib",
nested_root("/lib"),
vec![task_with("gen", vec![], vec![output("dist/*.js")])],
);
let workspace = workspace_with(vec![p]);
let producers = producers_of_path(&workspace, "/lib/dist/anything.js");
assert_eq!(producers, BTreeSet::from([id("lib", "gen")]));
}
#[test]
fn producers_of_path_returns_multiple_producers_when_outputs_overlap() {
let p = project_with(
"lib",
nested_root("/lib"),
vec![
task_with("gen_lit", vec![], vec![output("dist/api.js")]),
task_with("gen_glob", vec![], vec![output("dist/*.js")]),
],
);
let workspace = workspace_with(vec![p]);
let producers = producers_of_path(&workspace, "/lib/dist/api.js");
assert_eq!(
producers,
BTreeSet::from([id("lib", "gen_lit"), id("lib", "gen_glob")]),
);
}
#[test]
fn producers_of_path_uses_workspace_absolute_anchoring_across_projects() {
let producer_proj = project_with(
"gen_pkg",
nested_root("/gen_pkg"),
vec![task_with("emit", vec![], vec![output("dist/api.js")])],
);
let consumer_proj = project_with(
"web",
nested_root("/web"),
vec![task_with(
"build",
vec![input("/gen_pkg/dist/api.js")],
vec![],
)],
);
let workspace = workspace_with(vec![producer_proj, consumer_proj]);
let producers = producers_of_path(&workspace, "/gen_pkg/dist/api.js");
assert_eq!(producers, BTreeSet::from([id("gen_pkg", "emit")]));
}
#[test]
fn producers_of_path_handles_implicit_project_root_anchoring() {
let p = project_with(
"root_proj",
ProjectRoot::WorkspaceRoot,
vec![task_with("gen", vec![], vec![output("build/main.o")])],
);
let workspace = workspace_with(vec![p]);
let producers = producers_of_path(&workspace, "/build/main.o");
assert_eq!(producers, BTreeSet::from([id("root_proj", "gen")]));
}
#[test]
fn task_without_outputs_contributes_nothing() {
let p = project_with(
"p",
nested_root("/p"),
vec![
task_with("no_outputs", vec![input("src/main.rs")], vec![]),
task_with("no_inputs", vec![], vec![output("dist/main.js")]),
],
);
let workspace = workspace_with(vec![p]);
assert!(compute_producer_edges(&workspace).is_empty());
}
}