1mod capture;
4pub mod dotenv;
5mod env_flags;
6mod interpolate;
7mod process;
8#[cfg(unix)]
9mod pty;
10pub mod readiness;
11pub mod report;
12pub mod tokens;
13
14use std::collections::BTreeMap;
15use std::ffi::OsString;
16use std::path::{Path, PathBuf};
17
18use anyhow::{Context, Result, bail};
19use qk_config::Workspace;
20use qk_taskgraph::Task;
21use serde_json::{Map, Value};
22
23pub use capture::{
24 Capture, Decoration, Display, MAX_LOG_BYTES, OutputStyle, RecordedLog, Shown, collapsed,
25 printable, read_capture, replay,
26};
27pub use env_flags::{force_colour, is_ci, is_ci_with};
28pub use process::{Outcome, execute, execute_captured, shell};
29
30#[derive(Debug)]
31struct NodeRuntime {
32 root: PathBuf,
33 env: BTreeMap<OsString, OsString>,
34 paths: Vec<PathBuf>,
35 directory: std::sync::OnceLock<Option<PathBuf>>,
36}
37
38#[derive(Clone, Debug)]
40pub struct NodePath {
41 runtime: std::sync::Arc<NodeRuntime>,
42 local_entries: usize,
43}
44
45impl NodePath {
46 fn new(root: &Path, env: &BTreeMap<OsString, OsString>, local_entries: usize) -> Self {
47 type Key = (PathBuf, BTreeMap<OsString, OsString>);
48 static RUNTIMES: std::sync::OnceLock<
49 std::sync::Mutex<BTreeMap<Key, std::sync::Arc<NodeRuntime>>>,
50 > = std::sync::OnceLock::new();
51 let mut runtimes = RUNTIMES.get_or_init(Default::default).lock().unwrap();
52 let runtime = runtimes
53 .entry((root.to_owned(), env.clone()))
54 .or_insert_with(|| {
55 let paths = env
56 .get(&path_key(env))
57 .map(|path| std::env::split_paths(path).collect())
58 .unwrap_or_default();
59 std::sync::Arc::new(NodeRuntime {
60 root: root.to_owned(),
61 env: env.clone(),
62 paths,
63 directory: Default::default(),
64 })
65 })
66 .clone();
67 Self {
68 runtime,
69 local_entries,
70 }
71 }
72
73 fn path(&self, env: &BTreeMap<OsString, OsString>) -> Result<Option<(OsString, OsString)>> {
74 let Some(directory) = self.runtime.directory.get_or_init(|| {
77 node_directory(&self.runtime.root, &self.runtime.paths, &self.runtime.env)
78 }) else {
79 return Ok(None);
80 };
81 let key = path_key(env);
82 let mut paths: Vec<_> = env
83 .get(&key)
84 .map(|path| std::env::split_paths(path).collect())
85 .unwrap_or_default();
86 paths.insert(self.local_entries.min(paths.len()), directory.clone());
87 Ok(Some((
88 key,
89 std::env::join_paths(paths).context("cannot construct task PATH")?,
90 )))
91 }
92}
93
94fn path_key(env: &BTreeMap<OsString, OsString>) -> OsString {
95 if cfg!(windows) {
96 env.keys()
97 .find(|key| key.to_string_lossy().eq_ignore_ascii_case("PATH"))
98 .cloned()
99 .unwrap_or_else(|| "PATH".into())
100 } else {
101 "PATH".into()
102 }
103}
104
105fn environment_key(env: &BTreeMap<OsString, OsString>, name: OsString) -> OsString {
106 if cfg!(windows) && name.to_string_lossy().eq_ignore_ascii_case("PATH") {
107 path_key(env)
108 } else {
109 name
110 }
111}
112
113fn node_directory(
114 cwd: &Path,
115 paths: &[PathBuf],
116 env: &BTreeMap<OsString, OsString>,
117) -> Option<PathBuf> {
118 let name = if cfg!(windows) { "node.exe" } else { "node" };
119 for directory in paths {
120 let candidate = cwd.join(directory).join(name);
121 let Ok(metadata) = candidate.metadata() else {
122 continue;
123 };
124 if !metadata.is_file() {
125 continue;
126 }
127 #[cfg(unix)]
128 {
129 use std::os::unix::fs::PermissionsExt;
130 if metadata.permissions().mode() & 0o111 == 0 {
131 continue;
132 }
133 }
134 let actual = candidate.canonicalize().ok()?;
136 if actual.file_name()? == name {
137 use std::io::Read;
138 let mut header = [0; 2];
139 let script = std::fs::File::open(&actual)
140 .and_then(|mut file| file.read_exact(&mut header))
141 .is_ok()
142 && header == *b"#!";
143 if !script {
144 return actual.parent().map(Path::to_owned);
145 }
146 }
147 return probe_node(&candidate, cwd, env);
148 }
149 None
150}
151
152fn probe_node(candidate: &Path, cwd: &Path, env: &BTreeMap<OsString, OsString>) -> Option<PathBuf> {
153 use std::io::{Read, Seek};
154 use std::process::{Command, Stdio};
155 use std::time::{Duration, Instant};
156 let mut output = tempfile::tempfile().ok()?;
157 let mut child = Command::new(candidate)
158 .args(["-p", "process.execPath"])
159 .current_dir(cwd)
160 .env_clear()
161 .envs(env)
162 .stdout(Stdio::from(output.try_clone().ok()?))
163 .stderr(Stdio::null())
164 .spawn()
165 .ok()?;
166 let started = Instant::now();
167 loop {
168 match child.try_wait() {
169 Ok(Some(status)) => {
170 if !status.success() {
171 return None;
172 }
173 let mut bytes = Vec::new();
174 output.rewind().ok()?;
175 output.take(4096).read_to_end(&mut bytes).ok()?;
176 let path = PathBuf::from(std::str::from_utf8(&bytes).ok()?.trim());
177 return (path.is_absolute() && path.is_file())
178 .then(|| path.parent().unwrap().to_owned());
179 }
180 Ok(None) if started.elapsed() < Duration::from_secs(2) => {
181 std::thread::sleep(Duration::from_millis(5))
182 }
183 _ => {
184 let _ = child.kill();
185 let _ = child.wait();
186 return None;
187 }
188 }
189 }
190}
191
192#[macro_export]
195macro_rules! status {
196 ($($arg:tt)*) => {{
197 $crate::report::warning(&::std::format!($($arg)*));
198 }};
199}
200
201#[derive(Clone, Debug)]
203pub enum Confinement {
204 Seatbelt(PathBuf),
206 Landlock(i32),
209}
210
211#[derive(Clone, Debug)]
213pub struct Recording {
214 pub program: PathBuf,
215 pub directory: PathBuf,
216}
217
218impl Recording {
219 pub fn command(&self, index: usize, text: &str) -> std::process::Command {
221 let mut command = std::process::Command::new(&self.program);
222 command
224 .args([
225 "-ff",
226 "-ttt",
227 "-yy",
228 "-qq",
229 "-s",
230 "4096",
231 "-e",
232 "trace=%file,%process,getdents64,fchdir,close,dup,dup2,dup3,getcwd",
233 "-e",
234 "raw=execve,execveat",
235 "-o",
236 ])
237 .arg(self.directory.join(format!("command-{index}")))
238 .args(["--", "/bin/sh", "-c", text]);
239 command
240 }
241}
242
243#[derive(Clone, Debug)]
244pub struct PreparedTask {
245 pub id: String,
246 pub commands: Vec<String>,
247 pub parallel: bool,
248 pub cwd: PathBuf,
249 pub env: BTreeMap<OsString, OsString>,
250 pub runtime_env: BTreeMap<OsString, OsString>,
252 pub node_path: Option<NodePath>,
253 pub execution: BTreeMap<OsString, OsString>,
256 pub ready_when: Vec<String>,
259 pub ready: std::sync::Arc<std::sync::atomic::AtomicBool>,
261 pub ready_check: Option<std::result::Result<readiness::ReadyCheck, String>>,
264 pub verdict: std::sync::Arc<readiness::Verdict>,
266 pub processes: std::sync::Arc<std::sync::Mutex<std::collections::BTreeSet<u32>>>,
269 pub decorations: Vec<Decoration>,
271 pub interactive: bool,
275 pub pseudo_terminal: bool,
279 pub tty: bool,
282 pub sandbox: Option<Confinement>,
284 pub recording: Option<Recording>,
285 pub display: Display,
287}
288
289fn dotenv_files(
296 project_root: &str,
297 target: &str,
298 configuration: Option<&str>,
299 non_atomized: Option<&str>,
300) -> Vec<String> {
301 let mut identifiers = Vec::new();
302 if let Some(configuration) = configuration {
303 identifiers.push(format!("{target}.{configuration}"));
304 if let Some(non_atomized) = non_atomized {
305 identifiers.push(format!("{non_atomized}.{configuration}"));
306 }
307 identifiers.push(configuration.to_owned());
308 }
309 identifiers.push(target.to_owned());
310 if let Some(non_atomized) = non_atomized {
311 identifiers.push(non_atomized.to_owned());
312 }
313 identifiers.push(String::new());
314 let variants = |identifier: &str, root: &str| -> Vec<String> {
315 let prefix = if root.is_empty() || root == "." {
316 String::new()
317 } else {
318 format!("{root}/")
319 };
320 if identifier.is_empty() {
321 [".env.local", ".local.env", ".env"]
322 .map(|name| format!("{prefix}{name}"))
323 .to_vec()
324 } else {
325 [
326 format!(".env.{identifier}.local"),
327 format!(".env.{identifier}"),
328 format!(".{identifier}.local.env"),
329 format!(".{identifier}.env"),
330 ]
331 .map(|name| format!("{prefix}{name}"))
332 .to_vec()
333 }
334 };
335 let mut files = Vec::new();
336 for root in [project_root, ""] {
337 for identifier in &identifiers {
338 #[cfg(windows)]
340 if identifier.contains(':') {
341 continue;
342 }
343 for file in variants(identifier, root) {
344 if !files.contains(&file) {
345 files.push(file);
346 }
347 }
348 }
349 }
350 files
351}
352
353fn owner_target<'a>(
360 project: &'a qk_config::Project,
361 target: &'a str,
362) -> (&'a str, Option<&'a str>) {
363 let non_atomized = |definition: &'a qk_config::Target| {
364 definition
365 .extra
366 .get("metadata")
367 .and_then(|metadata| metadata.get("nonAtomizedTarget"))
368 .and_then(Value::as_str)
369 };
370 if let Some((name, definition)) = project.targets.get_key_value(target)
371 && let Some(non_atomized) = non_atomized(definition)
372 {
373 return (name, Some(non_atomized));
374 }
375 let groups = project
376 .extra
377 .get("metadata")
378 .and_then(|metadata| metadata.get("targetGroups"))
379 .and_then(Value::as_object);
380 for group in groups.into_iter().flat_map(|groups| groups.values()) {
381 let members: Vec<&str> = group
382 .as_array()
383 .into_iter()
384 .flatten()
385 .filter_map(Value::as_str)
386 .collect();
387 if !members.contains(&target) {
388 continue;
389 }
390 for member in members {
391 if let Some((name, definition)) = project.targets.get_key_value(member)
392 && let Some(non_atomized) = non_atomized(definition)
393 {
394 return (name, Some(non_atomized));
395 }
396 }
397 }
398 (target, None)
399}
400
401pub const INVOCATIONS: &str = "QK_TASK_INVOCATIONS";
403
404pub fn invocation(task: &Task) -> String {
407 let mut text = task.id.clone();
408 for arg in &task.args {
409 text.push('\u{1f}');
410 text.push_str(arg);
411 }
412 text.replace('\n', " ")
413}
414
415pub fn enclosing_invocations() -> Vec<String> {
417 std::env::var(INVOCATIONS)
418 .map(|chain| chain.lines().map(str::to_owned).collect())
419 .unwrap_or_default()
420}
421
422pub const ROOT_DOTENV_FILES: [&str; 3] = [".local.env", ".env.local", ".env"];
425
426fn task_environment(
431 workspace: &Workspace,
432 task: &Task,
433 base_env: &BTreeMap<OsString, OsString>,
434) -> Result<(BTreeMap<OsString, OsString>, BTreeMap<OsString, OsString>)> {
435 let mut env = base_env.clone();
436 let project = &workspace.projects[&task.project];
437 if loads_dotenv(base_env) {
438 for file in [".env", ".local.env", ".env.local"] {
442 for (name, value) in dotenv::read_alone(&workspace.root.join(file))? {
443 if env.get(&name) == Some(&value) {
444 env.remove(&name);
445 }
446 }
447 }
448 let (target, non_atomized) = owner_target(project, &task.target);
449 let files: Vec<PathBuf> = dotenv_files(
450 &project.root,
451 target,
452 task.configuration.as_deref(),
453 non_atomized,
454 )
455 .into_iter()
456 .map(|file| workspace.root.join(file))
457 .collect();
458 dotenv::load(&files, &mut env)?;
459 }
460 let runtime_env = env.clone();
461 for name in [
463 "NX_SET_CLI",
464 "NX_ORIGINAL_FORCE_COLOR",
465 "NX_TERMINAL_OUTPUT_PATH",
466 "NX_STREAM_OUTPUT",
467 "NX_PREFIX_OUTPUT",
468 ] {
469 env.remove(std::ffi::OsStr::new(name));
470 }
471 let mut set = |name: &str, value: OsString| {
472 env.insert(name.into(), value);
473 };
474 set("NX_WORKSPACE_ROOT", workspace.root.clone().into_os_string());
475 set("NX_TASK_TARGET_PROJECT", task.project.clone().into());
476 set("NX_TASK_TARGET_TARGET", task.target.clone().into());
477 set("LERNA_PACKAGE_NAME", task.project.clone().into());
478 set("NX_TUI", "false".into());
479 let ancestors = match base_env.get(std::ffi::OsStr::new("NX_INVOCATION_ANCESTOR_PIDS")) {
481 Some(inherited) if !inherited.is_empty() => {
482 format!("{},{}", inherited.to_string_lossy(), std::process::id())
483 }
484 _ => std::process::id().to_string(),
485 };
486 set("NX_INVOCATION_ANCESTOR_PIDS", ancestors.into());
487 let mut chain = base_env
490 .get(std::ffi::OsStr::new(INVOCATIONS))
491 .map(|chain| chain.to_string_lossy().into_owned())
492 .unwrap_or_default();
493 if !chain.is_empty() {
494 chain.push('\n');
495 }
496 chain.push_str(&invocation(task));
497 set(INVOCATIONS, chain.into());
498 if task.target == "test" {
499 set("NX_TERMINAL_CAPTURE_STDERR", "true".into());
500 }
501 match &task.configuration {
502 Some(configuration) => set("NX_TASK_TARGET_CONFIGURATION", configuration.clone().into()),
503 None => {
504 env.remove(std::ffi::OsStr::new("NX_TASK_TARGET_CONFIGURATION"));
505 }
506 }
507 Ok((env, runtime_env))
508}
509
510fn loads_dotenv(env: &BTreeMap<OsString, OsString>) -> bool {
513 env.get(std::ffi::OsStr::new("NX_LOAD_DOT_ENV_FILES"))
514 .is_none_or(|value| value != "false")
515}
516
517pub fn environment(workspace: &Path) -> Result<BTreeMap<OsString, OsString>> {
521 let mut values: BTreeMap<OsString, OsString> = std::env::vars_os().collect();
522 if loads_dotenv(&values) {
523 let files: Vec<PathBuf> = ROOT_DOTENV_FILES
524 .iter()
525 .map(|file| workspace.join(file))
526 .collect();
527 dotenv::load(&files, &mut values)?;
528 }
529 Ok(values)
530}
531
532pub fn prepare(
533 workspace: &Workspace,
534 task: &Task,
535 base_env: &BTreeMap<OsString, OsString>,
536) -> Result<PreparedTask> {
537 let definition = &task.definition;
538 let executor = definition
539 .executor
540 .as_deref()
541 .context("target has no executor or command")?;
542 let project = &workspace.projects[&task.project];
543 let run_commands = executor == "nx:run-commands";
544 if !matches!(executor, "nx:run-commands" | "nx:run-script" | "nx:noop") {
545 bail!("unsupported executor {executor:?}");
546 }
547 let options = if run_commands {
549 run_commands_overrides(&definition.options, &task.args, project)?
550 } else {
551 definition.options.clone()
552 };
553 let options = &options;
554 let empty = Map::new();
555 let mut interpolation = if run_commands {
556 interpolate::Interpolation::new(&task.args, options)?
557 } else {
558 interpolate::Interpolation::new(&[], &empty)?
559 };
560 let (mut env, mut runtime_env) = task_environment(workspace, task, base_env)?;
561 if let Some(file) = options.get("envFile").filter(|_| run_commands) {
565 let file = file.as_str().context("envFile must be a string")?;
566 let path = workspace.root.join(file);
567 if loads_dotenv(&runtime_env) {
568 if !path.is_file() {
569 bail!("envFile {file} does not exist");
570 }
571 dotenv::load(std::slice::from_ref(&path), &mut runtime_env)?;
572 dotenv::load(std::slice::from_ref(&path), &mut env)?;
573 }
574 }
575 let option_env = options.get("env").filter(|_| run_commands);
577 for values in [definition.extra.get("env"), option_env]
578 .into_iter()
579 .flatten()
580 {
581 for (name, value) in values.as_object().context("env must be an object")? {
582 if name.is_empty() || name.contains(['=', '\0']) {
583 bail!("invalid environment variable name");
584 }
585 let value: OsString = value
586 .as_str()
587 .context("environment values must be strings")?
588 .into();
589 let name = environment_key(&env, name.into());
590 runtime_env.insert(name.clone(), value.clone());
591 env.insert(name, value);
592 }
593 }
594 interpolation.set_env(&env);
595 let mut execution = BTreeMap::new();
596 execution.insert(
597 "FORCE_COLOR".into(),
598 env.get(std::ffi::OsStr::new("FORCE_COLOR"))
599 .cloned()
600 .unwrap_or_else(|| "true".into()),
601 );
602 if run_commands && boolean(options.get("color"), false, "color")? {
603 execution.insert("FORCE_COLOR".into(), "true".into());
604 }
605 let cwd = match options.get("cwd").filter(|_| run_commands) {
608 Some(cwd) => cwd.as_str().context("cwd must be a string")?,
609 None if run_commands => ".",
610 None => &project.root,
611 };
612 let cwd = workspace.root.join(cwd);
613 let mut resolved = PathBuf::new();
614 for part in cwd.components() {
615 match part {
616 std::path::Component::ParentDir => {
617 resolved.pop();
618 }
619 std::path::Component::CurDir => {}
620 _ => resolved.push(part.as_os_str()),
621 }
622 }
623 let mut paths = Vec::new();
625 for directory in resolved.ancestors() {
626 paths.push(directory.join("node_modules/.bin"));
627 }
628 let node_path = NodePath::new(&workspace.root, base_env, paths.len());
629 let path_key = path_key(&env);
630 if let Some(path) = env.get(&path_key) {
631 paths.extend(std::env::split_paths(path));
632 }
633 let path = std::env::join_paths(paths).context("cannot construct task PATH")?;
634 env.insert(path_key.clone(), path.clone());
635 runtime_env.insert(path_key, path);
636 let ready_when: Vec<String> = match options.get("readyWhen").filter(|_| run_commands) {
637 None | Some(Value::Null) => Vec::new(),
638 Some(Value::String(text)) => vec![text.clone()],
639 Some(value) => serde_json::from_value(value.clone())
640 .context("readyWhen must be a string or an array of strings")?,
641 };
642 let parallel = if run_commands && options.get("command").is_some_and(merge_truthy) {
645 !ready_when.is_empty()
646 } else if run_commands {
647 boolean(options.get("parallel"), true, "parallel")?
648 } else {
649 true
650 };
651 let (mut pseudo_terminal, tty) = if run_commands {
652 (
653 boolean(options.get("usePty"), true, "usePty")?
654 && base_env
655 .get(std::ffi::OsStr::new("NX_NATIVE_COMMAND_RUNNER"))
656 .is_none_or(|value| value != "false"),
657 boolean(options.get("tty"), true, "tty")?,
658 )
659 } else {
660 (true, true)
661 };
662 let mut decorations = Vec::new();
663 if !ready_when.is_empty() && !parallel {
664 bail!("readyWhen can only be used when parallel is true");
665 }
666 let mut commands = Vec::new();
667 match executor {
668 "nx:noop" => {}
669 "nx:run-script" => {
670 let script = options
671 .get("script")
672 .and_then(Value::as_str)
673 .context("run-script requires options.script")?;
674 let package = workspace
675 .packages
676 .get(&task.project)
677 .context("run-script requires a package.json")?;
678 if !package.scripts.contains_key(script) {
679 bail!("package.json has no script {script:?}");
680 }
681 let manager = package_manager(workspace)?;
682 let args = interpolate::quoted_arguments(&task.args)?;
685 let script = interpolate::quote(script)?;
686 let mut command = match manager {
689 "yarn" => format!("yarn {script}"),
690 manager => format!("{manager} run {script}"),
691 };
692 if !args.is_empty() {
693 if matches!(manager, "npm" | "bun") {
694 command.push_str(" --");
695 }
696 command.push(' ');
697 command.push_str(&args);
698 }
699 commands.push(command);
700 }
701 "nx:run-commands" => {
702 let forward = boolean(options.get("forwardAllArgs"), true, "forwardAllArgs")?;
703 if let Some(command) = options
705 .get("command")
706 .filter(|command| merge_truthy(command))
707 {
708 let command = match command {
710 Value::String(command) => command.clone(),
711 Value::Array(words) => words
712 .iter()
713 .map(|word| word.as_str().context("command words must be strings"))
714 .collect::<Result<Vec<_>>>()?
715 .join(" "),
716 _ => bail!("command must be a string or an array of strings"),
717 };
718 commands.push(interpolation.command(&command, forward)?);
719 } else if let Some(values) = options.get("commands") {
720 for value in values.as_array().context("commands must be an array")? {
721 let (command, forward) = if let Some(command) = value.as_str() {
722 (command, forward)
723 } else {
724 let value = value
725 .as_object()
726 .context("commands entries must be strings or objects")?;
727 for key in value.keys() {
728 if !matches!(
729 key.as_str(),
730 "command"
731 | "forwardAllArgs"
732 | "prefix"
733 | "prefixColor"
734 | "color"
735 | "bgColor"
736 | "description"
737 ) {
738 bail!("unsupported command entry field {key:?}");
739 }
740 }
741 let field =
742 |name: &str, background: Option<bool>| -> Result<Option<String>> {
743 let Some(value) = value.get(name) else {
744 return Ok(None);
745 };
746 let text = value.as_str().with_context(|| {
747 format!("command entry {name} must be a string")
748 })?;
749 if let Some(background) = background
750 && !Decoration::valid(text, background)
751 {
752 bail!("command entry {name} {text:?} is not a colour");
753 }
754 Ok(Some(text.to_owned()))
755 };
756 let decoration = Decoration {
757 prefix: field("prefix", None)?,
758 prefix_color: field("prefixColor", Some(false))?,
759 color: field("color", Some(false))?,
760 bg_color: field("bgColor", Some(true))?,
761 };
762 if !decoration.is_empty() && !parallel {
763 bail!(
764 "prefix, prefixColor, color and bgColor can only be set when parallel is true"
765 );
766 }
767 decorations.resize(commands.len(), Decoration::default());
768 decorations.push(decoration);
769 (
770 value
771 .get("command")
772 .and_then(Value::as_str)
773 .context("command entry requires a command string")?,
774 boolean(value.get("forwardAllArgs"), forward, "forwardAllArgs")?,
775 )
776 };
777 commands.push(interpolation.command(command, forward)?);
778 }
779 } else {
780 bail!("run-commands requires command or commands");
781 }
782 if commands.iter().any(|command| command.trim().is_empty()) {
784 bail!("run-commands requires nonempty commands");
785 }
786 }
787 _ => bail!("unsupported executor {executor:?}"),
788 }
789 pseudo_terminal &= commands.len() == 1 || !parallel;
791 let ready_check = ready_check(workspace, task, options, &env)?;
792 Ok(PreparedTask {
793 id: task.id.clone(),
794 commands,
795 parallel,
796 cwd,
797 env,
798 runtime_env,
799 node_path: Some(node_path),
800 execution,
801 ready_when,
802 ready: Default::default(),
803 ready_check,
804 verdict: Default::default(),
805 processes: Default::default(),
806 decorations,
807 interactive: false,
808 pseudo_terminal,
809 tty,
810 sandbox: None,
811 recording: None,
812 display: Display::default(),
813 })
814}
815
816fn ready_check(
820 workspace: &Workspace,
821 task: &Task,
822 options: &Map<String, Value>,
823 env: &BTreeMap<OsString, OsString>,
824) -> Result<Option<std::result::Result<readiness::ReadyCheck, String>>> {
825 let Some(ready_when) = task
826 .definition
827 .extra
828 .get("readyWhen")
829 .filter(|value| !value.is_null() && task.definition.continuous == Some(true))
830 else {
831 return Ok(None);
832 };
833 let mut options = options.clone();
834 let project = &workspace.projects[&task.project];
835 for (key, value) in interpolate::yargs(&task.args, false, false) {
836 if key == "_" {
837 continue;
838 }
839 let value = match value {
841 Value::String(text) => Value::String(qk_config::interpolate_override(&text, project)?),
842 value => value,
843 };
844 if key.contains('-') {
845 options.insert(interpolate::camel_case(&key), value.clone());
846 }
847 options.insert(key, value);
848 }
849 let env: BTreeMap<String, String> = env
850 .iter()
851 .filter_map(|(name, value)| Some((name.to_str()?.to_owned(), value.to_str()?.to_owned())))
852 .collect();
853 let scope = readiness::Scope {
854 workspace_root: workspace
855 .root
856 .to_str()
857 .context("workspace path must be UTF-8")?,
858 project_root: &workspace.projects[&task.project].root,
859 project_name: &task.project,
860 options: &options,
861 env: &env,
862 };
863 Ok(readiness::resolve(&task.id, ready_when, &scope)
864 .map_err(|error| format!("{error:#}"))
865 .transpose())
866}
867
868const OPTION_FLAGS: &[&str] = &[
870 "command",
871 "commands",
872 "color",
873 "parallel",
874 "readyWhen",
875 "cwd",
876 "args",
877 "envFile",
878 "__unparsed__",
879 "env",
880 "usePty",
881 "streamOutput",
882 "verbose",
883 "forwardAllArgs",
884 "tty",
885];
886
887fn run_commands_overrides(
891 options: &serde_json::Map<String, Value>,
892 args: &[String],
893 project: &qk_config::Project,
894) -> Result<serde_json::Map<String, Value>> {
895 const BOOLEAN: &[&str] = &[
896 "color",
897 "parallel",
898 "usePty",
899 "streamOutput",
900 "verbose",
901 "forwardAllArgs",
902 "tty",
903 ];
904 let mut options = options.clone();
905 let mut ready = Vec::new();
906 let mut args = args.iter().peekable();
907 while let Some(arg) = args.next() {
908 let Some(flag) = arg.strip_prefix("--") else {
909 continue;
910 };
911 let (key, value) = match flag.split_once('=') {
912 Some((key, value)) => (key, Some(value.to_owned())),
913 None => (flag, None),
914 };
915 let (key, negated) = match key.strip_prefix("no-") {
916 Some(key) if OPTION_FLAGS.contains(&key) => (key, true),
917 _ => (key, false),
918 };
919 let (base, field) = match key.split_once('.') {
920 Some((base, field)) => (base, Some(field)),
921 None => (key, None),
922 };
923 if !OPTION_FLAGS.contains(&base) {
924 continue;
925 }
926 let boolean = BOOLEAN.contains(&base);
927 let value = match value {
928 Some(value) => value,
929 None if negated => "false".into(),
930 None if !boolean && args.peek().is_some_and(|next| !next.starts_with('-')) => {
931 args.next().unwrap().clone()
932 }
933 None => "true".into(),
934 };
935 let value = if boolean {
936 Value::Bool(value != "false")
937 } else {
938 Value::String(qk_config::interpolate_override(&value, project)?)
940 };
941 match (base, field) {
942 ("env", Some(name)) => {
943 let env = options
944 .entry("env")
945 .or_insert_with(|| Value::Object(Default::default()));
946 if let Value::Object(env) = env {
947 env.insert(name.to_owned(), value);
948 }
949 }
950 ("readyWhen", None) => ready.push(value),
951 ("__unparsed__" | "commands", _) | (_, Some(_)) => {}
953 (base, None) => {
954 options.insert(base.to_owned(), value);
955 }
956 }
957 }
958 if !ready.is_empty() {
959 let ready = if ready.len() == 1 {
960 ready.pop().unwrap()
961 } else {
962 Value::Array(ready)
963 };
964 options.insert("readyWhen".into(), ready);
965 }
966 Ok(options)
967}
968
969fn merge_truthy(value: &Value) -> bool {
971 match value {
972 Value::Null => false,
973 Value::Bool(flag) => *flag,
974 Value::Number(number) => number.as_f64().is_some_and(|number| number != 0.0),
975 Value::String(text) => !text.is_empty(),
976 Value::Array(_) | Value::Object(_) => true,
977 }
978}
979
980fn boolean(value: Option<&Value>, default: bool, name: &str) -> Result<bool> {
981 value
982 .map(|value| {
983 value
984 .as_bool()
985 .with_context(|| format!("{name} must be boolean"))
986 })
987 .transpose()
988 .map(|value| value.unwrap_or(default))
989}
990
991fn package_manager(workspace: &Workspace) -> Result<&'static str> {
992 workspace.package_manager_name()
993}
994
995#[cfg(all(test, windows))]
996mod dotenv_tests {
997 #[test]
998 fn colon_targets_keep_configuration_and_generic_dotenv_files() {
999 let files = super::dotenv_files("app", "test:unit:watch", Some("ci"), None);
1000 assert!(files.iter().all(|file| !file.contains(':')));
1001 assert!(files.contains(&"app/.env.ci".to_owned()));
1002 assert!(files.contains(&"app/.env".to_owned()));
1003 assert!(files.contains(&".env.ci".to_owned()));
1004 assert!(files.contains(&".env".to_owned()));
1005 }
1006}