use crate::config::Settings;
use crate::config::env_directive::EnvDirective;
use crate::task::task_fetcher::TaskFetcher;
use crate::task::{Task, TaskRunPhase, dep_has_usage_ref, parse_usage_values_from_task};
use crate::{config::Config, task::task_list::resolve_depends};
use itertools::Itertools;
use petgraph::Direction;
use petgraph::algo::kosaraju_scc;
use petgraph::graph::{DiGraph, NodeIndex};
use std::{
collections::{HashMap, HashSet, VecDeque},
fmt,
sync::Arc,
};
use tokio::sync::mpsc;
pub(crate) type TaskKey = (String, Vec<String>, Vec<(String, String)>, TaskRunPhase);
fn env_key(task: &Task) -> Vec<(String, String)> {
task.env
.0
.iter()
.filter_map(|d| match d {
EnvDirective::Val(k, v, _) => Some((k.clone(), v.clone())),
_ => None,
})
.sorted()
.collect()
}
pub(crate) struct TaskCycleError {
paths: Vec<Vec<String>>,
keys: Vec<Vec<TaskKey>>,
}
impl TaskCycleError {
pub(crate) fn path(&self) -> &[String] {
self.paths.first().map(Vec::as_slice).unwrap_or_default()
}
pub(crate) fn paths(&self) -> &[Vec<String>] {
&self.paths
}
pub(crate) fn keys(&self) -> &[Vec<TaskKey>] {
&self.keys
}
}
impl fmt::Debug for TaskCycleError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TaskCycleError")
.field("paths", &self.paths)
.finish_non_exhaustive()
}
}
impl fmt::Display for TaskCycleError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"circular dependency detected: {}",
self.path().iter().join(" -> ")
)
}
}
impl std::error::Error for TaskCycleError {}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct TaskDependencyState {
pub cache_keys: Vec<String>,
pub any_did_work: bool,
pub any_unkeyed_did_work: bool,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct TaskCompletionState {
completed: HashSet<TaskKey>,
did_work: HashSet<TaskKey>,
cache_keys: HashMap<TaskKey, String>,
}
impl TaskCompletionState {
pub(crate) fn merge(&mut self, other: Self) {
self.completed.extend(other.completed);
self.did_work.extend(other.did_work);
self.cache_keys.extend(other.cache_keys);
}
}
#[derive(Debug)]
pub(crate) struct Deps {
pub graph: DiGraph<Task, ()>,
sent: HashSet<TaskKey>, removed: HashSet<TaskKey>, executed: HashSet<TaskKey>, did_work: HashSet<TaskKey>, cache_keys: HashMap<TaskKey, String>, dep_edges: HashMap<TaskKey, HashSet<TaskKey>>, post_dep_parents: HashMap<TaskKey, HashSet<TaskKey>>, tx: mpsc::UnboundedSender<Option<Task>>,
}
pub(super) fn task_key(task: &Task) -> TaskKey {
(
task.name.clone(),
task.args.clone(),
env_key(task),
task.run_phase,
)
}
fn same_task_without_phase(task: &Task, other: &Task) -> bool {
task.name == other.name && task.args == other.args && env_key(task) == env_key(other)
}
impl Deps {
pub(crate) async fn new(config: &Arc<Config>, tasks: Vec<Task>) -> eyre::Result<Self> {
Self::new_with_cycle_limit(config, tasks, Some(1)).await
}
pub(crate) async fn new_for_validation(
config: &Arc<Config>,
tasks: Vec<Task>,
) -> eyre::Result<Self> {
Self::new_with_cycle_limit(config, tasks, None).await
}
async fn new_with_cycle_limit(
config: &Arc<Config>,
tasks: Vec<Task>,
cycle_limit: Option<usize>,
) -> eyre::Result<Self> {
let mut graph = DiGraph::new();
let mut indexes = HashMap::new();
let mut stack = vec![];
let mut seen = HashSet::new();
let mut direct_post_parents: HashMap<TaskKey, HashSet<TaskKey>> = HashMap::new();
let mut dep_edges: HashMap<TaskKey, HashSet<TaskKey>> = HashMap::new();
let mut wait_edges = Vec::new();
let mut add_idx = |task: &Task, graph: &mut DiGraph<Task, ()>| {
*indexes
.entry(task_key(task))
.or_insert_with(|| graph.add_node(task.clone()))
};
for t in &tasks {
let t = t.clone().with_run_phase(TaskRunPhase::Normal);
stack.push(t.clone());
add_idx(&t, &mut graph);
}
let all_tasks_to_run = resolve_depends(config, tasks).await?;
let no_cache = Settings::get().task.remote_no_cache.unwrap_or(false);
let fetcher = TaskFetcher::new(no_cache);
while let Some(mut a) = stack.pop() {
if seen.contains(&a) {
continue;
}
if a.file
.as_ref()
.is_some_and(|f| TaskFetcher::is_remote_source(&f.to_string_lossy()))
{
let mut tasks_to_fetch = vec![a];
fetcher.fetch_tasks(config, &mut tasks_to_fetch).await?;
a = tasks_to_fetch.into_iter().next().unwrap();
}
let has_usage_deps = |raw: &Option<Vec<_>>| {
raw.as_ref()
.is_some_and(|r| r.iter().any(dep_has_usage_ref))
};
if has_usage_deps(&a.depends_raw)
|| has_usage_deps(&a.depends_post_raw)
|| has_usage_deps(&a.wait_for_raw)
{
let usage_values = parse_usage_values_from_task(config, &a).await?;
if !usage_values.is_empty() {
a.render_depends_with_usage(config, &usage_values).await?;
}
}
let a_idx = add_idx(&a, &mut graph);
graph[a_idx] = a.clone();
let resolved = a.resolve_depends(config, &all_tasks_to_run).await?;
for b in resolved.depends {
let b = b.with_run_phase(a.run_phase);
let b_idx = add_idx(&b, &mut graph);
graph.update_edge(a_idx, b_idx, ());
dep_edges
.entry(task_key(&a))
.or_default()
.insert(task_key(&b));
stack.push(b.clone());
}
for b in resolved.wait_for {
wait_edges.push((task_key(&a), b));
}
for b in resolved.depends_post {
let b = b.with_run_phase(TaskRunPhase::Post);
add_idx(&b, &mut graph);
direct_post_parents
.entry(task_key(&b))
.or_default()
.insert(task_key(&a));
stack.push(b.clone());
}
seen.insert(a);
}
let mut post_dep_parents: HashMap<TaskKey, HashSet<TaskKey>> = HashMap::new();
let mut pending = VecDeque::new();
for (post_key, parents) in direct_post_parents {
for parent in parents {
if post_dep_parents
.entry(post_key.clone())
.or_default()
.insert(parent.clone())
{
pending.push_back((post_key.clone(), parent));
}
}
}
while let Some((post_key, parent)) = pending.pop_front() {
for dependency in dep_edges.get(&post_key).into_iter().flatten() {
if dependency.3 != TaskRunPhase::Post || dependency == &parent {
continue;
}
if post_dep_parents
.entry(dependency.clone())
.or_default()
.insert(parent.clone())
{
pending.push_back((dependency.clone(), parent.clone()));
}
}
}
for (post_key, parents) in &post_dep_parents {
let post_idx = indexes[post_key];
for parent in parents {
graph.update_edge(post_idx, indexes[parent], ());
}
}
for (waiting_key, target) in wait_edges {
let waiting_idx = indexes[&waiting_key];
let target_idx = graph
.node_indices()
.filter(|&idx| same_task_without_phase(&graph[idx], &target))
.find(|&idx| graph[idx].run_phase == waiting_key.3)
.or_else(|| {
graph
.node_indices()
.find(|&idx| same_task_without_phase(&graph[idx], &target))
});
if let Some(target_idx) = target_idx {
graph.update_edge(waiting_idx, target_idx, ());
}
}
let cycles = find_cycles(&graph, cycle_limit);
if !cycles.is_empty() {
let paths = cycles
.iter()
.map(|cycle| {
cycle
.iter()
.map(|&idx| task_cycle_label(&graph[idx]))
.collect()
})
.collect();
let keys = cycles
.iter()
.map(|cycle| cycle.iter().map(|&idx| task_key(&graph[idx])).collect())
.collect();
return Err(eyre::Report::new(TaskCycleError { paths, keys }));
}
let (tx, _) = mpsc::unbounded_channel();
let sent = HashSet::new();
let removed = HashSet::new();
let executed = HashSet::new();
let did_work = HashSet::new();
let cache_keys = HashMap::new();
Ok(Self {
graph,
tx,
sent,
removed,
executed,
did_work,
cache_keys,
dep_edges,
post_dep_parents,
})
}
pub(crate) async fn new_pruned(
config: &Arc<Config>,
tasks: Vec<Task>,
completed: &TaskCompletionState,
) -> eyre::Result<Self> {
let mut deps = Self::new(config, tasks).await?;
deps.did_work.extend(completed.did_work.iter().cloned());
deps.cache_keys.extend(completed.cache_keys.clone());
let mut to_remove = vec![];
for idx in deps.graph.node_indices() {
let key = task_key(&deps.graph[idx]);
if completed.completed.contains(&key) {
to_remove.push(idx);
}
}
to_remove.sort_unstable_by(|a, b| b.cmp(a));
for idx in to_remove {
deps.graph.remove_node(idx);
}
deps.mark_ambiguous_prefixes();
Ok(deps)
}
fn emit_leaves(&mut self) {
let leaves = leaves(&self.graph);
let leaves_is_empty = leaves.is_empty();
for task in leaves {
let key = task_key(&task);
if self.sent.insert(key.clone()) {
trace!("Scheduling task {0}", task.name);
if let Err(e) = self.tx.send(Some(task)) {
trace!("Error sending task: {e:?}");
self.sent.remove(&key);
}
}
}
if self.is_empty() {
trace!("All tasks finished");
if let Err(e) = self.tx.send(None) {
trace!("Error closing task stream: {e:?}");
}
} else if leaves_is_empty && self.sent.len() == self.removed.len() {
panic!(
"Infinitive loop detected, all tasks are finished but the graph isn't empty {0} {1:#?}",
self.all().map(|t| t.name.clone()).join(", "),
self.graph
)
}
}
pub(crate) fn subscribe(&mut self) -> mpsc::UnboundedReceiver<Option<Task>> {
let (tx, rx) = mpsc::unbounded_channel();
self.tx = tx;
self.emit_leaves();
rx
}
pub(crate) fn is_empty(&self) -> bool {
self.graph.node_count() == 0
}
pub(crate) fn completion_state(&self) -> TaskCompletionState {
TaskCompletionState {
completed: self.removed.clone(),
did_work: self.did_work.clone(),
cache_keys: self.cache_keys.clone(),
}
}
pub(crate) fn is_runnable_post_dep(&self, task: &Task) -> bool {
let key = task_key(task);
match self.post_dep_parents.get(&key) {
Some(parent_keys) => parent_keys.iter().any(|pk| self.executed.contains(pk)),
None => false,
}
}
pub(crate) fn mark_executed(&mut self, task: &Task) {
self.executed.insert(task_key(task));
}
pub(crate) fn unmark_executed(&mut self, task: &Task) {
self.executed.remove(&task_key(task));
}
pub(crate) fn mark_did_work(&mut self, task: &Task) {
self.did_work.insert(task_key(task));
}
pub(crate) fn mark_cache_key(&mut self, task: &Task, cache_key: String) {
self.cache_keys.insert(task_key(task), cache_key);
}
pub(crate) fn dependency_state(&self, task: &Task) -> TaskDependencyState {
let key = task_key(task);
let deps = self
.dep_edges
.get(&key)
.into_iter()
.flatten()
.chain(self.post_dep_parents.get(&key).into_iter().flatten())
.collect::<HashSet<_>>();
let mut cache_keys = deps
.iter()
.filter_map(|dep_key| self.cache_keys.get(dep_key).cloned())
.collect::<Vec<_>>();
cache_keys.sort();
cache_keys.dedup();
TaskDependencyState {
cache_keys,
any_did_work: deps.iter().any(|dep_key| self.did_work.contains(dep_key)),
any_unkeyed_did_work: deps.iter().any(|dep_key| {
self.did_work.contains(dep_key) && !self.cache_keys.contains_key(dep_key)
}),
}
}
pub(crate) fn remove_batch(&mut self, tasks: &[Task]) {
for task in tasks {
if let Some(idx) = self.node_idx(task) {
self.graph.remove_node(idx);
let key = task_key(task);
self.removed.insert(key);
}
}
self.emit_leaves();
}
pub(crate) fn remove(&mut self, task: &Task) {
if let Some(idx) = self.node_idx(task) {
self.graph.remove_node(idx);
let key = task_key(task);
self.removed.insert(key);
self.emit_leaves();
}
}
fn node_idx(&self, task: &Task) -> Option<petgraph::graph::NodeIndex> {
self.graph
.node_indices()
.find(|&idx| &self.graph[idx] == task)
}
pub(crate) fn all(&self) -> impl Iterator<Item = &Task> {
self.graph.node_indices().map(|idx| &self.graph[idx])
}
pub(crate) fn mark_ambiguous_prefixes(&mut self) {
let mut name_to_indices: HashMap<String, Vec<petgraph::graph::NodeIndex>> = HashMap::new();
for idx in self.graph.node_indices() {
name_to_indices
.entry(self.graph[idx].display_name.clone())
.or_default()
.push(idx);
}
for indices in name_to_indices.values() {
if indices.len() > 1 {
for &idx in indices {
self.graph[idx].show_args_in_prefix = true;
}
}
}
}
pub(crate) fn is_linear(&self) -> bool {
let mut graph = self.graph.clone();
loop {
let leaves = leaves(&graph);
if leaves.is_empty() {
return true;
} else if leaves.len() > 1 {
return false;
} else {
let idx = self
.graph
.node_indices()
.find(|&idx| graph[idx] == leaves[0])
.unwrap();
graph.remove_node(idx);
}
}
}
}
fn leaves(graph: &DiGraph<Task, ()>) -> Vec<Task> {
graph
.externals(Direction::Outgoing)
.map(|idx| graph[idx].clone())
.collect()
}
pub(crate) fn task_cycle_label(task: &Task) -> String {
let mut label = if task.args.is_empty() {
task.name.clone()
} else {
format!("{} {}", task.name, task.args.join(" "))
};
if task.run_phase == TaskRunPhase::Post {
label.push_str(" (post)");
}
let env_keys = task
.env
.0
.iter()
.filter_map(|directive| match directive {
EnvDirective::Val(key, _, _) => Some(key),
_ => None,
})
.sorted()
.unique()
.join(", ");
if env_keys.is_empty() {
label
} else {
format!("{label} [env: {env_keys}]")
}
}
fn find_cycles(graph: &DiGraph<Task, ()>, limit: Option<usize>) -> Vec<Vec<NodeIndex>> {
let mut cycles = Vec::new();
for mut component in kosaraju_scc(graph) {
component.sort_by_key(|node| node.index());
let component: HashSet<_> = component.into_iter().collect();
if component.len() == 1 {
let node = *component.iter().next().unwrap();
if graph.find_edge(node, node).is_some() {
cycles.push(vec![node, node]);
}
if limit.is_some_and(|limit| cycles.len() >= limit) {
return cycles;
}
continue;
}
let mut starts = component.iter().copied().collect_vec();
starts.sort_by_key(|node| node.index());
for start in starts {
let mut path = vec![start];
let mut in_path = HashSet::from([start]);
let mut stack = vec![(
start,
graph
.neighbors_directed(start, Direction::Outgoing)
.filter(|node| component.contains(node))
.sorted_by_key(|node| node.index())
.collect_vec(),
0,
)];
while !stack.is_empty() {
let dependency = {
let (_, dependencies, next) = stack.last_mut().unwrap();
if *next < dependencies.len() {
let dependency = dependencies[*next];
*next += 1;
Some(dependency)
} else {
None
}
};
let Some(dependency) = dependency else {
let (node, _, _) = stack.pop().unwrap();
path.pop();
in_path.remove(&node);
continue;
};
if dependency == start {
let mut cycle = path.clone();
cycle.push(start);
cycles.push(cycle);
if limit.is_some_and(|limit| cycles.len() >= limit) {
return cycles;
}
} else if dependency.index() >= start.index() && in_path.insert(dependency) {
path.push(dependency);
stack.push((
dependency,
graph
.neighbors_directed(dependency, Direction::Outgoing)
.filter(|node| component.contains(node))
.sorted_by_key(|node| node.index())
.collect_vec(),
0,
));
}
}
}
}
cycles
}
#[cfg(test)]
mod tests {
use super::*;
fn task(name: &str) -> Task {
Task {
name: name.to_string(),
..Default::default()
}
}
fn post_task(name: &str) -> Task {
task(name).with_run_phase(TaskRunPhase::Post)
}
fn deps_with_relationships(
dep_edges: HashMap<TaskKey, HashSet<TaskKey>>,
post_dep_parents: HashMap<TaskKey, HashSet<TaskKey>>,
) -> Deps {
let (tx, _) = mpsc::unbounded_channel();
Deps {
graph: DiGraph::new(),
sent: HashSet::new(),
removed: HashSet::new(),
executed: HashSet::new(),
did_work: HashSet::new(),
cache_keys: HashMap::new(),
dep_edges,
post_dep_parents,
tx,
}
}
#[test]
fn unmark_executed_disables_post_dependency_cleanup() {
let parent = task("parent");
let cleanup = task("cleanup");
let mut deps = deps_with_relationships(
HashMap::new(),
HashMap::from([(task_key(&cleanup), HashSet::from([task_key(&parent)]))]),
);
deps.mark_executed(&parent);
assert!(deps.is_runnable_post_dep(&cleanup));
deps.unmark_executed(&parent);
assert!(!deps.is_runnable_post_dep(&cleanup));
}
#[test]
fn dependency_state_tracks_direct_artifact_identity_and_unkeyed_work() {
let a = task("a");
let b = task("b");
let c = task("c");
let dep_edges = HashMap::from([
(task_key(&b), HashSet::from([task_key(&a)])),
(task_key(&c), HashSet::from([task_key(&b)])),
]);
let mut deps = deps_with_relationships(dep_edges, HashMap::new());
deps.mark_did_work(&b);
assert_eq!(
deps.dependency_state(&c),
TaskDependencyState {
cache_keys: vec![],
any_did_work: true,
any_unkeyed_did_work: true,
}
);
deps.mark_cache_key(&b, "b-key".to_string());
assert_eq!(
deps.dependency_state(&c),
TaskDependencyState {
cache_keys: vec!["b-key".to_string()],
any_did_work: true,
any_unkeyed_did_work: false,
}
);
}
#[test]
fn dependency_state_includes_post_dependency_parents() {
let parent = task("parent");
let post = task("post");
let post_dep_parents =
HashMap::from([(task_key(&post), HashSet::from([task_key(&parent)]))]);
let mut deps = deps_with_relationships(HashMap::new(), post_dep_parents);
deps.mark_did_work(&parent);
deps.mark_cache_key(&parent, "parent-key".to_string());
assert_eq!(
deps.dependency_state(&post),
TaskDependencyState {
cache_keys: vec!["parent-key".to_string()],
any_did_work: true,
any_unkeyed_did_work: false,
}
);
}
#[tokio::test]
async fn new_pruned_preserves_completed_artifact_state() {
let completed_task = task("completed");
let key = task_key(&completed_task);
let completion_state = TaskCompletionState {
completed: HashSet::from([key.clone()]),
did_work: HashSet::from([key.clone()]),
cache_keys: HashMap::from([(key.clone(), "completed-key".to_string())]),
};
let config = Config::get().await.unwrap();
let deps = Deps::new_pruned(&config, vec![completed_task], &completion_state)
.await
.unwrap();
let propagated = deps.completion_state();
assert!(deps.is_empty());
assert!(propagated.did_work.contains(&key));
assert_eq!(
propagated.cache_keys.get(&key).map(String::as_str),
Some("completed-key")
);
}
#[tokio::test]
async fn wait_for_falls_back_to_post_occurrence_without_tracking_dependency_state() {
let config = Config::get().await.unwrap();
let tasks = config.tasks().await.unwrap();
let mut parent = tasks["configtask"].clone();
parent.depends_post = vec!["lint".parse().unwrap()];
let mut waiter = tasks["test"].clone();
waiter.wait_for = vec!["lint".parse().unwrap()];
let mut deps = Deps::new(&config, vec![parent, waiter.clone()])
.await
.unwrap();
let waiter_idx = deps.node_idx(&waiter).unwrap();
let post_target_idx = deps
.graph
.node_indices()
.find(|&idx| {
deps.graph[idx].name == "lint" && deps.graph[idx].run_phase == TaskRunPhase::Post
})
.unwrap();
assert!(deps.graph.find_edge(waiter_idx, post_target_idx).is_some());
assert!(!deps.graph.node_indices().any(|idx| {
deps.graph[idx].name == "lint" && deps.graph[idx].run_phase == TaskRunPhase::Normal
}));
let post_target = deps.graph[post_target_idx].clone();
deps.mark_did_work(&post_target);
deps.mark_cache_key(&post_target, "post-key".to_string());
assert_eq!(
deps.dependency_state(&waiter),
TaskDependencyState::default()
);
}
#[test]
fn finds_cycle_path() {
let mut graph = DiGraph::new();
let a = graph.add_node(task("a"));
let b = graph.add_node(task("b"));
let c = graph.add_node(task("c"));
graph.update_edge(a, b, ());
graph.update_edge(b, c, ());
graph.update_edge(c, a, ());
let cycle = find_cycles(&graph, Some(1)).pop().unwrap();
let labels = cycle
.iter()
.map(|&idx| task_cycle_label(&graph[idx]))
.collect_vec();
assert_eq!(labels, ["a", "b", "c", "a"]);
}
#[test]
fn accepts_acyclic_graph() {
let mut graph = DiGraph::new();
let a = graph.add_node(task("a"));
let b = graph.add_node(task("b"));
graph.update_edge(b, a, ());
assert!(find_cycles(&graph, None).is_empty());
}
#[test]
fn normal_and_post_occurrences_have_distinct_identity() {
let normal = task("shared");
let post = post_task("shared");
assert_ne!(normal, post);
assert_ne!(task_key(&normal), task_key(&post));
let mut graph = DiGraph::new();
let normal_idx = graph.add_node(normal);
let parent_idx = graph.add_node(task("parent"));
let post_idx = graph.add_node(post);
graph.update_edge(parent_idx, normal_idx, ());
graph.update_edge(post_idx, parent_idx, ());
assert!(find_cycles(&graph, None).is_empty());
}
#[test]
fn post_subtree_waits_for_parent() {
let mut graph = DiGraph::new();
let parent = graph.add_node(task("parent"));
let prerequisite = graph.add_node(post_task("prerequisite"));
let post = graph.add_node(post_task("post"));
graph.update_edge(post, prerequisite, ());
graph.update_edge(prerequisite, parent, ());
graph.update_edge(post, parent, ());
assert_eq!(leaves(&graph), [task("parent")]);
graph.remove_node(parent);
assert_eq!(leaves(&graph), [post_task("prerequisite")]);
graph.remove_node(prerequisite);
assert_eq!(leaves(&graph), [post_task("post")]);
}
#[test]
fn shared_post_occurrence_waits_for_all_parents() {
let mut graph = DiGraph::new();
let parent_a = graph.add_node(task("parent-a"));
let parent_b = graph.add_node(task("parent-b"));
let post = graph.add_node(post_task("post"));
graph.update_edge(post, parent_a, ());
graph.update_edge(post, parent_b, ());
assert_eq!(leaves(&graph).len(), 2);
graph.remove_node(parent_a);
assert_eq!(leaves(&graph), [task("parent-b")]);
graph.remove_node(parent_b);
assert_eq!(leaves(&graph), [post_task("post")]);
}
#[test]
fn accepts_deep_acyclic_graph() {
let mut graph = DiGraph::new();
let nodes = (0..10_000)
.map(|i| graph.add_node(task(&format!("task-{i}"))))
.collect_vec();
for pair in nodes.windows(2) {
graph.update_edge(pair[0], pair[1], ());
}
assert!(find_cycles(&graph, None).is_empty());
}
#[test]
fn finds_overlapping_cycles() {
let mut graph = DiGraph::new();
let root = graph.add_node(task("root"));
let left = graph.add_node(task("left"));
let right = graph.add_node(task("right"));
graph.update_edge(root, left, ());
graph.update_edge(left, root, ());
graph.update_edge(root, right, ());
graph.update_edge(right, root, ());
let cycles = find_cycles(&graph, None)
.into_iter()
.map(|cycle| {
cycle
.iter()
.map(|&idx| task_cycle_label(&graph[idx]))
.collect_vec()
})
.collect_vec();
assert_eq!(
cycles,
[["root", "left", "root"], ["root", "right", "root"]]
);
}
#[test]
fn cycle_label_disambiguates_environment_variants_without_values() {
let mut task = task("build");
task.args = vec!["linux".to_string()];
task.env.0 = vec![
EnvDirective::Val(
"TOKEN".to_string(),
"secret".to_string(),
Default::default(),
),
EnvDirective::Val(
"TARGET".to_string(),
"linux".to_string(),
Default::default(),
),
];
assert_eq!(task_cycle_label(&task), "build linux [env: TARGET, TOKEN]");
}
}