Skip to main content

qk_executor/
lib.rs

1//! Prepare finite tasks, then execute shell commands in managed process groups.
2
3mod bins;
4mod capture;
5pub mod dotenv;
6mod env_flags;
7mod interpolate;
8#[cfg(target_os = "linux")]
9pub mod overlay;
10mod process;
11mod providers;
12#[cfg(unix)]
13mod pty;
14pub mod readiness;
15pub mod ready_signal;
16pub mod report;
17pub mod tokens;
18
19use std::collections::BTreeMap;
20use std::ffi::OsString;
21use std::path::{Path, PathBuf};
22
23use anyhow::{Context, Result, bail};
24use qk_config::Workspace;
25use qk_taskgraph::Task;
26use serde_json::{Map, Value};
27
28pub use capture::{
29    Capture, Decoration, Display, MAX_LOG_BYTES, OutputStyle, RecordedLog, Shown, collapsed,
30    printable, read_capture, replay,
31};
32pub use env_flags::{force_colour, is_ci, is_ci_with};
33pub use process::{Outcome, execute, execute_captured, shell};
34
35#[derive(Debug)]
36struct NodeRuntime {
37    root: PathBuf,
38    env: BTreeMap<OsString, OsString>,
39    paths: Vec<PathBuf>,
40    directory: std::sync::OnceLock<Option<PathBuf>>,
41}
42
43/// The runner's Node selection, resolved only when a command starts.
44#[derive(Clone, Debug)]
45pub struct NodePath {
46    runtime: std::sync::Arc<NodeRuntime>,
47    local_entries: usize,
48}
49
50impl NodePath {
51    fn new(root: &Path, env: &BTreeMap<OsString, OsString>, local_entries: usize) -> Self {
52        type Key = (PathBuf, BTreeMap<OsString, OsString>);
53        static RUNTIMES: std::sync::OnceLock<
54            std::sync::Mutex<BTreeMap<Key, std::sync::Arc<NodeRuntime>>>,
55        > = std::sync::OnceLock::new();
56        let mut runtimes = RUNTIMES.get_or_init(Default::default).lock().unwrap();
57        let runtime = runtimes
58            .entry((root.to_owned(), env.clone()))
59            .or_insert_with(|| {
60                let paths = env
61                    .get(&path_key(env))
62                    .map(|path| std::env::split_paths(path).collect())
63                    .unwrap_or_default();
64                std::sync::Arc::new(NodeRuntime {
65                    root: root.to_owned(),
66                    env: env.clone(),
67                    paths,
68                    directory: Default::default(),
69                })
70            })
71            .clone();
72        Self {
73            runtime,
74            local_entries,
75        }
76    }
77
78    fn path(&self, env: &BTreeMap<OsString, OsString>) -> Result<Option<(OsString, OsString)>> {
79        // Select before task overrides, like process.execPath in Nx. Cache hits
80        // never call this, and concurrent commands share the same proxy probe.
81        let Some(directory) = self.runtime.directory.get_or_init(|| {
82            node_directory(&self.runtime.root, &self.runtime.paths, &self.runtime.env)
83        }) else {
84            return Ok(None);
85        };
86        let key = path_key(env);
87        let mut paths: Vec<_> = env
88            .get(&key)
89            .map(|path| std::env::split_paths(path).collect())
90            .unwrap_or_default();
91        paths.insert(self.local_entries.min(paths.len()), directory.clone());
92        Ok(Some((
93            key,
94            std::env::join_paths(paths).context("cannot construct task PATH")?,
95        )))
96    }
97}
98
99fn path_key(env: &BTreeMap<OsString, OsString>) -> OsString {
100    if cfg!(windows) {
101        env.keys()
102            .find(|key| key.to_string_lossy().eq_ignore_ascii_case("PATH"))
103            .cloned()
104            .unwrap_or_else(|| "PATH".into())
105    } else {
106        "PATH".into()
107    }
108}
109
110fn environment_key(env: &BTreeMap<OsString, OsString>, name: OsString) -> OsString {
111    if cfg!(windows) && name.to_string_lossy().eq_ignore_ascii_case("PATH") {
112        path_key(env)
113    } else {
114        name
115    }
116}
117
118fn node_directory(
119    cwd: &Path,
120    paths: &[PathBuf],
121    env: &BTreeMap<OsString, OsString>,
122) -> Option<PathBuf> {
123    let name = if cfg!(windows) { "node.exe" } else { "node" };
124    for directory in paths {
125        let candidate = cwd.join(directory).join(name);
126        let Ok(metadata) = candidate.metadata() else {
127            continue;
128        };
129        if !metadata.is_file() {
130            continue;
131        }
132        #[cfg(unix)]
133        {
134            use std::os::unix::fs::PermissionsExt;
135            if metadata.permissions().mode() & 0o111 == 0 {
136                continue;
137            }
138        }
139        // The first executable PATH match is authoritative, even if discovery fails.
140        let actual = candidate.canonicalize().ok()?;
141        if actual.file_name()? == name {
142            use std::io::Read;
143            let mut header = [0; 2];
144            let script = std::fs::File::open(&actual)
145                .and_then(|mut file| file.read_exact(&mut header))
146                .is_ok()
147                && header == *b"#!";
148            if !script {
149                return actual.parent().map(Path::to_owned);
150            }
151        }
152        return probe_node(&candidate, cwd, env);
153    }
154    None
155}
156
157fn probe_node(candidate: &Path, cwd: &Path, env: &BTreeMap<OsString, OsString>) -> Option<PathBuf> {
158    use std::io::{Read, Seek};
159    use std::process::{Command, Stdio};
160    use std::time::{Duration, Instant};
161    let mut output = tempfile::tempfile().ok()?;
162    let mut child = Command::new(candidate)
163        .args(["-p", "process.execPath"])
164        .current_dir(cwd)
165        .env_clear()
166        .envs(env)
167        .stdout(Stdio::from(output.try_clone().ok()?))
168        .stderr(Stdio::null())
169        .spawn()
170        .ok()?;
171    let started = Instant::now();
172    loop {
173        match child.try_wait() {
174            Ok(Some(status)) => {
175                if !status.success() {
176                    return None;
177                }
178                let mut bytes = Vec::new();
179                output.rewind().ok()?;
180                output.take(4096).read_to_end(&mut bytes).ok()?;
181                let path = PathBuf::from(std::str::from_utf8(&bytes).ok()?.trim());
182                return (path.is_absolute() && path.is_file())
183                    .then(|| path.parent().unwrap().to_owned());
184            }
185            Ok(None) if started.elapsed() < Duration::from_secs(2) => {
186                std::thread::sleep(Duration::from_millis(5))
187            }
188            _ => {
189                let _ = child.kill();
190                let _ = child.wait();
191                return None;
192            }
193        }
194    }
195}
196
197/// Reports a `qk:` warning to the installed sink; by default it is written to
198/// stderr in one write, so task processes sharing stderr cannot split it.
199#[macro_export]
200macro_rules! status {
201    ($($arg:tt)*) => {{
202        $crate::report::warning(&::std::format!($($arg)*));
203    }};
204}
205
206/// How a sandboxed task's commands are held to what it declares.
207#[derive(Clone, Debug)]
208pub enum Confinement {
209    /// macOS: a Seatbelt profile, through `sandbox-exec`.
210    Seatbelt(PathBuf),
211    /// Linux: a Landlock rule set's descriptor, which each command restricts
212    /// itself to before it starts. It stays open while the run lasts.
213    Landlock(i32),
214    /// Enforced, and could not be held to its plan, for this reason: its
215    /// commands fail rather than start unconfined.
216    Refused(String),
217    /// Linux: each command runs in its own user and mount namespace over an
218    /// overlay of the workspace, whose upper layer the sandbox reads when the
219    /// task ends.
220    Overlay(std::sync::Arc<Overlay>),
221}
222
223/// Where a task's overlays live, and what passes through them to the
224/// workspace.
225#[derive(Debug)]
226pub struct Overlay {
227    /// The workspace, which the overlay is mounted over.
228    pub workspace: PathBuf,
229    /// The task's directory: each command's layers in `command-<index>`.
230    pub directory: PathBuf,
231    /// Paths in the workspace bound through, so that what a command writes
232    /// there reaches the workspace directly.
233    pub passthrough: Vec<PathBuf>,
234    /// The Landlock rules each command restricts itself to once the overlay
235    /// is mounted, which reads must keep to.
236    pub rules: OverlayRules,
237    /// Where the task may write: what its commands wrote beneath these is
238    /// applied to the workspace as they end, and the rest is not.
239    pub keep: Vec<PathBuf>,
240    /// Set once the commands' writes are applied.
241    pub settled: std::sync::OnceLock<Settled>,
242}
243
244/// What applying a task's overlay to the workspace did not apply.
245#[derive(Debug, Default)]
246pub struct Settled {
247    /// Workspace paths written outside where the task may write.
248    pub discarded: std::collections::BTreeSet<String>,
249    /// What could not be read or applied, and why: outputs that may be
250    /// missing from the workspace.
251    pub failed: Vec<String>,
252}
253
254impl Overlay {
255    /// Applies what the task's commands wrote within `keep` to the
256    /// workspace, once, as they end: before the cache reads its outputs and
257    /// before any task depending on it starts.
258    pub fn settle(&self) -> &Settled {
259        self.settled.get_or_init(|| {
260            #[cfg(target_os = "linux")]
261            {
262                overlay::settle(self)
263            }
264            #[cfg(not(target_os = "linux"))]
265            {
266                Settled::default()
267            }
268        })
269    }
270}
271
272/// A Landlock rule set to build in a command's namespace. Paths outside the
273/// workspace, which the overlay leaves as they are, are opened beforehand;
274/// those in it are opened there, as the overlay's.
275#[derive(Debug, Default)]
276pub struct OverlayRules {
277    /// The rights the rule set handles, and those that apply to a file.
278    pub handled: u64,
279    pub file_rights: u64,
280    /// Opened outside the namespace: descriptors, held open until the task
281    /// ends, with the rights beneath each.
282    #[cfg(unix)]
283    pub outside: Vec<(std::os::fd::OwnedFd, u64)>,
284    /// The rights everywhere in the workspace, where writes reach the overlay
285    /// and are judged as the task ends.
286    pub workspace: u64,
287    /// Paths in the workspace with the rights beneath each, and whether to
288    /// reach it through no link.
289    pub inside: Vec<(PathBuf, u64, bool)>,
290}
291
292/// An external syscall recorder wrapping each command, with separate trace files.
293#[derive(Clone, Debug)]
294pub struct Recording {
295    /// The recorder's program: strace, or qk itself, whose helper loads the
296    /// eBPF recorder.
297    pub program: PathBuf,
298    pub directory: PathBuf,
299    /// Whether strace filters the traced calls in the kernel, with seccomp,
300    /// so untraced calls run at full speed. strace 5.3 and later can.
301    pub seccomp: bool,
302    /// The eBPF recorder, and the recordings of this task's commands it has
303    /// begun; `None` when strace records.
304    #[cfg(target_os = "linux")]
305    ebpf: Option<Ebpf>,
306}
307
308#[cfg(target_os = "linux")]
309#[derive(Clone, Debug)]
310struct Ebpf {
311    recorder: std::sync::Arc<qk_trace::Recorder>,
312    sessions: std::sync::Arc<std::sync::Mutex<Vec<qk_trace::Session>>>,
313    /// Why settling failed, kept so that a later settle, as the cache's and
314    /// then an audit's of one recording, does not find it whole.
315    failure: std::sync::Arc<std::sync::Mutex<Option<String>>>,
316    /// Whether an opened file's path is recorded with what its links lead
317    /// to, as strace records it, which costs a lookup an open.
318    resolve: bool,
319}
320
321/// The eBPF recorder, its helper started once a process: as qk itself when
322/// qk runs as root, else through `sudo -n`, so that it is never asked for a
323/// password.
324#[cfg(target_os = "linux")]
325fn ebpf_recorder() -> Result<std::sync::Arc<qk_trace::Recorder>, String> {
326    static RECORDER: std::sync::OnceLock<Result<std::sync::Arc<qk_trace::Recorder>, String>> =
327        std::sync::OnceLock::new();
328    RECORDER
329        .get_or_init(|| {
330            let qk = std::env::current_exe().map_err(|error| error.to_string())?;
331            // SAFETY: geteuid has no preconditions.
332            let helper = if unsafe { libc::geteuid() } == 0 {
333                let mut helper = std::process::Command::new(qk);
334                helper.arg("__trace-helper");
335                helper
336            } else {
337                let mut helper = std::process::Command::new("sudo");
338                helper.args(["-n", "--"]).arg(qk).arg("__trace-helper");
339                helper
340            };
341            qk_trace::Recorder::start(helper).map_err(|error| format!("{error:#}"))
342        })
343        .clone()
344}
345
346impl Recording {
347    /// The recorder `QK_RECORDER` picks: by default eBPF, where its helper
348    /// can load it, else strace on `PATH`, checked to record child
349    /// processes, with seccomp filtering where it supports it. Its trial
350    /// traces go in `directory`.
351    pub fn probe(directory: &Path) -> anyhow::Result<Self> {
352        let choice = std::env::var("QK_RECORDER").unwrap_or_default();
353        match choice.as_str() {
354            "" | "auto" | "ebpf" =>
355            {
356                #[cfg(target_os = "linux")]
357                match ebpf_recorder() {
358                    Ok(recorder) => {
359                        return Ok(Self {
360                            program: std::env::current_exe()?,
361                            directory: directory.to_owned(),
362                            seccomp: false,
363                            ebpf: Some(Ebpf {
364                                recorder,
365                                sessions: Default::default(),
366                                failure: Default::default(),
367                                resolve: false,
368                            }),
369                        });
370                    }
371                    Err(error) if choice == "ebpf" => {
372                        anyhow::bail!("cannot record with eBPF: {error}")
373                    }
374                    Err(_) => {}
375                }
376            }
377            "strace" => {}
378            other => anyhow::bail!("unknown QK_RECORDER {other:?}; use auto, ebpf or strace"),
379        }
380        let path = std::env::var_os("PATH").context("recording needs strace on PATH")?;
381        let program = std::env::split_paths(&path)
382            .map(|directory| directory.join("strace"))
383            .find(|candidate| candidate.is_file())
384            .context("recording needs strace on PATH; install strace and retry")?
385            .canonicalize()?;
386        let mut failure = String::new();
387        for seccomp in [true, false] {
388            let recording = Self {
389                program: program.clone(),
390                directory: directory.to_owned(),
391                seccomp,
392                #[cfg(target_os = "linux")]
393                ebpf: None,
394            };
395            let probe = recording
396                .command(0, "exec /bin/true", Path::new("/"))
397                .output()
398                .context("cannot probe strace")?;
399            if probe.status.success() {
400                return Ok(recording);
401            }
402            failure = String::from_utf8_lossy(&probe.stderr).into_owned();
403        }
404        anyhow::bail!("strace cannot record child processes: {failure}")
405    }
406
407    /// The same settings, with traces in `directory`.
408    /// The recorder's name, as reports give it: `ebpf` or `strace`.
409    pub fn backend(&self) -> &'static str {
410        #[cfg(target_os = "linux")]
411        if self.ebpf.is_some() {
412            return "ebpf";
413        }
414        "strace"
415    }
416
417    pub fn in_directory(&self, directory: PathBuf) -> Self {
418        Self {
419            directory,
420            #[cfg(target_os = "linux")]
421            ebpf: self.ebpf.as_ref().map(|ebpf| Ebpf {
422                recorder: ebpf.recorder.clone(),
423                sessions: Default::default(),
424                failure: Default::default(),
425                resolve: ebpf.resolve,
426            }),
427            ..self.clone()
428        }
429    }
430
431    /// The same, recording an opened file's path with what its links lead
432    /// to, as a report of what a task read shows it. strace always does.
433    pub fn resolving(self) -> Self {
434        #[cfg(target_os = "linux")]
435        {
436            let mut recording = self;
437            if let Some(ebpf) = &mut recording.ebpf {
438                ebpf.resolve = true;
439            }
440            recording
441        }
442        #[cfg(not(target_os = "linux"))]
443        self
444    }
445
446    /// Ends the recordings of the commands that have run, once they have
447    /// finished, so that their traces are whole. strace's are as soon as it
448    /// exits; the eBPF recorder's once its helper has sent every event.
449    pub fn settle(&self) -> anyhow::Result<()> {
450        #[cfg(target_os = "linux")]
451        if let Some(ebpf) = &self.ebpf {
452            if let Some(failure) = ebpf.failure.lock().unwrap().as_ref() {
453                anyhow::bail!("{failure}");
454            }
455            let sessions = std::mem::take(&mut *ebpf.sessions.lock().unwrap());
456            let mut sessions = sessions.into_iter();
457            while let Some(session) = sessions.next() {
458                if let Err(error) = ebpf.recorder.finish(session) {
459                    *ebpf.failure.lock().unwrap() = Some(format!("{error:#}"));
460                    // The rest without waiting again: a helper that did not
461                    // answer once will not now.
462                    sessions
463                        .by_ref()
464                        .for_each(|rest| ebpf.recorder.abandon(rest));
465                    return Err(error);
466                }
467            }
468        }
469        Ok(())
470    }
471
472    /// The same invocation is used by preflight and actual task commands,
473    /// which start in `cwd`.
474    pub fn command(&self, index: usize, text: &str, cwd: &Path) -> std::process::Command {
475        #[cfg(target_os = "linux")]
476        if let Some(ebpf) = &self.ebpf {
477            use std::os::unix::process::CommandExt;
478            let session = ebpf.recorder.begin(
479                &self.directory,
480                &format!("command-{index}"),
481                cwd,
482                ebpf.resolve,
483            );
484            ebpf.sessions.lock().unwrap().push(session);
485            let mut command = crate::process::shell(text);
486            // SAFETY: the hook makes one system call.
487            unsafe { command.pre_exec(ebpf.recorder.join(session)) };
488            return command;
489        }
490        let _ = cwd;
491        let mut command = std::process::Command::new(&self.program);
492        // No read/write buffers or exec argument/environment arrays.
493        if self.seccomp {
494            command.arg("--seccomp-bpf");
495        }
496        command
497            .args([
498                "-ff",
499                "-ttt",
500                "-yy",
501                "-qq",
502                "-s",
503                "4096",
504                "-e",
505                "trace=%file,%process,getdents64,fchdir,close,dup,dup2,dup3,getcwd",
506                "-e",
507                "raw=execve,execveat",
508                "-o",
509            ])
510            .arg(self.directory.join(format!("command-{index}")))
511            .args(["--", "/bin/sh", "-c", text]);
512        command
513    }
514}
515
516/// A scratch directory, below the workspace `root` it lies in, which the
517/// task keeps nothing in between runs.
518#[derive(Clone, Debug)]
519pub struct Scratch {
520    pub root: PathBuf,
521    pub directory: PathBuf,
522}
523
524#[derive(Clone, Debug)]
525pub struct PreparedTask {
526    pub id: String,
527    pub commands: Vec<String>,
528    pub parallel: bool,
529    pub cwd: PathBuf,
530    pub env: BTreeMap<OsString, OsString>,
531    /// Dotenv, explicit settings and local binary lookup without task metadata.
532    pub runtime_env: BTreeMap<OsString, OsString>,
533    pub node_path: Option<NodePath>,
534    /// The environment the task declares, `qk:env`, which its key holds;
535    /// `None` where it inherits every variable.
536    pub environment: Option<qk_config::env::Environment>,
537    /// Variables set for the process after `env` that are not part of what
538    /// the task is: nothing reads them to key it, such as a thread count.
539    pub execution: BTreeMap<OsString, OsString>,
540    /// Nx's `readyWhen`: text whose appearance in the output, every piece of
541    /// it, makes the task ready while its commands keep running.
542    pub ready_when: Vec<String>,
543    /// Set once all of `ready_when` has appeared.
544    pub ready: std::sync::Arc<std::sync::atomic::AtomicBool>,
545    /// A continuous target's own `readyWhen`, which tasks depending on it
546    /// with `waitFor: "ready"` wait for, or why it is invalid. Prepared, it is
547    /// resolved without `NX_READY_URL`; see [`PreparedTask::resolve_ready_check`].
548    pub ready_check: Option<std::result::Result<readiness::ReadyCheck, String>>,
549    /// What `ready_check` is resolved from, to resolve it again once the
550    /// servers the task waits for have reported their address.
551    pub ready_inputs: Option<std::sync::Arc<ReadyInputs>>,
552    /// What `ready_check` concluded.
553    pub verdict: std::sync::Arc<readiness::Verdict>,
554    /// The process ids of its commands while they run, from which the runner
555    /// measures the memory the task uses.
556    pub processes: std::sync::Arc<std::sync::Mutex<std::collections::BTreeSet<u32>>>,
557    /// Each command's prefix and colours, by position; empty without any.
558    pub decorations: Vec<Decoration>,
559    /// Whether the task reads qk's stdin, and has the terminal while it runs.
560    /// The runner sets it for the one task a run is for, when that task has a
561    /// single command whose output goes straight to the terminal.
562    pub interactive: bool,
563    /// Whether captured commands may run under a pseudo-terminal, which they
564    /// do when qk's output is a terminal: Nx's `usePty`. Commands whose
565    /// output is hashed set it to false, so keys never depend on a terminal.
566    pub pseudo_terminal: bool,
567    /// Nx's `tty`: whether qk's terminal is in raw input mode while the
568    /// interactive task reads it through a pseudo-terminal.
569    pub tty: bool,
570    /// What the commands run under, for `--sandbox`.
571    pub sandbox: Option<Confinement>,
572    pub recording: Option<Recording>,
573    /// Whether `recording` may key the task's result for `qk:ultracache`, as
574    /// `--record` gives it. A recording made only to audit the task does not.
575    pub recording_keys: bool,
576    /// Directories emptied before its commands run, which it keeps nothing
577    /// in between runs: its target's `qk:sandbox.scratch`.
578    pub scratch: Vec<Scratch>,
579    /// How the task's output is shown; the runner sets it per output style.
580    pub display: Display,
581}
582
583/// The dotenv files Nx loads for a task, most specific first: in the
584/// project root, then the workspace root, for the target and configuration
585/// (`.env.<target>.<configuration>`, `.env.<configuration>`, `.env.<target>`),
586/// then `.env.local`, `.local.env` and `.env`, each also in its `.local` and
587/// `.<name>.env` spellings. An atomized target also reads the files of the
588/// target it was split from, after its own of the same kind.
589fn dotenv_files(
590    project_root: &str,
591    target: &str,
592    configuration: Option<&str>,
593    non_atomized: Option<&str>,
594) -> Vec<String> {
595    let mut identifiers = Vec::new();
596    if let Some(configuration) = configuration {
597        identifiers.push(format!("{target}.{configuration}"));
598        if let Some(non_atomized) = non_atomized {
599            identifiers.push(format!("{non_atomized}.{configuration}"));
600        }
601        identifiers.push(configuration.to_owned());
602    }
603    identifiers.push(target.to_owned());
604    if let Some(non_atomized) = non_atomized {
605        identifiers.push(non_atomized.to_owned());
606    }
607    identifiers.push(String::new());
608    let variants = |identifier: &str, root: &str| -> Vec<String> {
609        let prefix = if root.is_empty() || root == "." {
610            String::new()
611        } else {
612            format!("{root}/")
613        };
614        if identifier.is_empty() {
615            [".env.local", ".local.env", ".env"]
616                .map(|name| format!("{prefix}{name}"))
617                .to_vec()
618        } else {
619            [
620                format!(".env.{identifier}.local"),
621                format!(".env.{identifier}"),
622                format!(".{identifier}.local.env"),
623                format!(".{identifier}.env"),
624            ]
625            .map(|name| format!("{prefix}{name}"))
626            .to_vec()
627        }
628    };
629    let mut files = Vec::new();
630    for root in [project_root, ""] {
631        for identifier in &identifiers {
632            // Colons in target names cannot name regular dotenv files on Windows.
633            #[cfg(windows)]
634            if identifier.contains(':') {
635                continue;
636            }
637            for file in variants(identifier, root) {
638                if !files.contains(&file) {
639                    files.push(file);
640                }
641            }
642        }
643    }
644    files
645}
646
647/// The target whose dotenv files a task reads, and the target it was
648/// atomized from, as Nx's getOwnerTargetForTask finds them: through the
649/// project's `metadata.targetGroups`, a target of the task's group that
650/// names a `metadata.nonAtomizedTarget`. Unlike Nx, a task that names one
651/// itself is its own owner, so each atomized target reads its own files
652/// rather than the first of its group's.
653fn owner_target<'a>(
654    project: &'a qk_config::Project,
655    target: &'a str,
656) -> (&'a str, Option<&'a str>) {
657    let non_atomized = |definition: &'a qk_config::Target| {
658        definition
659            .extra
660            .get("metadata")
661            .and_then(|metadata| metadata.get("nonAtomizedTarget"))
662            .and_then(Value::as_str)
663    };
664    if let Some((name, definition)) = project.targets.get_key_value(target)
665        && let Some(non_atomized) = non_atomized(definition)
666    {
667        return (name, Some(non_atomized));
668    }
669    let groups = project
670        .extra
671        .get("metadata")
672        .and_then(|metadata| metadata.get("targetGroups"))
673        .and_then(Value::as_object);
674    for group in groups.into_iter().flat_map(|groups| groups.values()) {
675        let members: Vec<&str> = group
676            .as_array()
677            .into_iter()
678            .flatten()
679            .filter_map(Value::as_str)
680            .collect();
681        if !members.contains(&target) {
682            continue;
683        }
684        for member in members {
685            if let Some((name, definition)) = project.targets.get_key_value(member)
686                && let Some(non_atomized) = non_atomized(definition)
687            {
688                return (name, Some(non_atomized));
689            }
690        }
691    }
692    (target, None)
693}
694
695/// The variable naming the tasks a task runs inside, one invocation a line.
696pub const INVOCATIONS: &str = "QK_TASK_INVOCATIONS";
697
698/// A task as an invocation: its id and arguments, which a task running inside
699/// it repeats only by recursing, as Nx's task invocation tracker tells it.
700pub fn invocation(task: &Task) -> String {
701    let mut text = task.id.clone();
702    for arg in &task.args {
703        text.push('\u{1f}');
704        text.push_str(arg);
705    }
706    text.replace('\n', " ")
707}
708
709/// The tasks this process runs inside, from [`INVOCATIONS`].
710pub fn enclosing_invocations() -> Vec<String> {
711    std::env::var(INVOCATIONS)
712        .map(|chain| chain.lines().map(str::to_owned).collect())
713        .unwrap_or_default()
714}
715
716/// The root dotenv files Nx loads into its own process at startup, in the
717/// order they take precedence.
718pub const ROOT_DOTENV_FILES: [&str; 3] = [".local.env", ".env.local", ".env"];
719
720/// A task's environment as Nx builds it: the process environment, without
721/// what the root dotenv files set in it at startup, then the task's dotenv
722/// files, loaded and expanded together, then the variables Nx sets for
723/// every task. A target's `env` applies on top. With a declared
724/// environment, `qk:env`, only what it lets through of the process
725/// environment is kept, and what it sets takes its place.
726fn task_environment(
727    workspace: &Workspace,
728    task: &Task,
729    base_env: &BTreeMap<OsString, OsString>,
730    environment: Option<&mut qk_config::env::Environment>,
731) -> Result<(BTreeMap<OsString, OsString>, BTreeMap<OsString, OsString>)> {
732    let mut env = base_env.clone();
733    let project = &workspace.projects[&task.project];
734    if loads_dotenv(base_env) {
735        // As Nx's unloadDotEnvFiles: a variable whose value is a root file's
736        // goes, so the task's own files decide it. Nx unloads them even when
737        // it did not load them; qk keeps what the shell set then.
738        for file in [".env", ".local.env", ".env.local"] {
739            for (name, value) in dotenv::read_alone(&workspace.root.join(file))? {
740                if env.get(&name) == Some(&value) {
741                    env.remove(&name);
742                }
743            }
744        }
745    }
746    // What the task inherits, before its own dotenv files and settings,
747    // which its configuration declares.
748    if let Some(environment) = environment {
749        let mut provided = BTreeMap::new();
750        let mut keys = Vec::new();
751        for provider in environment.providers() {
752            let (variables, key) = providers::load(provider, base_env, &workspace.root)
753                .with_context(|| format!("cannot build the environment of {}", task.id))?;
754            provided.extend(variables);
755            keys.push(key);
756        }
757        environment.provide(provided, keys);
758        environment
759            .filter(&mut env)
760            .with_context(|| format!("cannot build the environment of {}", task.id))?;
761        if environment.builds_path() {
762            let path = bins::path(
763                environment,
764                base_env.get(&path_key(base_env)),
765                &workspace.root,
766                &project.root,
767            )
768            .with_context(|| format!("cannot build the PATH of {}", task.id))?;
769            env.insert(path_key(&env), path);
770        }
771    }
772    if loads_dotenv(base_env) {
773        let (target, non_atomized) = owner_target(project, &task.target);
774        let files: Vec<PathBuf> = dotenv_files(
775            &project.root,
776            target,
777            task.configuration.as_deref(),
778            non_atomized,
779        )
780        .into_iter()
781        .map(|file| workspace.root.join(file))
782        .collect();
783        dotenv::load(&files, &mut env)?;
784    }
785    let runtime_env = env.clone();
786    // Nx's own variables from an enclosing run, which describe that run.
787    for name in [
788        "NX_SET_CLI",
789        "NX_ORIGINAL_FORCE_COLOR",
790        "NX_TERMINAL_OUTPUT_PATH",
791        "NX_STREAM_OUTPUT",
792        "NX_PREFIX_OUTPUT",
793        // A task of an enclosing run's signal socket and reported address:
794        // a task here neither signals for that task nor reads its address.
795        ready_signal::SOCKET_ENV,
796        ready_signal::TASK_ENV,
797        ready_signal::URL_ENV,
798    ] {
799        env.remove(std::ffi::OsStr::new(name));
800    }
801    let mut set = |name: &str, value: OsString| {
802        env.insert(name.into(), value);
803    };
804    set("NX_WORKSPACE_ROOT", workspace.root.clone().into_os_string());
805    set("NX_TASK_TARGET_PROJECT", task.project.clone().into());
806    set("NX_TASK_TARGET_TARGET", task.target.clone().into());
807    set("LERNA_PACKAGE_NAME", task.project.clone().into());
808    set("NX_TUI", "false".into());
809    // The qk and Nx processes this task runs inside, outermost first.
810    let ancestors = match base_env.get(std::ffi::OsStr::new("NX_INVOCATION_ANCESTOR_PIDS")) {
811        Some(inherited) if !inherited.is_empty() => {
812            format!("{},{}", inherited.to_string_lossy(), std::process::id())
813        }
814        _ => std::process::id().to_string(),
815    };
816    set("NX_INVOCATION_ANCESTOR_PIDS", ancestors.into());
817    // The tasks this one runs inside, outermost first, so a run it starts can
818    // tell when it would run one of them again.
819    let mut chain = base_env
820        .get(std::ffi::OsStr::new(INVOCATIONS))
821        .map(|chain| chain.to_string_lossy().into_owned())
822        .unwrap_or_default();
823    if !chain.is_empty() {
824        chain.push('\n');
825    }
826    chain.push_str(&invocation(task));
827    set(INVOCATIONS, chain.into());
828    if task.target == "test" {
829        set("NX_TERMINAL_CAPTURE_STDERR", "true".into());
830    }
831    match &task.configuration {
832        Some(configuration) => set("NX_TASK_TARGET_CONFIGURATION", configuration.clone().into()),
833        None => {
834            env.remove(std::ffi::OsStr::new("NX_TASK_TARGET_CONFIGURATION"));
835        }
836    }
837    Ok((env, runtime_env))
838}
839
840/// Whether dotenv files load: as in Nx, `NX_LOAD_DOT_ENV_FILES=false` turns
841/// them all off.
842fn loads_dotenv(env: &BTreeMap<OsString, OsString>) -> bool {
843    env.get(std::ffi::OsStr::new("NX_LOAD_DOT_ENV_FILES"))
844        .is_none_or(|value| value != "false")
845}
846
847/// The process environment with the root dotenv files loaded into it, as
848/// Nx loads them into its own process at startup, unless
849/// `NX_LOAD_DOT_ENV_FILES=false`. Variables already set win.
850pub fn environment(workspace: &Path) -> Result<BTreeMap<OsString, OsString>> {
851    let mut values: BTreeMap<OsString, OsString> = std::env::vars_os().collect();
852    if loads_dotenv(&values) {
853        let files: Vec<PathBuf> = ROOT_DOTENV_FILES
854            .iter()
855            .map(|file| workspace.join(file))
856            .collect();
857        dotenv::load(&files, &mut values)?;
858    }
859    Ok(values)
860}
861
862pub fn prepare(
863    workspace: &Workspace,
864    task: &Task,
865    base_env: &BTreeMap<OsString, OsString>,
866) -> Result<PreparedTask> {
867    let definition = &task.definition;
868    let executor = definition
869        .executor
870        .as_deref()
871        .context("target has no executor or command")?;
872    let project = &workspace.projects[&task.project];
873    let run_commands = executor == "nx:run-commands";
874    if !matches!(executor, "nx:run-commands" | "nx:run-script" | "nx:noop") {
875        bail!("unsupported executor {executor:?}");
876    }
877    // As in Nx, run-script reads only `script`, and noop nothing.
878    let options = if run_commands {
879        run_commands_overrides(&definition.options, &task.args, project)?
880    } else {
881        definition.options.clone()
882    };
883    let options = &options;
884    let empty = Map::new();
885    let mut interpolation = if run_commands {
886        interpolate::Interpolation::new(&task.args, options)?
887    } else {
888        interpolate::Interpolation::new(&[], &empty)?
889    };
890    let mut environment = qk_config::env::Environment::of(&workspace.config, definition)
891        .with_context(|| format!("invalid environment for {}", task.id))?;
892    let (mut env, mut runtime_env) =
893        task_environment(workspace, task, base_env, environment.as_mut())?;
894    // As in Nx 24, `envFile` sets what the task's environment, dotenv files
895    // included, leaves unset or empty, beneath `env`; and the task's
896    // environment says whether it loads.
897    if let Some(file) = options.get("envFile").filter(|_| run_commands) {
898        let file = file.as_str().context("envFile must be a string")?;
899        let path = workspace.root.join(file);
900        if loads_dotenv(&runtime_env) {
901            if !path.is_file() {
902                bail!("envFile {file} does not exist");
903            }
904            dotenv::load(std::slice::from_ref(&path), &mut runtime_env)?;
905            dotenv::load(std::slice::from_ref(&path), &mut env)?;
906        }
907    }
908    if let Some(values) = options.get("env").filter(|_| run_commands) {
909        for (name, value) in values.as_object().context("env must be an object")? {
910            if name.is_empty() || name.contains(['=', '\0']) {
911                bail!("invalid environment variable name");
912            }
913            let value: OsString = value
914                .as_str()
915                .context("environment values must be strings")?
916                .into();
917            let name = environment_key(&env, name.into());
918            runtime_env.insert(name.clone(), value.clone());
919            env.insert(name, value);
920        }
921    }
922    interpolation.set_env(&env);
923    let mut execution = BTreeMap::new();
924    execution.insert(
925        "FORCE_COLOR".into(),
926        env.get(std::ffi::OsStr::new("FORCE_COLOR"))
927            .cloned()
928            .unwrap_or_else(|| "true".into()),
929    );
930    if run_commands && boolean(options.get("color"), false, "color")? {
931        execution.insert("FORCE_COLOR".into(), "true".into());
932    }
933    // run-commands runs where `cwd` says, the workspace root by default, and
934    // run-script in the project root.
935    let cwd = match options.get("cwd").filter(|_| run_commands) {
936        Some(cwd) => cwd.as_str().context("cwd must be a string")?,
937        None if run_commands => ".",
938        None => &project.root,
939    };
940    let cwd = workspace.root.join(cwd);
941    let mut resolved = PathBuf::new();
942    for part in cwd.components() {
943        match part {
944            std::path::Component::ParentDir => {
945                resolved.pop();
946            }
947            std::path::Component::CurDir => {}
948            _ => resolved.push(part.as_os_str()),
949        }
950    }
951    // Expose local binaries even for plain run-commands targets.
952    let mut paths = Vec::new();
953    for directory in resolved.ancestors() {
954        paths.push(directory.join("node_modules/.bin"));
955    }
956    let node_path = NodePath::new(&workspace.root, base_env, paths.len());
957    let path_key = path_key(&env);
958    if let Some(path) = env.get(&path_key) {
959        paths.extend(std::env::split_paths(path));
960    }
961    let path = std::env::join_paths(paths).context("cannot construct task PATH")?;
962    env.insert(path_key.clone(), path.clone());
963    runtime_env.insert(path_key, path);
964    let ready_when: Vec<String> = match options.get("readyWhen").filter(|_| run_commands) {
965        None | Some(Value::Null) => Vec::new(),
966        Some(Value::String(text)) => vec![text.clone()],
967        Some(value) => serde_json::from_value(value.clone())
968            .context("readyWhen must be a string or an array of strings")?,
969    };
970    // As in Nx, a single `command` runs in parallel exactly when it has
971    // `readyWhen`, whatever `parallel` says.
972    let parallel = if run_commands && options.get("command").is_some_and(merge_truthy) {
973        !ready_when.is_empty()
974    } else if run_commands {
975        boolean(options.get("parallel"), true, "parallel")?
976    } else {
977        true
978    };
979    let (mut pseudo_terminal, tty) = if run_commands {
980        (
981            boolean(options.get("usePty"), true, "usePty")?
982                && base_env
983                    .get(std::ffi::OsStr::new("NX_NATIVE_COMMAND_RUNNER"))
984                    .is_none_or(|value| value != "false"),
985            boolean(options.get("tty"), true, "tty")?,
986        )
987    } else {
988        (true, true)
989    };
990    let mut decorations = Vec::new();
991    if !ready_when.is_empty() && !parallel {
992        bail!("readyWhen can only be used when parallel is true");
993    }
994    let mut commands = Vec::new();
995    match executor {
996        "nx:noop" => {}
997        "nx:run-script" => {
998            let script = options
999                .get("script")
1000                .and_then(Value::as_str)
1001                .context("run-script requires options.script")?;
1002            let package = workspace
1003                .packages
1004                .get(&task.project)
1005                .context("run-script requires a package.json")?;
1006            if !package.scripts.contains_key(script) {
1007                bail!("package.json has no script {script:?}");
1008            }
1009            let manager = package_manager(workspace)?;
1010            // Nx joins the arguments as they are; qk quotes each, so that
1011            // one with a space or a shell character stays one argument.
1012            let args = interpolate::quoted_arguments(&task.args)?;
1013            let script = interpolate::quote(script)?;
1014            // Nx's `run` for each: npm and bun need `--` before arguments,
1015            // and yarn runs a script by name.
1016            let mut command = match manager {
1017                "yarn" => format!("yarn {script}"),
1018                manager => format!("{manager} run {script}"),
1019            };
1020            if !args.is_empty() {
1021                if matches!(manager, "npm" | "bun") {
1022                    command.push_str(" --");
1023                }
1024                command.push(' ');
1025                command.push_str(&args);
1026            }
1027            commands.push(command);
1028        }
1029        "nx:run-commands" => {
1030            let forward = boolean(options.get("forwardAllArgs"), true, "forwardAllArgs")?;
1031            // As in Nx, `command` wins over `commands`.
1032            if let Some(command) = options
1033                .get("command")
1034                .filter(|command| merge_truthy(command))
1035            {
1036                // Nx also takes an array, joined with spaces.
1037                let command = match command {
1038                    Value::String(command) => command.clone(),
1039                    Value::Array(words) => words
1040                        .iter()
1041                        .map(|word| word.as_str().context("command words must be strings"))
1042                        .collect::<Result<Vec<_>>>()?
1043                        .join(" "),
1044                    _ => bail!("command must be a string or an array of strings"),
1045                };
1046                commands.push(interpolation.command(&command, forward)?);
1047            } else if let Some(values) = options.get("commands") {
1048                for value in values.as_array().context("commands must be an array")? {
1049                    let (command, forward) = if let Some(command) = value.as_str() {
1050                        (command, forward)
1051                    } else {
1052                        let value = value
1053                            .as_object()
1054                            .context("commands entries must be strings or objects")?;
1055                        for key in value.keys() {
1056                            if !matches!(
1057                                key.as_str(),
1058                                "command"
1059                                    | "forwardAllArgs"
1060                                    | "prefix"
1061                                    | "prefixColor"
1062                                    | "color"
1063                                    | "bgColor"
1064                                    | "description"
1065                            ) {
1066                                bail!("unsupported command entry field {key:?}");
1067                            }
1068                        }
1069                        let field =
1070                            |name: &str, background: Option<bool>| -> Result<Option<String>> {
1071                                let Some(value) = value.get(name) else {
1072                                    return Ok(None);
1073                                };
1074                                let text = value.as_str().with_context(|| {
1075                                    format!("command entry {name} must be a string")
1076                                })?;
1077                                if let Some(background) = background
1078                                    && !Decoration::valid(text, background)
1079                                {
1080                                    bail!("command entry {name} {text:?} is not a colour");
1081                                }
1082                                Ok(Some(text.to_owned()))
1083                            };
1084                        let decoration = Decoration {
1085                            prefix: field("prefix", None)?,
1086                            prefix_color: field("prefixColor", Some(false))?,
1087                            color: field("color", Some(false))?,
1088                            bg_color: field("bgColor", Some(true))?,
1089                        };
1090                        if !decoration.is_empty() && !parallel {
1091                            bail!(
1092                                "prefix, prefixColor, color and bgColor can only be set when parallel is true"
1093                            );
1094                        }
1095                        decorations.resize(commands.len(), Decoration::default());
1096                        decorations.push(decoration);
1097                        (
1098                            value
1099                                .get("command")
1100                                .and_then(Value::as_str)
1101                                .context("command entry requires a command string")?,
1102                            boolean(value.get("forwardAllArgs"), forward, "forwardAllArgs")?,
1103                        )
1104                    };
1105                    commands.push(interpolation.command(command, forward)?);
1106                }
1107            } else {
1108                bail!("run-commands requires command or commands");
1109            }
1110            // An empty `commands` succeeds without running anything, as in Nx.
1111            if commands.iter().any(|command| command.trim().is_empty()) {
1112                bail!("run-commands requires nonempty commands");
1113            }
1114        }
1115        _ => bail!("unsupported executor {executor:?}"),
1116    }
1117    // As in Nx, commands run side by side share qk's output through pipes.
1118    pseudo_terminal &= commands.len() == 1 || !parallel;
1119    let ready_inputs = ready_inputs(workspace, task, options)?.map(std::sync::Arc::new);
1120    let mut prepared = PreparedTask {
1121        id: task.id.clone(),
1122        commands,
1123        parallel,
1124        cwd,
1125        env,
1126        runtime_env,
1127        environment,
1128        node_path: Some(node_path),
1129        execution,
1130        ready_when,
1131        ready: Default::default(),
1132        ready_check: None,
1133        ready_inputs,
1134        verdict: Default::default(),
1135        processes: Default::default(),
1136        decorations,
1137        interactive: false,
1138        pseudo_terminal,
1139        tty,
1140        sandbox: None,
1141        recording: None,
1142        recording_keys: false,
1143        scratch: Vec::new(),
1144        display: Display::default(),
1145    };
1146    prepared.ready_check = prepared.resolve_ready_check(None);
1147    Ok(prepared)
1148}
1149
1150/// A continuous target's `readyWhen` and what its placeholders read but the
1151/// environment: the options the task runs with, its arguments applied.
1152#[derive(Debug)]
1153pub struct ReadyInputs {
1154    config: Value,
1155    workspace_root: String,
1156    project_root: String,
1157    project_name: String,
1158    options: Map<String, Value>,
1159    /// `options` as a command check reads them, in `NX_TASK_OPTIONS`.
1160    options_json: String,
1161}
1162
1163/// A continuous target's `readyWhen`, as Nx 24 reads it. `null` turns off one
1164/// a target default sets. A target that is not continuous has none.
1165fn ready_inputs(
1166    workspace: &Workspace,
1167    task: &Task,
1168    options: &Map<String, Value>,
1169) -> Result<Option<ReadyInputs>> {
1170    let Some(ready_when) = task
1171        .definition
1172        .extra
1173        .get("readyWhen")
1174        .filter(|value| !value.is_null() && task.definition.continuous == Some(true))
1175    else {
1176        return Ok(None);
1177    };
1178    let mut options = options.clone();
1179    let project = &workspace.projects[&task.project];
1180    for (key, value) in interpolate::yargs(&task.args, false, false) {
1181        if key == "_" {
1182            continue;
1183        }
1184        // As in Nx, an argument's tokens resolve for the task's project.
1185        let value = match value {
1186            Value::String(text) => Value::String(qk_config::interpolate_override(&text, project)?),
1187            value => value,
1188        };
1189        if key.contains('-') {
1190            options.insert(interpolate::camel_case(&key), value.clone());
1191        }
1192        options.insert(key, value);
1193    }
1194    Ok(Some(ReadyInputs {
1195        config: ready_when.clone(),
1196        workspace_root: workspace
1197            .root
1198            .to_str()
1199            .context("workspace path must be UTF-8")?
1200            .to_owned(),
1201        project_root: project.root.clone(),
1202        project_name: task.project.clone(),
1203        options_json: serde_json::to_string(&options)?,
1204        options,
1205    }))
1206}
1207
1208impl PreparedTask {
1209    /// The task's own `readyWhen`, resolved against its options and the
1210    /// environment it runs with, with `url` as `NX_READY_URL`. As in Nx
1211    /// 24.0.1, a task waiting for a server that reports its address resolves
1212    /// it once that server is ready, so its placeholders and a command check
1213    /// read the address.
1214    pub fn resolve_ready_check(
1215        &self,
1216        url: Option<&str>,
1217    ) -> Option<std::result::Result<readiness::ReadyCheck, String>> {
1218        let inputs = self.ready_inputs.as_deref()?;
1219        let mut full_env = self.env.clone();
1220        if let Some(url) = url {
1221            full_env.insert(ready_signal::URL_ENV.into(), url.into());
1222        }
1223        let env: BTreeMap<String, String> = full_env
1224            .iter()
1225            .filter_map(|(name, value)| {
1226                Some((name.to_str()?.to_owned(), value.to_str()?.to_owned()))
1227            })
1228            .collect();
1229        let scope = readiness::Scope {
1230            workspace_root: &inputs.workspace_root,
1231            project_root: &inputs.project_root,
1232            project_name: &inputs.project_name,
1233            options: &inputs.options,
1234            env: &env,
1235        };
1236        let mut check = readiness::resolve(&self.id, &inputs.config, &scope);
1237        // As in Nx 24, a command check runs as the task's commands do, and
1238        // reads the options they run with.
1239        if let Ok(Some(readiness::ReadyCheck {
1240            probe: readiness::Probe::Command { env: probe_env, .. },
1241            ..
1242        })) = &mut check
1243        {
1244            *probe_env = full_env;
1245            probe_env.insert("NX_TASK_OPTIONS".into(), inputs.options_json.clone().into());
1246        }
1247        check.map_err(|error| format!("{error:#}")).transpose()
1248    }
1249}
1250
1251/// The run-commands options Nx reads, which a task's arguments may set too.
1252const OPTION_FLAGS: &[&str] = &[
1253    "command",
1254    "commands",
1255    "color",
1256    "parallel",
1257    "readyWhen",
1258    "cwd",
1259    "args",
1260    "envFile",
1261    "__unparsed__",
1262    "env",
1263    "usePty",
1264    "streamOutput",
1265    "verbose",
1266    "forwardAllArgs",
1267    "tty",
1268];
1269
1270/// The target's options with the task's arguments that name a run-commands
1271/// option applied. As in Nx, `--cwd=dir`, `--no-parallel` or
1272/// `--env.NAME=value` set the option rather than reaching the command.
1273fn run_commands_overrides(
1274    options: &serde_json::Map<String, Value>,
1275    args: &[String],
1276    project: &qk_config::Project,
1277) -> Result<serde_json::Map<String, Value>> {
1278    const BOOLEAN: &[&str] = &[
1279        "color",
1280        "parallel",
1281        "usePty",
1282        "streamOutput",
1283        "verbose",
1284        "forwardAllArgs",
1285        "tty",
1286    ];
1287    let mut options = options.clone();
1288    let mut ready = Vec::new();
1289    let mut args = args.iter().peekable();
1290    while let Some(arg) = args.next() {
1291        let Some(flag) = arg.strip_prefix("--") else {
1292            continue;
1293        };
1294        let (key, value) = match flag.split_once('=') {
1295            Some((key, value)) => (key, Some(value.to_owned())),
1296            None => (flag, None),
1297        };
1298        let (key, negated) = match key.strip_prefix("no-") {
1299            Some(key) if OPTION_FLAGS.contains(&key) => (key, true),
1300            _ => (key, false),
1301        };
1302        let (base, field) = match key.split_once('.') {
1303            Some((base, field)) => (base, Some(field)),
1304            None => (key, None),
1305        };
1306        if !OPTION_FLAGS.contains(&base) {
1307            continue;
1308        }
1309        let boolean = BOOLEAN.contains(&base);
1310        let value = match value {
1311            Some(value) => value,
1312            None if negated => "false".into(),
1313            None if !boolean && args.peek().is_some_and(|next| !next.starts_with('-')) => {
1314                args.next().unwrap().clone()
1315            }
1316            None => "true".into(),
1317        };
1318        let value = if boolean {
1319            Value::Bool(value != "false")
1320        } else {
1321            // As in Nx, an argument's tokens resolve for the task's project.
1322            Value::String(qk_config::interpolate_override(&value, project)?)
1323        };
1324        match (base, field) {
1325            ("env", Some(name)) => {
1326                let env = options
1327                    .entry("env")
1328                    .or_insert_with(|| Value::Object(Default::default()));
1329                if let Value::Object(env) = env {
1330                    env.insert(name.to_owned(), value);
1331                }
1332            }
1333            ("readyWhen", None) => ready.push(value),
1334            // Nx sets these itself, and a list cannot come from a flag.
1335            ("__unparsed__" | "commands", _) | (_, Some(_)) => {}
1336            (base, None) => {
1337                options.insert(base.to_owned(), value);
1338            }
1339        }
1340    }
1341    if !ready.is_empty() {
1342        let ready = if ready.len() == 1 {
1343            ready.pop().unwrap()
1344        } else {
1345            Value::Array(ready)
1346        };
1347        options.insert("readyWhen".into(), ready);
1348    }
1349    Ok(options)
1350}
1351
1352/// JavaScript truthiness, which decides what Nx reads as set.
1353fn merge_truthy(value: &Value) -> bool {
1354    match value {
1355        Value::Null => false,
1356        Value::Bool(flag) => *flag,
1357        Value::Number(number) => number.as_f64().is_some_and(|number| number != 0.0),
1358        Value::String(text) => !text.is_empty(),
1359        Value::Array(_) | Value::Object(_) => true,
1360    }
1361}
1362
1363fn boolean(value: Option<&Value>, default: bool, name: &str) -> Result<bool> {
1364    value
1365        .map(|value| {
1366            value
1367                .as_bool()
1368                .with_context(|| format!("{name} must be boolean"))
1369        })
1370        .transpose()
1371        .map(|value| value.unwrap_or(default))
1372}
1373
1374fn package_manager(workspace: &Workspace) -> Result<&'static str> {
1375    workspace.package_manager_name()
1376}
1377
1378#[cfg(all(test, windows))]
1379mod dotenv_tests {
1380    #[test]
1381    fn colon_targets_keep_configuration_and_generic_dotenv_files() {
1382        let files = super::dotenv_files("app", "test:unit:watch", Some("ci"), None);
1383        assert!(files.iter().all(|file| !file.contains(':')));
1384        assert!(files.contains(&"app/.env.ci".to_owned()));
1385        assert!(files.contains(&"app/.env".to_owned()));
1386        assert!(files.contains(&".env.ci".to_owned()));
1387        assert!(files.contains(&".env".to_owned()));
1388    }
1389}