Skip to main content

qk_taskgraph/
lib.rs

1//! Expand requested targets into a validated task DAG before executing anything.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use anyhow::{Context, Result, bail};
6use qk_config::{Target, Workspace};
7use qk_graph::{ProjectGraph, select_projects};
8use serde::Serialize;
9use serde_json::Value;
10
11#[derive(Clone, Debug)]
12pub struct Request {
13    pub project: String,
14    pub target: String,
15    pub configuration: Option<String>,
16    /// Configuration name propagated to dependencies, before root fallback.
17    pub requested_configuration: Option<String>,
18    pub args: Vec<String>,
19}
20
21impl Request {
22    pub fn parse(value: &str) -> Result<Self> {
23        let parts: Vec<_> = value.split(':').collect();
24        if !(2..=3).contains(&parts.len()) || parts.iter().any(|part| part.is_empty()) {
25            bail!("expected project:target[:configuration], got {value:?}");
26        }
27        Ok(Self {
28            project: parts[0].into(),
29            target: parts[1].into(),
30            configuration: parts.get(2).map(|part| (*part).into()),
31            requested_configuration: parts.get(2).map(|part| (*part).into()),
32            args: Vec::new(),
33        })
34    }
35
36    /// Resolve a literal target name before treating its final segment as a configuration.
37    pub fn parse_in(workspace: &Workspace, value: &str) -> Result<Self> {
38        let Some((project, identifier)) = value.split_once(':') else {
39            return Self::parse(value);
40        };
41        if let Some(project_config) = workspace.projects.get(project) {
42            if project_config.targets.contains_key(identifier) {
43                return Ok(Self {
44                    project: project.into(),
45                    target: identifier.into(),
46                    configuration: None,
47                    requested_configuration: None,
48                    args: Vec::new(),
49                });
50            }
51            if let Some((target, configuration)) = identifier.rsplit_once(':')
52                && project_config.targets.contains_key(target)
53                && !configuration.is_empty()
54            {
55                return Ok(Self {
56                    project: project.into(),
57                    target: target.into(),
58                    configuration: Some(configuration.into()),
59                    requested_configuration: Some(configuration.into()),
60                    args: Vec::new(),
61                });
62            }
63        }
64        Self::parse(value)
65    }
66}
67
68#[derive(Clone, Debug, Serialize)]
69pub struct Task {
70    pub id: String,
71    pub project: String,
72    pub target: String,
73    pub configuration: Option<String>,
74    pub args: Vec<String>,
75    pub definition: Target,
76    pub dependencies: BTreeSet<String>,
77}
78
79#[derive(Debug, Serialize)]
80pub struct TaskGraph {
81    pub roots: BTreeSet<String>,
82    pub tasks: BTreeMap<String, Task>,
83    /// The cycles broken under `ignore_cycles`, each as the tasks around it.
84    #[serde(skip)]
85    pub cycles: Vec<Vec<String>>,
86}
87
88/// Nx's options for building a task graph.
89#[derive(Clone, Copy, Debug, Default)]
90pub struct BuildOptions {
91    /// `--exclude-task-dependencies`: only the requested tasks, without the
92    /// tasks they depend on.
93    pub exclude_task_dependencies: bool,
94    /// `--nx-ignore-cycles`: break each cycle rather than failing, as Nx's
95    /// `makeAcyclic` does, by dropping the dependency that closes it.
96    pub ignore_cycles: bool,
97}
98
99impl TaskGraph {
100    pub fn build(workspace: &Workspace, requests: &[Request]) -> Result<Self> {
101        Self::build_with(workspace, requests, BuildOptions::default())
102    }
103
104    pub fn build_with(
105        workspace: &Workspace,
106        requests: &[Request],
107        options: BuildOptions,
108    ) -> Result<Self> {
109        if requests.is_empty() {
110            bail!("no tasks selected");
111        }
112        let projects = ProjectGraph::build(workspace)?;
113        let mut builder = Builder {
114            workspace,
115            projects,
116            tasks: BTreeMap::new(),
117            visiting: Vec::new(),
118            visited: BTreeSet::new(),
119            ignore_cycles: options.ignore_cycles,
120            cycles: Vec::new(),
121        };
122        let mut roots = BTreeSet::new();
123        for request in requests {
124            roots.insert(builder.visit(request.clone(), true)?.with_context(|| {
125                format!(
126                    "project {:?} has no target {:?}",
127                    request.project, request.target
128                )
129            })?);
130        }
131        let mut graph = Self {
132            roots,
133            tasks: builder.tasks,
134            cycles: builder.cycles,
135        };
136        if !graph.cycles.is_empty() {
137            graph.make_acyclic();
138        }
139        if options.exclude_task_dependencies {
140            let roots = graph.roots.clone();
141            graph.tasks.retain(|id, _| roots.contains(id));
142            for task in graph.tasks.values_mut() {
143                task.dependencies.retain(|id| roots.contains(id));
144            }
145        }
146        Ok(graph)
147    }
148
149    /// Nx's `makeAcyclic`: from each task, depth first, a dependency on a
150    /// task already on the path is dropped.
151    fn make_acyclic(&mut self) {
152        fn visit(
153            graph: &mut BTreeMap<String, Task>,
154            id: &str,
155            visited: &mut BTreeSet<String>,
156            path: &mut Vec<String>,
157        ) {
158            if !visited.insert(id.to_owned()) {
159                return;
160            }
161            let dependencies: Vec<String> = graph[id].dependencies.iter().cloned().collect();
162            for dependency in dependencies {
163                if path.contains(&dependency) {
164                    graph.get_mut(id).unwrap().dependencies.remove(&dependency);
165                } else {
166                    path.push(dependency.clone());
167                    visit(graph, &dependency, visited, path);
168                    path.pop();
169                }
170            }
171        }
172        let ids: Vec<String> = self.tasks.keys().cloned().collect();
173        let mut visited = BTreeSet::new();
174        for id in ids {
175            let mut path = vec![id.clone()];
176            visit(&mut self.tasks, &id, &mut visited, &mut path);
177        }
178    }
179}
180
181struct Builder<'a> {
182    workspace: &'a Workspace,
183    projects: ProjectGraph,
184    tasks: BTreeMap<String, Task>,
185    visiting: Vec<String>,
186    visited: BTreeSet<(String, Option<String>)>,
187    ignore_cycles: bool,
188    cycles: Vec<Vec<String>>,
189}
190
191impl Builder<'_> {
192    fn visit(&mut self, request: Request, required: bool) -> Result<Option<String>> {
193        let project = self
194            .workspace
195            .projects
196            .get(&request.project)
197            .with_context(|| format!("unknown project {:?}", request.project))?;
198        let Some(target) = project.targets.get(&request.target) else {
199            if required {
200                bail!(
201                    "project {:?} has no target {:?}",
202                    request.project,
203                    request.target
204                );
205            }
206            return Ok(None);
207        };
208        let requested_configuration = request.requested_configuration.clone();
209        let configuration = match request.configuration {
210            Some(name) if target.configurations.contains_key(&name) => Some(name),
211            Some(name) if required => bail!(
212                "{}:{} has no configuration {name:?}",
213                request.project,
214                request.target
215            ),
216            _ => target.default_configuration.clone(),
217        };
218        let id = format!(
219            "{}:{}{}",
220            request.project,
221            request.target,
222            configuration
223                .as_ref()
224                .map(|c| format!(":{c}"))
225                .unwrap_or_default()
226        );
227        if let Some(position) = self.visiting.iter().position(|task| task == &id) {
228            let mut cycle = self.visiting[position..].to_vec();
229            cycle.push(id.clone());
230            if !self.ignore_cycles {
231                bail!("task dependency cycle: {}", cycle.join(" -> "));
232            }
233            // The edge stays until the graph is complete, then the cycle is broken.
234            self.cycles.push(cycle);
235            return Ok(Some(id));
236        }
237        if let Some(existing) = self.tasks.get(&id) {
238            if existing.args != request.args {
239                bail!("task {id} requested with conflicting forwarded arguments");
240            }
241            if self
242                .visited
243                .contains(&(id.clone(), requested_configuration.clone()))
244            {
245                return Ok(Some(id));
246            }
247        }
248        let mut definition = target.clone();
249        if let Some(name) = &configuration {
250            let options = target
251                .configurations
252                .get(name)
253                .with_context(|| format!("{id}: default configuration does not exist"))?;
254            for (key, value) in options {
255                if key == "outputs" {
256                    definition.outputs =
257                        Some(serde_json::from_value(value.clone()).with_context(|| {
258                            format!("{id}: configuration outputs must be an array of paths")
259                        })?);
260                } else {
261                    if key == "env" {
262                        value.as_object().with_context(|| {
263                            format!("{id}: configuration env must be an object")
264                        })?;
265                    }
266                    definition.options.insert(key.clone(), value.clone());
267                }
268            }
269        }
270        let task = Task {
271            id: id.clone(),
272            project: request.project,
273            target: request.target,
274            configuration,
275            args: request.args,
276            definition,
277            dependencies: BTreeSet::new(),
278        };
279        self.visiting.push(id.clone());
280        let mut dependencies = self
281            .tasks
282            .get(&id)
283            .map(|task| task.dependencies.clone())
284            .unwrap_or_default();
285        self.collect(
286            &task,
287            &task.project,
288            requested_configuration.as_deref(),
289            &mut dependencies,
290            &mut BTreeSet::new(),
291        )?;
292        self.visiting.pop();
293        self.visited.insert((id.clone(), requested_configuration));
294        self.tasks.insert(
295            id.clone(),
296            Task {
297                dependencies,
298                ..task
299            },
300        );
301        Ok(Some(id))
302    }
303
304    /// Resolves `dependsOn` with project dependencies taken from `derive_from`.
305    /// Like Nx, a dependency without the target stands in for the task: the
306    /// whole `dependsOn` list is applied again from there, so `^tsc` reaches
307    /// the nearest projects that have `tsc`.
308    fn collect(
309        &mut self,
310        task: &Task,
311        derive_from: &str,
312        requested_configuration: Option<&str>,
313        dependencies: &mut BTreeSet<String>,
314        seen: &mut BTreeSet<String>,
315    ) -> Result<()> {
316        if !seen.insert(derive_from.to_owned()) {
317            return Ok(());
318        }
319        for dependency in task.definition.depends_on.as_deref().unwrap_or_default() {
320            let (requests, derived) = self
321                .dependencies(task, derive_from, requested_configuration, dependency)
322                .with_context(|| format!("dependsOn of {}", task.id))?;
323            for request in requests {
324                let has_target = self.workspace.projects[&request.project]
325                    .targets
326                    .contains_key(&request.target);
327                if derived && !has_target {
328                    let project = request.project;
329                    self.collect(task, &project, requested_configuration, dependencies, seen)?;
330                } else if request.project == task.project && request.target == task.target {
331                    // Reached back to itself through a dependency cycle.
332                    continue;
333                } else if let Some(id) = self.visit(request, false)? {
334                    dependencies.insert(id);
335                }
336            }
337        }
338        Ok(())
339    }
340
341    /// The requests one `dependsOn` entry makes, and whether its projects are
342    /// `derive_from`'s dependencies rather than named ones.
343    fn dependencies(
344        &self,
345        task: &Task,
346        derive_from: &str,
347        requested_configuration: Option<&str>,
348        value: &Value,
349    ) -> Result<(Vec<Request>, bool)> {
350        let mut forward_options = false;
351        let (target, projects, forward, derived) = match value {
352            Value::String(value) => {
353                if let Some(target) = value.strip_prefix('^') {
354                    (
355                        target.to_owned(),
356                        self.project_dependencies(derive_from),
357                        false,
358                        true,
359                    )
360                } else if let Some((project, target)) = value.split_once(':') {
361                    (target.to_owned(), vec![project.to_owned()], false, false)
362                } else {
363                    (value.clone(), vec![task.project.clone()], false, false)
364                }
365            }
366            Value::Object(object) => {
367                let target = object
368                    .get("target")
369                    .and_then(Value::as_str)
370                    .context("dependency target must be a string")?;
371                let forward = match object.get("params").and_then(Value::as_str) {
372                    None if !object.contains_key("params") => false,
373                    Some("ignore") => false,
374                    Some("forward") => true,
375                    _ => bail!("dependency params must be forward or ignore"),
376                };
377                forward_options = match object.get("options").and_then(Value::as_str) {
378                    None if !object.contains_key("options") => false,
379                    Some("ignore") => false,
380                    Some("forward") => true,
381                    _ => bail!("dependency options must be forward or ignore"),
382                };
383                for key in object.keys() {
384                    if !matches!(
385                        key.as_str(),
386                        "target" | "projects" | "dependencies" | "params" | "options"
387                    ) {
388                        bail!("unsupported dependency field {key:?}");
389                    }
390                }
391                let dependencies = match object.get("dependencies") {
392                    Some(value) => value
393                        .as_bool()
394                        .context("dependency dependencies must be boolean")?,
395                    None => false,
396                };
397                if dependencies && object.contains_key("projects") {
398                    bail!("dependency cannot specify both projects and dependencies: true");
399                }
400                let projects = if dependencies {
401                    self.project_dependencies(derive_from)
402                } else if let Some(selectors) = object.get("projects") {
403                    let mut selectors: Vec<String> = match selectors {
404                        Value::String(selector) => vec![selector.clone()],
405                        value => serde_json::from_value(value.clone())
406                            .context("dependency projects must be a string or string array")?,
407                    };
408                    for selector in &mut selectors {
409                        if selector == "self" {
410                            *selector = task.project.clone();
411                        } else if selector == "!self" {
412                            *selector = format!("!{}", task.project);
413                        }
414                    }
415                    select_projects(&self.workspace.projects, &selectors, &[])?
416                        .into_iter()
417                        .collect()
418                } else {
419                    vec![task.project.clone()]
420                };
421                (target.to_owned(), projects, forward, dependencies)
422            }
423            _ => bail!("dependency must be a string or object"),
424        };
425        if target.is_empty() || target.contains(':') || target.starts_with('^') {
426            bail!("invalid dependency target {target:?}");
427        }
428        // As in Nx, a glob stands for every target name in the workspace it
429        // matches, each then depended on as if named.
430        let targets: Vec<String> = if qk_config::is_glob(&target) {
431            let matcher = globset::Glob::new(&target)
432                .with_context(|| format!("invalid dependency target {target:?}"))?
433                .compile_matcher();
434            self.workspace
435                .projects
436                .values()
437                .flat_map(|project| project.targets.keys())
438                .filter(|name| matcher.is_match(name.as_str()))
439                .cloned()
440                .collect::<BTreeSet<_>>()
441                .into_iter()
442                .collect()
443        } else {
444            vec![target]
445        };
446        // As in Nx, `options: "forward"` passes the task's options, its
447        // configuration's included, as overrides, before any forwarded
448        // arguments so that those win.
449        let mut args = Vec::new();
450        if forward_options {
451            for (key, value) in &task.definition.options {
452                option_flags(key, value, &mut args);
453            }
454        }
455        if forward {
456            args.extend(task.args.iter().cloned());
457        }
458        let requests = targets
459            .iter()
460            .flat_map(|target| {
461                projects.iter().map(|project| Request {
462                    project: project.clone(),
463                    target: target.clone(),
464                    configuration: requested_configuration.map(str::to_owned),
465                    requested_configuration: requested_configuration.map(str::to_owned),
466                    args: args.clone(),
467                })
468            })
469            .collect();
470        Ok((requests, derived))
471    }
472
473    fn project_dependencies(&self, project: &str) -> Vec<String> {
474        // A pair can have several edge kinds; each dependency counts once.
475        self.projects.dependencies[project]
476            .iter()
477            .map(|edge| edge.target.clone())
478            .collect::<BTreeSet<_>>()
479            .into_iter()
480            .collect()
481    }
482}
483
484/// An option as the flags that set it: `--name=value`, and `--name.key=value`
485/// for each field of an object. A list cannot be spelled as a flag, and is
486/// left out.
487fn option_flags(key: &str, value: &Value, flags: &mut Vec<String>) {
488    match value {
489        Value::Object(fields) => {
490            for (field, value) in fields {
491                option_flags(&format!("{key}.{field}"), value, flags);
492            }
493        }
494        Value::String(text) => flags.push(format!("--{key}={text}")),
495        Value::Number(number) => flags.push(format!("--{key}={number}")),
496        Value::Bool(flag) => flags.push(format!("--{key}={flag}")),
497        Value::Array(_) | Value::Null => {}
498    }
499}