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