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