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;
8use serde::Serialize;
9use serde_json::{Map, 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    /// The `inputs` and `waitFor` of the `dependsOn` entries that named each
78    /// dependency, where they differ from the defaults. They only matter for
79    /// a continuous dependency.
80    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
81    pub edges: BTreeMap<String, Edge>,
82}
83
84impl Task {
85    /// How this task depends on `dependency`.
86    pub fn edge(&self, dependency: &str) -> Edge {
87        self.edges.get(dependency).copied().unwrap_or_default()
88    }
89}
90
91/// How a task depends on a continuous task, from its `dependsOn` entries.
92#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
93#[serde(rename_all = "camelCase")]
94pub struct Edge {
95    /// Whether the continuous task's inputs count for this task. Nx adds them
96    /// to the dependent's hash and affected selection unless the entry sets
97    /// `inputs: false`.
98    pub inputs: bool,
99    pub wait_for: WaitFor,
100}
101
102impl Default for Edge {
103    fn default() -> Self {
104        Self {
105            inputs: true,
106            wait_for: WaitFor::Started,
107        }
108    }
109}
110
111impl Edge {
112    /// Two entries naming one dependency, as Nx merges them: waiting for
113    /// readiness wins, and the inputs count unless every entry leaves them out.
114    fn merge(self, other: Self) -> Self {
115        Self {
116            inputs: self.inputs || other.inputs,
117            wait_for: self.wait_for.max(other.wait_for),
118        }
119    }
120}
121
122/// When a task depending on a continuous task starts: once that task has
123/// started, or once its target-level `readyWhen` check has passed.
124#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Serialize)]
125#[serde(rename_all = "camelCase")]
126pub enum WaitFor {
127    #[default]
128    Started,
129    Ready,
130}
131
132/// The dependencies `dependsOn` resolves to, with how each is depended on.
133#[derive(Default)]
134struct Dependencies {
135    ids: BTreeSet<String>,
136    edges: BTreeMap<String, Edge>,
137}
138
139impl Dependencies {
140    fn insert(&mut self, id: String, edge: Edge) {
141        let edge = if self.ids.insert(id.clone()) {
142            edge
143        } else {
144            self.edges.get(&id).copied().unwrap_or_default().merge(edge)
145        };
146        if edge == Edge::default() {
147            self.edges.remove(&id);
148        } else {
149            self.edges.insert(id, edge);
150        }
151    }
152}
153
154#[derive(Debug, Default, Serialize)]
155pub struct TaskGraph {
156    pub roots: BTreeSet<String>,
157    pub tasks: BTreeMap<String, Task>,
158    /// The cycles broken under `ignore_cycles`, each as the tasks around it.
159    #[serde(skip)]
160    pub cycles: Vec<Vec<String>>,
161    /// What the graph was built from, so that [`TaskGraph::rebuild`] can
162    /// build it again from another configuration.
163    #[serde(skip)]
164    pub requests: Vec<Request>,
165    #[serde(skip)]
166    pub options: BuildOptions,
167    /// The tasks `exclude_task_dependencies` left out, whole, and the
168    /// dependencies it took from the tasks it kept: not run, but what a kept
169    /// task's `dependentTasksOutputFiles` inputs still read the outputs of.
170    #[serde(skip)]
171    pub excluded: Excluded,
172}
173
174/// What `exclude_task_dependencies` took out of a graph.
175#[derive(Clone, Debug, Default)]
176pub struct Excluded {
177    pub tasks: BTreeMap<String, Task>,
178    /// The dependencies each kept task had before they were pruned.
179    pub original_dependencies: BTreeMap<String, BTreeSet<String>>,
180}
181
182/// Nx's options for building a task graph.
183#[derive(Clone, Copy, Debug, Default)]
184pub struct BuildOptions {
185    /// `--exclude-task-dependencies`: only the requested tasks, without the
186    /// tasks they depend on.
187    pub exclude_task_dependencies: bool,
188    /// `--nx-ignore-cycles`: break each cycle rather than failing, as Nx's
189    /// `makeAcyclic` does, by dropping the dependency that closes it.
190    pub ignore_cycles: bool,
191}
192
193impl TaskGraph {
194    pub fn build(workspace: &Workspace, requests: &[Request]) -> Result<Self> {
195        Self::build_with(workspace, requests, BuildOptions::default())
196    }
197
198    pub fn build_with(
199        workspace: &Workspace,
200        requests: &[Request],
201        options: BuildOptions,
202    ) -> Result<Self> {
203        if requests.is_empty() {
204            bail!("no tasks selected");
205        }
206        let projects = ProjectGraph::build(workspace)?;
207        let mut builder = Builder {
208            workspace,
209            projects,
210            tasks: BTreeMap::new(),
211            visiting: Vec::new(),
212            visited: BTreeSet::new(),
213            ignore_cycles: options.ignore_cycles,
214            cycles: Vec::new(),
215        };
216        let mut roots = BTreeSet::new();
217        for request in requests {
218            let cli = request.args.clone();
219            let visit = Visit {
220                request: request.clone(),
221                overrides: Map::new(),
222                cli: &cli,
223            };
224            roots.insert(builder.visit(visit, true)?.with_context(|| {
225                format!(
226                    "project {:?} has no target {:?}",
227                    request.project, request.target
228                )
229            })?);
230        }
231        let mut graph = Self {
232            roots,
233            tasks: builder.tasks,
234            cycles: builder.cycles,
235            requests: requests.to_vec(),
236            options,
237            excluded: Excluded::default(),
238        };
239        if !graph.cycles.is_empty() {
240            graph.make_acyclic();
241        }
242        if options.exclude_task_dependencies {
243            let roots = graph.roots.clone();
244            let (kept, left): (BTreeMap<_, _>, BTreeMap<_, _>) = std::mem::take(&mut graph.tasks)
245                .into_iter()
246                .partition(|(id, _)| roots.contains(id));
247            graph.tasks = kept;
248            graph.excluded.tasks = left;
249            for task in graph.tasks.values_mut() {
250                graph
251                    .excluded
252                    .original_dependencies
253                    .insert(task.id.clone(), task.dependencies.clone());
254                task.dependencies.retain(|id| roots.contains(id));
255                task.edges.retain(|id, _| roots.contains(id));
256            }
257        }
258        Ok(graph)
259    }
260
261    /// The tasks whose declared outputs `task`'s `dependentTasksOutputFiles`
262    /// inputs read: its direct dependencies, or with `transitive` every task
263    /// it depends on. Those `exclude_task_dependencies` left out count: they
264    /// are not run, but their outputs as they are on disk still key `task`,
265    /// which would otherwise be keyed as though it read none.
266    pub fn output_dependencies(&self, task: &Task, transitive: bool) -> BTreeSet<String> {
267        let mut selected = self
268            .excluded
269            .original_dependencies
270            .get(&task.id)
271            .unwrap_or(&task.dependencies)
272            .clone();
273        if transitive {
274            let mut pending = selected.clone();
275            while let Some(id) = pending.pop_first() {
276                let Some(dependency) = self.keyed_task(&id) else {
277                    continue;
278                };
279                let dependencies = self
280                    .excluded
281                    .original_dependencies
282                    .get(&id)
283                    .unwrap_or(&dependency.dependencies);
284                for next in dependencies {
285                    if selected.insert(next.clone()) {
286                        pending.insert(next.clone());
287                    }
288                }
289            }
290        }
291        selected
292    }
293
294    /// The task `id`, among those that run or those
295    /// `exclude_task_dependencies` left out.
296    pub fn keyed_task(&self, id: &str) -> Option<&Task> {
297        self.tasks.get(id).or_else(|| self.excluded.tasks.get(id))
298    }
299
300    /// The graph the same requests make of `workspace`, with `extra`
301    /// requested beside them.
302    pub fn rebuild(&self, workspace: &Workspace, extra: &[Request]) -> Result<Self> {
303        let mut requests = self.requests.clone();
304        requests.extend_from_slice(extra);
305        Self::build_with(workspace, &requests, self.options)
306    }
307
308    /// Nx's `makeAcyclic`: from each task, depth first, a dependency on a
309    /// task already on the path is dropped.
310    fn make_acyclic(&mut self) {
311        fn visit(
312            graph: &mut BTreeMap<String, Task>,
313            id: &str,
314            visited: &mut BTreeSet<String>,
315            path: &mut Vec<String>,
316        ) {
317            if !visited.insert(id.to_owned()) {
318                return;
319            }
320            let dependencies: Vec<String> = graph[id].dependencies.iter().cloned().collect();
321            for dependency in dependencies {
322                if path.contains(&dependency) {
323                    let task = graph.get_mut(id).unwrap();
324                    task.dependencies.remove(&dependency);
325                    task.edges.remove(&dependency);
326                } else {
327                    path.push(dependency.clone());
328                    visit(graph, &dependency, visited, path);
329                    path.pop();
330                }
331            }
332        }
333        let ids: Vec<String> = self.tasks.keys().cloned().collect();
334        let mut visited = BTreeSet::new();
335        for id in ids {
336            let mut path = vec![id.clone()];
337            visit(&mut self.tasks, &id, &mut visited, &mut path);
338        }
339    }
340}
341
342/// What one `dependsOn` entry declares: its requests, the options it
343/// forwards, whether its projects are dependencies rather than named ones,
344/// and how it depends on them.
345struct Declared {
346    requests: Vec<Request>,
347    overrides: Map<String, Value>,
348    derived: bool,
349    edge: Edge,
350}
351
352/// A task to visit: its request, the options a dependent forwards to it,
353/// and the run's own arguments, which `params: "forward"` passes on.
354struct Visit<'c> {
355    request: Request,
356    overrides: Map<String, Value>,
357    cli: &'c [String],
358}
359
360struct Builder<'a> {
361    workspace: &'a Workspace,
362    projects: ProjectGraph,
363    tasks: BTreeMap<String, Task>,
364    visiting: Vec<String>,
365    visited: BTreeSet<(String, Option<String>)>,
366    ignore_cycles: bool,
367    cycles: Vec<Vec<String>>,
368}
369
370impl Builder<'_> {
371    fn visit(&mut self, visit: Visit, required: bool) -> Result<Option<String>> {
372        let Visit {
373            request,
374            overrides,
375            cli,
376        } = visit;
377        let project = self
378            .workspace
379            .projects
380            .get(&request.project)
381            .with_context(|| format!("unknown project {:?}", request.project))?;
382        let Some(target) = project.targets.get(&request.target) else {
383            if required {
384                bail!(
385                    "project {:?} has no target {:?}",
386                    request.project,
387                    request.target
388                );
389            }
390            return Ok(None);
391        };
392        let requested_configuration = request.requested_configuration.clone();
393        let configuration = match request.configuration {
394            Some(name) if target.configurations.contains_key(&name) => Some(name),
395            Some(name) if required => bail!(
396                "{}:{} has no configuration {name:?}",
397                request.project,
398                request.target
399            ),
400            // As in Nx, an empty default names no configuration.
401            _ => target
402                .default_configuration
403                .clone()
404                .filter(|name| !name.is_empty()),
405        };
406        let id = format!(
407            "{}:{}{}",
408            request.project,
409            request.target,
410            configuration
411                .as_ref()
412                .map(|c| format!(":{c}"))
413                .unwrap_or_default()
414        );
415        if let Some(position) = self.visiting.iter().position(|task| task == &id) {
416            let mut cycle = self.visiting[position..].to_vec();
417            cycle.push(id.clone());
418            if !self.ignore_cycles {
419                bail!("task dependency cycle: {}", cycle.join(" -> "));
420            }
421            // The edge stays until the graph is complete, then the cycle is broken.
422            self.cycles.push(cycle);
423            return Ok(Some(id));
424        }
425        if self
426            .visited
427            .contains(&(id.clone(), requested_configuration.clone()))
428        {
429            return Ok(Some(id));
430        }
431        // As in Nx, a task reached again keeps the arguments and forwarded
432        // options it was first reached with.
433        let (args, definition) = match self.tasks.get(&id) {
434            Some(existing) => (existing.args.clone(), existing.definition.clone()),
435            None => {
436                let mut definition = target.clone();
437                if let Some(name) = &configuration {
438                    let options = target
439                        .configurations
440                        .get(name)
441                        .with_context(|| format!("{id}: default configuration does not exist"))?;
442                    for (key, value) in options {
443                        if key == "env" {
444                            value.as_object().with_context(|| {
445                                format!("{id}: configuration env must be an object")
446                            })?;
447                        }
448                        definition.options.insert(key.clone(), value.clone());
449                    }
450                }
451                // Forwarded options win over the target's own, as Nx's
452                // overrides do.
453                definition.options.extend(overrides);
454                (request.args, definition)
455            }
456        };
457        // As in Nx, creating a task validates its outputs.
458        if let Some(outputs) = &definition.outputs {
459            validate_outputs(outputs)?;
460        }
461        let task = Task {
462            id: id.clone(),
463            project: request.project,
464            target: request.target,
465            configuration,
466            args,
467            definition,
468            dependencies: BTreeSet::new(),
469            edges: BTreeMap::new(),
470        };
471        self.visiting.push(id.clone());
472        let mut dependencies = self
473            .tasks
474            .get(&id)
475            .map(|task| Dependencies {
476                ids: task.dependencies.clone(),
477                edges: task.edges.clone(),
478            })
479            .unwrap_or_default();
480        self.collect(
481            &task,
482            &task.project,
483            requested_configuration.as_deref(),
484            cli,
485            &mut dependencies,
486            &mut BTreeSet::new(),
487        )?;
488        self.visiting.pop();
489        self.visited.insert((id.clone(), requested_configuration));
490        self.tasks.insert(
491            id.clone(),
492            Task {
493                dependencies: dependencies.ids,
494                edges: dependencies.edges,
495                ..task
496            },
497        );
498        Ok(Some(id))
499    }
500
501    /// Resolves `dependsOn` with project dependencies taken from `derive_from`.
502    /// Like Nx, a dependency without the target stands in for the task: the
503    /// whole `dependsOn` list is applied again from there, so `^tsc` reaches
504    /// the nearest projects that have `tsc`.
505    fn collect(
506        &mut self,
507        task: &Task,
508        derive_from: &str,
509        requested_configuration: Option<&str>,
510        cli: &[String],
511        dependencies: &mut Dependencies,
512        seen: &mut BTreeSet<String>,
513    ) -> Result<()> {
514        if !seen.insert(derive_from.to_owned()) {
515            return Ok(());
516        }
517        for dependency in task.definition.depends_on.as_deref().unwrap_or_default() {
518            let Declared {
519                requests,
520                overrides,
521                derived,
522                edge,
523            } = self
524                .dependencies(task, derive_from, requested_configuration, cli, dependency)
525                .with_context(|| format!("dependsOn of {}", task.id))?;
526            for request in requests {
527                let has_target = self.workspace.projects[&request.project]
528                    .targets
529                    .contains_key(&request.target);
530                if derived && !has_target {
531                    let project = request.project;
532                    self.collect(
533                        task,
534                        &project,
535                        requested_configuration,
536                        cli,
537                        dependencies,
538                        seen,
539                    )?;
540                } else if request.project == task.project && request.target == task.target {
541                    // Reached back to itself through a dependency cycle.
542                    continue;
543                } else {
544                    let visit = Visit {
545                        request,
546                        overrides: overrides.clone(),
547                        cli,
548                    };
549                    if let Some(id) = self.visit(visit, false)? {
550                        dependencies.insert(id, edge);
551                    }
552                }
553            }
554        }
555        Ok(())
556    }
557
558    /// What one `dependsOn` entry declares, with project dependencies taken
559    /// from `derive_from`.
560    fn dependencies(
561        &self,
562        task: &Task,
563        derive_from: &str,
564        requested_configuration: Option<&str>,
565        cli: &[String],
566        value: &Value,
567    ) -> Result<Declared> {
568        let mut forward_options = false;
569        let mut edge = Edge::default();
570        let (target, projects, forward, derived) = match value {
571            Value::String(value) => {
572                if let Some(target) = value.strip_prefix('^') {
573                    (
574                        target.to_owned(),
575                        self.project_dependencies(derive_from),
576                        false,
577                        true,
578                    )
579                } else {
580                    let (project, target) = self.target_reference(value, &task.project);
581                    (target, vec![project], false, false)
582                }
583            }
584            Value::Object(object) => {
585                // As in Nx, fields it does not know are ignored, and any
586                // `params` or `options` but `forward` leaves them behind.
587                let target = object
588                    .get("target")
589                    .and_then(Value::as_str)
590                    .context("dependency target must be a string")?;
591                let forward = object.get("params").and_then(Value::as_str) == Some("forward");
592                forward_options = object.get("options").and_then(Value::as_str) == Some("forward");
593                if let Some(inputs) = object.get("inputs") {
594                    edge.inputs = inputs
595                        .as_bool()
596                        .context("dependency inputs must be boolean")?;
597                }
598                edge.wait_for = match object.get("waitFor").and_then(Value::as_str) {
599                    None if !object.contains_key("waitFor") => WaitFor::Started,
600                    Some("started") => WaitFor::Started,
601                    Some("ready") => WaitFor::Ready,
602                    _ => bail!("dependency waitFor must be started or ready"),
603                };
604                let dependencies = match object.get("dependencies") {
605                    Some(value) => value
606                        .as_bool()
607                        .context("dependency dependencies must be boolean")?,
608                    None => false,
609                };
610                // Named projects win over `dependencies: true`, as in Nx.
611                let projects = if let Some(selectors) = object.get("projects") {
612                    let selectors: Vec<String> = match selectors {
613                        // Nx 24 dropped the Nx 15 spellings: a task's own
614                        // project is no `projects`, its dependencies `^target`.
615                        Value::String(selector)
616                            if matches!(selector.as_str(), "self" | "dependencies")
617                                && !self.workspace.projects.contains_key(selector) =>
618                        {
619                            bail!(
620                                "projects: {selector:?} is no longer supported; leave projects \
621                                 out for the task's own project, or use dependencies: true"
622                            )
623                        }
624                        Value::String(selector) => vec![selector.clone()],
625                        value => serde_json::from_value(value.clone())
626                            .context("dependency projects must be a string or string array")?,
627                    };
628                    qk_config::find_matching_projects(
629                        &selectors,
630                        &self.workspace.projects,
631                        qk_config::Reader::Nx,
632                    )
633                } else if dependencies {
634                    self.project_dependencies(derive_from)
635                } else {
636                    vec![task.project.clone()]
637                };
638                let derived = dependencies && !object.contains_key("projects");
639                (target.to_owned(), projects, forward, derived)
640            }
641            _ => bail!("dependency must be a string or object"),
642        };
643        // As in Nx, a glob stands for every target name in the workspace it
644        // matches as minimatch matches it, each then depended on as if named.
645        let targets: Vec<String> = if qk_config::is_glob(&target) {
646            let matcher = qk_config::minimatch::Minimatch::new(&target, false);
647            self.workspace
648                .projects
649                .values()
650                .flat_map(|project| project.targets.keys())
651                .filter(|name| matcher.is_match(name))
652                .cloned()
653                .collect::<BTreeSet<_>>()
654                .into_iter()
655                .collect()
656        } else {
657            vec![target]
658        };
659        // As in Nx, `options: "forward"` passes the target's options, its
660        // configuration's included, as the dependency's own, and
661        // `params: "forward"` the run's arguments.
662        let mut overrides = Map::new();
663        if forward_options {
664            let source = &self.workspace.projects[&task.project].targets[&task.target];
665            overrides.extend(source.options.clone());
666            if let Some(options) = task
667                .configuration
668                .as_ref()
669                .and_then(|name| source.configurations.get(name))
670            {
671                overrides.extend(options.clone());
672            }
673        }
674        let args = if forward { cli.to_vec() } else { Vec::new() };
675        let requests = targets
676            .iter()
677            .flat_map(|target| {
678                projects.iter().map(|project| Request {
679                    project: project.clone(),
680                    target: target.clone(),
681                    configuration: requested_configuration.map(str::to_owned),
682                    requested_configuration: requested_configuration.map(str::to_owned),
683                    args: args.clone(),
684                })
685            })
686            .collect();
687        Ok(Declared {
688            requests,
689            overrides,
690            derived,
691            edge,
692        })
693    }
694
695    /// Nx's `readProjectAndTargetFromTargetString`: `project:target` when
696    /// the first part names a project, and otherwise a target of the task's
697    /// own project, colons and all.
698    fn target_reference(&self, text: &str, current: &str) -> (String, String) {
699        let parts = split_target(text, &self.workspace.projects, current);
700        match &parts[..] {
701            [target] => (current.to_owned(), target.clone()),
702            [project, rest @ ..] if self.workspace.projects.contains_key(project) => {
703                (project.clone(), rest.join(":"))
704            }
705            _ => (current.to_owned(), text.to_owned()),
706        }
707    }
708
709    fn project_dependencies(&self, project: &str) -> Vec<String> {
710        // A pair can have several edge kinds; each dependency counts once.
711        self.projects.dependencies[project]
712            .iter()
713            .map(|edge| edge.target.clone())
714            .collect::<BTreeSet<_>>()
715            .into_iter()
716            .collect()
717    }
718}
719
720/// Nx's `splitByColons`: parts between colons, a double-quoted run keeping
721/// its colons.
722fn split_by_colons(text: &str) -> Vec<String> {
723    let mut parts = vec![String::new()];
724    let mut chars = text.chars();
725    while let Some(c) = chars.next() {
726        match c {
727            ':' => parts.push(String::new()),
728            '"' => {
729                for c in chars.by_ref() {
730                    if c == '"' {
731                        break;
732                    }
733                    parts.last_mut().unwrap().push(c);
734                }
735            }
736            c => parts.last_mut().unwrap().push(c),
737        }
738    }
739    parts
740}
741
742/// Every `[project, target?, configuration?]` reading of the parts that
743/// names a project and, when given, one of its targets and configurations;
744/// read on the current project too, as a bare target.
745fn readings(
746    parts: &[String],
747    projects: &BTreeMap<String, qk_config::Project>,
748    current: &str,
749) -> Vec<Vec<String>> {
750    let mut readings = Vec::new();
751    let read = |project: &str, rest: &[String], readings: &mut Vec<Vec<String>>| {
752        let targets = &projects[project].targets;
753        for end in 1..=rest.len() {
754            let target = rest[..end].join(":");
755            let Some(definition) = targets.get(&target) else {
756                continue;
757            };
758            if end == rest.len() {
759                readings.push(vec![project.to_owned(), target]);
760            } else {
761                let configuration = rest[end..].join(":");
762                if definition.configurations.contains_key(&configuration) {
763                    readings.push(vec![project.to_owned(), target, configuration]);
764                }
765            }
766        }
767    };
768    if projects.contains_key(current) {
769        read(current, parts, &mut readings);
770    }
771    for end in 1..=parts.len() {
772        let project = parts[..end].join(":");
773        if !projects.contains_key(&project) {
774            continue;
775        }
776        if end == parts.len() {
777            readings.push(vec![project]);
778        } else {
779            read(&project, &parts[end..], &mut readings);
780        }
781    }
782    readings
783}
784
785/// The reading Nx prefers: on the current project, then the longest
786/// project, target and configuration names.
787fn best(readings: Vec<Vec<String>>, current: &str) -> Option<Vec<String>> {
788    let rank = |reading: &Vec<String>| {
789        let length = |index: usize| reading.get(index).map_or(0, String::len);
790        (reading[0] == current, length(0), length(1), length(2))
791    };
792    let mut best: Option<Vec<String>> = None;
793    for reading in readings {
794        if best.as_ref().is_none_or(|best| rank(&reading) > rank(best)) {
795            best = Some(reading);
796        }
797    }
798    best
799}
800
801/// Nx's `splitTargetFromNodes`.
802fn split_target(
803    text: &str,
804    projects: &BTreeMap<String, qk_config::Project>,
805    current: &str,
806) -> Vec<String> {
807    let parts = split_by_colons(text);
808    if let Some(best) = best(readings(&parts, projects, current), current) {
809        return best;
810    }
811    let colon = match text.find(':') {
812        Some(0) => text[1..].find(':').map(|index| index + 1),
813        colon => colon,
814    };
815    if colon.is_none_or(|colon| colon == 0) {
816        return vec![text.to_owned()];
817    }
818    let mut project = parts[0].clone();
819    let mut rest = parts[1..].to_vec();
820    // A leading colon starts the longest known project name it can.
821    if project.is_empty() && !rest.is_empty() {
822        let mut absorbed = 1;
823        for count in (1..rest.len()).rev() {
824            if projects.contains_key(&format!(":{}", rest[..count].join(":"))) {
825                absorbed = count;
826                break;
827            }
828        }
829        project = format!(":{}", rest[..absorbed].join(":"));
830        rest.drain(..absorbed);
831    }
832    let Some(configuration) = rest.last().cloned() else {
833        return vec![project];
834    };
835    // The project and target, with a configuration they do not have.
836    if let Some(head) = text.get(..text.len().saturating_sub(configuration.len() + 1))
837        && let Some(best) = best(readings(&split_by_colons(head), projects, current), current)
838        && best.len() == 2
839    {
840        return vec![best[0].clone(), best[1].clone(), configuration];
841    }
842    let mut result = vec![project.clone()];
843    let targets = projects.get(&project).map(|project| &project.targets);
844    for end in (1..=rest.len()).rev() {
845        let target = rest[..end].join(":");
846        if targets.is_some_and(|targets| targets.contains_key(&target)) {
847            result.push(target);
848            if end < rest.len() {
849                result.push(rest[end..].join(":"));
850            }
851            return result;
852        }
853    }
854    result.extend(rest);
855    result
856}
857
858/// Nx 24's `validateOutputs`: each output starts with a token, `!` before it
859/// if negated, and one from `{workspaceRoot}` names a directory before any
860/// pattern. The error lists every output that breaks either rule, in Nx's
861/// words.
862pub fn validate_outputs(outputs: &[String]) -> Result<()> {
863    let missing_prefix: Vec<&String> = outputs
864        .iter()
865        .filter(|output| {
866            // Nx's `^!?\{[\s\S]+\}`: a `{` and, after at least one more
867            // character, a `}`.
868            let mut chars = output.strip_prefix('!').unwrap_or(output).chars();
869            !(chars.next() == Some('{') && chars.next().is_some() && chars.any(|c| c == '}'))
870        })
871        .collect();
872    let workspace_globs: Vec<&String> = outputs
873        .iter()
874        .filter(|output| !missing_prefix.contains(output))
875        .filter(|output| {
876            ["!{workspaceRoot}", "{workspaceRoot}"]
877                .iter()
878                .any(|prefix| output.strip_prefix(prefix).is_some_and(has_no_directory))
879        })
880        .collect();
881    let mut message = String::new();
882    if !missing_prefix.is_empty() {
883        message.push_str(&format!(
884            "The following outputs are invalid: \n{}\n\nRun `nx repair` to fix this.",
885            missing_prefix
886                .iter()
887                .map(|output| format!(
888                    " - {output}\n   ** Reason: Outputs must start with either \"{{workspaceRoot}}/\" or \"{{projectRoot}}/\"."
889                ))
890                .collect::<Vec<_>>()
891                .join("\n")
892        ));
893    }
894    if !workspace_globs.is_empty() {
895        message.push_str(&format!(
896            "The following outputs are defined by a glob pattern from the workspace root: \n - {}\n\nThese can be slow, replace them with a more specific pattern.",
897            workspace_globs
898                .iter()
899                .map(|output| output.as_str())
900                .collect::<Vec<_>>()
901                .join("\n - ")
902        ));
903    }
904    if message.is_empty() {
905        Ok(())
906    } else {
907        bail!(message)
908    }
909}
910
911/// Whether `glob` has a pattern before any named directory, as Nx's
912/// `partition_glob` reads it: its leading segments with no glob syntax once
913/// their escapes are read join to nothing. `@` and `+` start no pattern
914/// without a group after them.
915fn has_no_directory(glob: &str) -> bool {
916    let mut directory = Vec::new();
917    for segment in glob.split('/') {
918        let mut syntax = false;
919        let mut chars = segment.chars();
920        while let Some(c) = chars.next() {
921            match c {
922                '\\' => {
923                    chars.next();
924                }
925                '*' | '?' | '[' | ']' | '{' | '}' | '(' | ')' | '!' => syntax = true,
926                _ => {}
927            }
928        }
929        if syntax {
930            return directory.join("/").is_empty();
931        }
932        directory.push(segment);
933    }
934    false
935}