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    // A target-level `env` is a qk extension, for every executor.
909    let option_env = options.get("env").filter(|_| run_commands);
910    for values in [definition.extra.get("env"), option_env]
911        .into_iter()
912        .flatten()
913    {
914        for (name, value) in values.as_object().context("env must be an object")? {
915            if name.is_empty() || name.contains(['=', '\0']) {
916                bail!("invalid environment variable name");
917            }
918            let value: OsString = value
919                .as_str()
920                .context("environment values must be strings")?
921                .into();
922            let name = environment_key(&env, name.into());
923            runtime_env.insert(name.clone(), value.clone());
924            env.insert(name, value);
925        }
926    }
927    interpolation.set_env(&env);
928    let mut execution = BTreeMap::new();
929    execution.insert(
930        "FORCE_COLOR".into(),
931        env.get(std::ffi::OsStr::new("FORCE_COLOR"))
932            .cloned()
933            .unwrap_or_else(|| "true".into()),
934    );
935    if run_commands && boolean(options.get("color"), false, "color")? {
936        execution.insert("FORCE_COLOR".into(), "true".into());
937    }
938    // run-commands runs where `cwd` says, the workspace root by default, and
939    // run-script in the project root.
940    let cwd = match options.get("cwd").filter(|_| run_commands) {
941        Some(cwd) => cwd.as_str().context("cwd must be a string")?,
942        None if run_commands => ".",
943        None => &project.root,
944    };
945    let cwd = workspace.root.join(cwd);
946    let mut resolved = PathBuf::new();
947    for part in cwd.components() {
948        match part {
949            std::path::Component::ParentDir => {
950                resolved.pop();
951            }
952            std::path::Component::CurDir => {}
953            _ => resolved.push(part.as_os_str()),
954        }
955    }
956    // Expose local binaries even for plain run-commands targets.
957    let mut paths = Vec::new();
958    for directory in resolved.ancestors() {
959        paths.push(directory.join("node_modules/.bin"));
960    }
961    let node_path = NodePath::new(&workspace.root, base_env, paths.len());
962    let path_key = path_key(&env);
963    if let Some(path) = env.get(&path_key) {
964        paths.extend(std::env::split_paths(path));
965    }
966    let path = std::env::join_paths(paths).context("cannot construct task PATH")?;
967    env.insert(path_key.clone(), path.clone());
968    runtime_env.insert(path_key, path);
969    let ready_when: Vec<String> = match options.get("readyWhen").filter(|_| run_commands) {
970        None | Some(Value::Null) => Vec::new(),
971        Some(Value::String(text)) => vec![text.clone()],
972        Some(value) => serde_json::from_value(value.clone())
973            .context("readyWhen must be a string or an array of strings")?,
974    };
975    // As in Nx, a single `command` runs in parallel exactly when it has
976    // `readyWhen`, whatever `parallel` says.
977    let parallel = if run_commands && options.get("command").is_some_and(merge_truthy) {
978        !ready_when.is_empty()
979    } else if run_commands {
980        boolean(options.get("parallel"), true, "parallel")?
981    } else {
982        true
983    };
984    let (mut pseudo_terminal, tty) = if run_commands {
985        (
986            boolean(options.get("usePty"), true, "usePty")?
987                && base_env
988                    .get(std::ffi::OsStr::new("NX_NATIVE_COMMAND_RUNNER"))
989                    .is_none_or(|value| value != "false"),
990            boolean(options.get("tty"), true, "tty")?,
991        )
992    } else {
993        (true, true)
994    };
995    let mut decorations = Vec::new();
996    if !ready_when.is_empty() && !parallel {
997        bail!("readyWhen can only be used when parallel is true");
998    }
999    let mut commands = Vec::new();
1000    match executor {
1001        "nx:noop" => {}
1002        "nx:run-script" => {
1003            let script = options
1004                .get("script")
1005                .and_then(Value::as_str)
1006                .context("run-script requires options.script")?;
1007            let package = workspace
1008                .packages
1009                .get(&task.project)
1010                .context("run-script requires a package.json")?;
1011            if !package.scripts.contains_key(script) {
1012                bail!("package.json has no script {script:?}");
1013            }
1014            let manager = package_manager(workspace)?;
1015            // Nx joins the arguments as they are; qk quotes each, so that
1016            // one with a space or a shell character stays one argument.
1017            let args = interpolate::quoted_arguments(&task.args)?;
1018            let script = interpolate::quote(script)?;
1019            // Nx's `run` for each: npm and bun need `--` before arguments,
1020            // and yarn runs a script by name.
1021            let mut command = match manager {
1022                "yarn" => format!("yarn {script}"),
1023                manager => format!("{manager} run {script}"),
1024            };
1025            if !args.is_empty() {
1026                if matches!(manager, "npm" | "bun") {
1027                    command.push_str(" --");
1028                }
1029                command.push(' ');
1030                command.push_str(&args);
1031            }
1032            commands.push(command);
1033        }
1034        "nx:run-commands" => {
1035            let forward = boolean(options.get("forwardAllArgs"), true, "forwardAllArgs")?;
1036            // As in Nx, `command` wins over `commands`.
1037            if let Some(command) = options
1038                .get("command")
1039                .filter(|command| merge_truthy(command))
1040            {
1041                // Nx also takes an array, joined with spaces.
1042                let command = match command {
1043                    Value::String(command) => command.clone(),
1044                    Value::Array(words) => words
1045                        .iter()
1046                        .map(|word| word.as_str().context("command words must be strings"))
1047                        .collect::<Result<Vec<_>>>()?
1048                        .join(" "),
1049                    _ => bail!("command must be a string or an array of strings"),
1050                };
1051                commands.push(interpolation.command(&command, forward)?);
1052            } else if let Some(values) = options.get("commands") {
1053                for value in values.as_array().context("commands must be an array")? {
1054                    let (command, forward) = if let Some(command) = value.as_str() {
1055                        (command, forward)
1056                    } else {
1057                        let value = value
1058                            .as_object()
1059                            .context("commands entries must be strings or objects")?;
1060                        for key in value.keys() {
1061                            if !matches!(
1062                                key.as_str(),
1063                                "command"
1064                                    | "forwardAllArgs"
1065                                    | "prefix"
1066                                    | "prefixColor"
1067                                    | "color"
1068                                    | "bgColor"
1069                                    | "description"
1070                            ) {
1071                                bail!("unsupported command entry field {key:?}");
1072                            }
1073                        }
1074                        let field =
1075                            |name: &str, background: Option<bool>| -> Result<Option<String>> {
1076                                let Some(value) = value.get(name) else {
1077                                    return Ok(None);
1078                                };
1079                                let text = value.as_str().with_context(|| {
1080                                    format!("command entry {name} must be a string")
1081                                })?;
1082                                if let Some(background) = background
1083                                    && !Decoration::valid(text, background)
1084                                {
1085                                    bail!("command entry {name} {text:?} is not a colour");
1086                                }
1087                                Ok(Some(text.to_owned()))
1088                            };
1089                        let decoration = Decoration {
1090                            prefix: field("prefix", None)?,
1091                            prefix_color: field("prefixColor", Some(false))?,
1092                            color: field("color", Some(false))?,
1093                            bg_color: field("bgColor", Some(true))?,
1094                        };
1095                        if !decoration.is_empty() && !parallel {
1096                            bail!(
1097                                "prefix, prefixColor, color and bgColor can only be set when parallel is true"
1098                            );
1099                        }
1100                        decorations.resize(commands.len(), Decoration::default());
1101                        decorations.push(decoration);
1102                        (
1103                            value
1104                                .get("command")
1105                                .and_then(Value::as_str)
1106                                .context("command entry requires a command string")?,
1107                            boolean(value.get("forwardAllArgs"), forward, "forwardAllArgs")?,
1108                        )
1109                    };
1110                    commands.push(interpolation.command(command, forward)?);
1111                }
1112            } else {
1113                bail!("run-commands requires command or commands");
1114            }
1115            // An empty `commands` succeeds without running anything, as in Nx.
1116            if commands.iter().any(|command| command.trim().is_empty()) {
1117                bail!("run-commands requires nonempty commands");
1118            }
1119        }
1120        _ => bail!("unsupported executor {executor:?}"),
1121    }
1122    // As in Nx, commands run side by side share qk's output through pipes.
1123    pseudo_terminal &= commands.len() == 1 || !parallel;
1124    let ready_inputs = ready_inputs(workspace, task, options)?.map(std::sync::Arc::new);
1125    let mut prepared = PreparedTask {
1126        id: task.id.clone(),
1127        commands,
1128        parallel,
1129        cwd,
1130        env,
1131        runtime_env,
1132        environment,
1133        node_path: Some(node_path),
1134        execution,
1135        ready_when,
1136        ready: Default::default(),
1137        ready_check: None,
1138        ready_inputs,
1139        verdict: Default::default(),
1140        processes: Default::default(),
1141        decorations,
1142        interactive: false,
1143        pseudo_terminal,
1144        tty,
1145        sandbox: None,
1146        recording: None,
1147        recording_keys: false,
1148        scratch: Vec::new(),
1149        display: Display::default(),
1150    };
1151    prepared.ready_check = prepared.resolve_ready_check(None);
1152    Ok(prepared)
1153}
1154
1155/// A continuous target's `readyWhen` and what its placeholders read but the
1156/// environment: the options the task runs with, its arguments applied.
1157#[derive(Debug)]
1158pub struct ReadyInputs {
1159    config: Value,
1160    workspace_root: String,
1161    project_root: String,
1162    project_name: String,
1163    options: Map<String, Value>,
1164    /// `options` as a command check reads them, in `NX_TASK_OPTIONS`.
1165    options_json: String,
1166}
1167
1168/// A continuous target's `readyWhen`, as Nx 24 reads it. `null` turns off one
1169/// a target default sets. A target that is not continuous has none.
1170fn ready_inputs(
1171    workspace: &Workspace,
1172    task: &Task,
1173    options: &Map<String, Value>,
1174) -> Result<Option<ReadyInputs>> {
1175    let Some(ready_when) = task
1176        .definition
1177        .extra
1178        .get("readyWhen")
1179        .filter(|value| !value.is_null() && task.definition.continuous == Some(true))
1180    else {
1181        return Ok(None);
1182    };
1183    let mut options = options.clone();
1184    let project = &workspace.projects[&task.project];
1185    for (key, value) in interpolate::yargs(&task.args, false, false) {
1186        if key == "_" {
1187            continue;
1188        }
1189        // As in Nx, an argument's tokens resolve for the task's project.
1190        let value = match value {
1191            Value::String(text) => Value::String(qk_config::interpolate_override(&text, project)?),
1192            value => value,
1193        };
1194        if key.contains('-') {
1195            options.insert(interpolate::camel_case(&key), value.clone());
1196        }
1197        options.insert(key, value);
1198    }
1199    Ok(Some(ReadyInputs {
1200        config: ready_when.clone(),
1201        workspace_root: workspace
1202            .root
1203            .to_str()
1204            .context("workspace path must be UTF-8")?
1205            .to_owned(),
1206        project_root: project.root.clone(),
1207        project_name: task.project.clone(),
1208        options_json: serde_json::to_string(&options)?,
1209        options,
1210    }))
1211}
1212
1213impl PreparedTask {
1214    /// The task's own `readyWhen`, resolved against its options and the
1215    /// environment it runs with, with `url` as `NX_READY_URL`. As in Nx
1216    /// 24.0.1, a task waiting for a server that reports its address resolves
1217    /// it once that server is ready, so its placeholders and a command check
1218    /// read the address.
1219    pub fn resolve_ready_check(
1220        &self,
1221        url: Option<&str>,
1222    ) -> Option<std::result::Result<readiness::ReadyCheck, String>> {
1223        let inputs = self.ready_inputs.as_deref()?;
1224        let mut full_env = self.env.clone();
1225        if let Some(url) = url {
1226            full_env.insert(ready_signal::URL_ENV.into(), url.into());
1227        }
1228        let env: BTreeMap<String, String> = full_env
1229            .iter()
1230            .filter_map(|(name, value)| {
1231                Some((name.to_str()?.to_owned(), value.to_str()?.to_owned()))
1232            })
1233            .collect();
1234        let scope = readiness::Scope {
1235            workspace_root: &inputs.workspace_root,
1236            project_root: &inputs.project_root,
1237            project_name: &inputs.project_name,
1238            options: &inputs.options,
1239            env: &env,
1240        };
1241        let mut check = readiness::resolve(&self.id, &inputs.config, &scope);
1242        // As in Nx 24, a command check runs as the task's commands do, and
1243        // reads the options they run with.
1244        if let Ok(Some(readiness::ReadyCheck {
1245            probe: readiness::Probe::Command { env: probe_env, .. },
1246            ..
1247        })) = &mut check
1248        {
1249            *probe_env = full_env;
1250            probe_env.insert("NX_TASK_OPTIONS".into(), inputs.options_json.clone().into());
1251        }
1252        check.map_err(|error| format!("{error:#}")).transpose()
1253    }
1254}
1255
1256/// The run-commands options Nx reads, which a task's arguments may set too.
1257const OPTION_FLAGS: &[&str] = &[
1258    "command",
1259    "commands",
1260    "color",
1261    "parallel",
1262    "readyWhen",
1263    "cwd",
1264    "args",
1265    "envFile",
1266    "__unparsed__",
1267    "env",
1268    "usePty",
1269    "streamOutput",
1270    "verbose",
1271    "forwardAllArgs",
1272    "tty",
1273];
1274
1275/// The target's options with the task's arguments that name a run-commands
1276/// option applied. As in Nx, `--cwd=dir`, `--no-parallel` or
1277/// `--env.NAME=value` set the option rather than reaching the command.
1278fn run_commands_overrides(
1279    options: &serde_json::Map<String, Value>,
1280    args: &[String],
1281    project: &qk_config::Project,
1282) -> Result<serde_json::Map<String, Value>> {
1283    const BOOLEAN: &[&str] = &[
1284        "color",
1285        "parallel",
1286        "usePty",
1287        "streamOutput",
1288        "verbose",
1289        "forwardAllArgs",
1290        "tty",
1291    ];
1292    let mut options = options.clone();
1293    let mut ready = Vec::new();
1294    let mut args = args.iter().peekable();
1295    while let Some(arg) = args.next() {
1296        let Some(flag) = arg.strip_prefix("--") else {
1297            continue;
1298        };
1299        let (key, value) = match flag.split_once('=') {
1300            Some((key, value)) => (key, Some(value.to_owned())),
1301            None => (flag, None),
1302        };
1303        let (key, negated) = match key.strip_prefix("no-") {
1304            Some(key) if OPTION_FLAGS.contains(&key) => (key, true),
1305            _ => (key, false),
1306        };
1307        let (base, field) = match key.split_once('.') {
1308            Some((base, field)) => (base, Some(field)),
1309            None => (key, None),
1310        };
1311        if !OPTION_FLAGS.contains(&base) {
1312            continue;
1313        }
1314        let boolean = BOOLEAN.contains(&base);
1315        let value = match value {
1316            Some(value) => value,
1317            None if negated => "false".into(),
1318            None if !boolean && args.peek().is_some_and(|next| !next.starts_with('-')) => {
1319                args.next().unwrap().clone()
1320            }
1321            None => "true".into(),
1322        };
1323        let value = if boolean {
1324            Value::Bool(value != "false")
1325        } else {
1326            // As in Nx, an argument's tokens resolve for the task's project.
1327            Value::String(qk_config::interpolate_override(&value, project)?)
1328        };
1329        match (base, field) {
1330            ("env", Some(name)) => {
1331                let env = options
1332                    .entry("env")
1333                    .or_insert_with(|| Value::Object(Default::default()));
1334                if let Value::Object(env) = env {
1335                    env.insert(name.to_owned(), value);
1336                }
1337            }
1338            ("readyWhen", None) => ready.push(value),
1339            // Nx sets these itself, and a list cannot come from a flag.
1340            ("__unparsed__" | "commands", _) | (_, Some(_)) => {}
1341            (base, None) => {
1342                options.insert(base.to_owned(), value);
1343            }
1344        }
1345    }
1346    if !ready.is_empty() {
1347        let ready = if ready.len() == 1 {
1348            ready.pop().unwrap()
1349        } else {
1350            Value::Array(ready)
1351        };
1352        options.insert("readyWhen".into(), ready);
1353    }
1354    Ok(options)
1355}
1356
1357/// JavaScript truthiness, which decides what Nx reads as set.
1358fn merge_truthy(value: &Value) -> bool {
1359    match value {
1360        Value::Null => false,
1361        Value::Bool(flag) => *flag,
1362        Value::Number(number) => number.as_f64().is_some_and(|number| number != 0.0),
1363        Value::String(text) => !text.is_empty(),
1364        Value::Array(_) | Value::Object(_) => true,
1365    }
1366}
1367
1368fn boolean(value: Option<&Value>, default: bool, name: &str) -> Result<bool> {
1369    value
1370        .map(|value| {
1371            value
1372                .as_bool()
1373                .with_context(|| format!("{name} must be boolean"))
1374        })
1375        .transpose()
1376        .map(|value| value.unwrap_or(default))
1377}
1378
1379fn package_manager(workspace: &Workspace) -> Result<&'static str> {
1380    workspace.package_manager_name()
1381}
1382
1383#[cfg(all(test, windows))]
1384mod dotenv_tests {
1385    #[test]
1386    fn colon_targets_keep_configuration_and_generic_dotenv_files() {
1387        let files = super::dotenv_files("app", "test:unit:watch", Some("ci"), None);
1388        assert!(files.iter().all(|file| !file.contains(':')));
1389        assert!(files.contains(&"app/.env.ci".to_owned()));
1390        assert!(files.contains(&"app/.env".to_owned()));
1391        assert!(files.contains(&".env.ci".to_owned()));
1392        assert!(files.contains(&".env".to_owned()));
1393    }
1394}