use std::collections::HashMap;
use anyhow::{Result, anyhow};
use super::join_labels;
use super::policies::{
is_env_truthy, parse_fallback_label, parse_mismatch_label, parse_prefer_runners,
resolve_failure_policy, resolve_fallback_policy, resolve_mismatch_policy,
};
use super::types::{
DiagnosticFlags, ExplainSource, OverrideOrigin, OverrideSources, PmOverride,
ResolutionOverrides, RunnerOverride, SourceValue,
};
use crate::config::{LoadedConfig, parse_node_pm, parse_python_pm};
use crate::types::{DetectionWarning, Ecosystem, PackageManager, TaskRunner};
impl ResolutionOverrides {
pub(crate) fn from_cli_and_env(
cli_pm: Option<&str>,
cli_runner: Option<&str>,
cli_fallback: Option<&str>,
cli_on_mismatch: Option<&str>,
diagnostics: DiagnosticFlags,
failure: crate::cli::ChainFailureFlags,
config: Option<&LoadedConfig>,
) -> Result<Self> {
let env = EnvSnapshot::capture();
let cli = CliSides {
pm: cli_pm,
runner: cli_runner,
fallback: cli_fallback,
on_mismatch: cli_on_mismatch,
diagnostics,
failure,
};
Self::from_sources(env.sources(cli, config))
}
pub(crate) fn from_cli_and_env_lenient(
cli_pm: Option<&str>,
cli_runner: Option<&str>,
cli_fallback: Option<&str>,
cli_on_mismatch: Option<&str>,
diagnostics: DiagnosticFlags,
failure: crate::cli::ChainFailureFlags,
config: Option<&LoadedConfig>,
) -> Result<(Self, Vec<DetectionWarning>)> {
let env = EnvSnapshot::capture();
let cli = CliSides {
pm: cli_pm,
runner: cli_runner,
fallback: cli_fallback,
on_mismatch: cli_on_mismatch,
diagnostics,
failure,
};
Self::from_sources_lenient(env.sources(cli, config))
}
pub(crate) fn from_sources_lenient(
mut sources: OverrideSources<'_>,
) -> Result<(Self, Vec<DetectionWarning>)> {
let mut warnings = Vec::new();
lenient_env_field(&mut sources.pm, "RUNNER_PM", &mut warnings, |raw| {
parse_pm_label(raw).map(drop)
});
lenient_env_field(&mut sources.runner, "RUNNER_RUNNER", &mut warnings, |raw| {
parse_runner_label(raw).map(drop)
});
lenient_env_field(
&mut sources.fallback,
"RUNNER_FALLBACK",
&mut warnings,
|raw| parse_fallback_label(raw).map(drop),
);
lenient_env_field(
&mut sources.on_mismatch,
"RUNNER_ON_MISMATCH",
&mut warnings,
|raw| parse_mismatch_label(raw).map(drop),
);
let overrides = Self::from_sources(sources)?;
Ok((overrides, warnings))
}
#[allow(
clippy::needless_pass_by_value,
reason = "OverrideSources is a single-use builder; taking by value keeps the call sites moveable"
)]
pub(crate) fn from_sources(sources: OverrideSources<'_>) -> Result<Self> {
let pm = parse_override(
sources.pm.cli,
sources.pm.env,
&PM_SOURCE_NAMES,
parse_pm_label,
|pm, origin| PmOverride { pm, origin },
)?;
let runner = parse_override(
sources.runner.cli,
sources.runner.env,
&RUNNER_SOURCE_NAMES,
parse_runner_label,
|runner, origin| RunnerOverride { runner, origin },
)?;
let fallback =
resolve_fallback_policy(sources.fallback.cli, sources.fallback.env, sources.config)?;
let on_mismatch = resolve_mismatch_policy(
sources.on_mismatch.cli,
sources.on_mismatch.env,
sources.config,
)?;
let prefer_runners = parse_prefer_runners(sources.config)?;
let no_warnings =
sources.no_warnings.cli || sources.no_warnings.env.is_some_and(is_env_truthy);
let explain = sources.explain.cli || sources.explain.env.is_some_and(is_env_truthy);
let failure_policy =
resolve_failure_policy(sources.keep_going, sources.kill_on_fail, sources.config)?;
let group_output = sources.config.is_none_or(|c| c.config.github.group_output);
let github_group_parallel = sources
.config
.is_none_or(|c| c.config.github.group_parallel);
let parallel_grouped = sources.config.is_some_and(|c| c.config.parallel.grouped);
let mut pm_by_ecosystem = HashMap::new();
if let Some(loaded) = sources.config {
if let Some(raw) = loaded.config.pm.node.as_deref() {
let pm_value = parse_node_pm(raw)?;
pm_by_ecosystem.insert(
pm_value.ecosystem(),
PmOverride {
pm: pm_value,
origin: OverrideOrigin::ConfigFile {
path: loaded.path.clone(),
},
},
);
}
if let Some(raw) = loaded.config.pm.python.as_deref() {
let pm_value = parse_python_pm(raw)?;
pm_by_ecosystem.insert(
Ecosystem::Python,
PmOverride {
pm: pm_value,
origin: OverrideOrigin::ConfigFile {
path: loaded.path.clone(),
},
},
);
}
}
Ok(Self {
pm,
pm_by_ecosystem,
runner,
prefer_runners,
fallback,
on_mismatch,
no_warnings,
explain,
failure_policy,
group_output,
github_group_parallel,
parallel_grouped,
})
}
}
fn parse_pm_label(raw: &str) -> Result<PackageManager> {
if let Some(pm) = PackageManager::from_label(raw) {
return Ok(pm);
}
if let Some(runner) = TaskRunner::from_label(raw) {
return Err(anyhow!(
"{:?} is a task runner, not a package manager; use `--runner {}` instead",
raw,
runner.label(),
));
}
Err(anyhow!(
"unknown package manager \"{}\"; expected one of {}",
sanitize_raw_label(raw),
join_labels(
PackageManager::all()
.iter()
.copied()
.map(PackageManager::label)
),
))
}
fn parse_runner_label(raw: &str) -> Result<TaskRunner> {
if let Some(runner) = TaskRunner::from_label(raw) {
return Ok(runner);
}
if let Some(pm) = PackageManager::from_label(raw) {
return Err(anyhow!(
"{:?} is a package manager, not a task runner; use `--pm {}` instead",
raw,
pm.label(),
));
}
Err(anyhow!(
"unknown task runner \"{}\"; expected one of {}",
sanitize_raw_label(raw),
join_labels(TaskRunner::all().iter().copied().map(TaskRunner::label)),
))
}
const MAX_RAW_DISPLAY: usize = 60;
fn sanitize_raw_label(raw: &str) -> String {
let escaped: String = raw.chars().flat_map(char::escape_debug).collect();
let mut chars = escaped.chars();
let truncated: String = chars.by_ref().take(MAX_RAW_DISPLAY).collect();
if chars.next().is_some() {
format!("{truncated}…")
} else {
truncated
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lenient_policy_env_garbage_does_not_leak_full_raw_value() {
let token_prefix = "ghp_";
let fake_token = format!(
"{token_prefix}{}DO_NOT_LEAK_ME",
"A".repeat(MAX_RAW_DISPLAY.saturating_sub(token_prefix.len()))
);
let huge = fake_token.repeat(6);
let (_overrides, warnings) = ResolutionOverrides::from_sources_lenient(OverrideSources {
fallback: SourceValue {
cli: None,
env: Some(&huge),
},
..OverrideSources::default()
})
.expect("lenient pass must absorb fallback env garbage");
assert_eq!(warnings.len(), 1);
let detail = warnings[0].detail();
assert!(
detail.contains('…'),
"long invalid env value should be truncated in warning detail"
);
assert!(
!detail.contains("DO_NOT_LEAK_ME"),
"secret-looking env tail must not leak in warning detail"
);
}
}
#[derive(Clone, Copy)]
struct CliSides<'a> {
pm: Option<&'a str>,
runner: Option<&'a str>,
fallback: Option<&'a str>,
on_mismatch: Option<&'a str>,
diagnostics: DiagnosticFlags,
failure: crate::cli::ChainFailureFlags,
}
struct EnvSnapshot {
pm: Option<String>,
runner: Option<String>,
fallback: Option<String>,
on_mismatch: Option<String>,
no_warnings: Option<String>,
explain: Option<String>,
keep_going: Option<String>,
kill_on_fail: Option<String>,
}
impl EnvSnapshot {
fn capture() -> Self {
Self {
pm: std::env::var("RUNNER_PM").ok(),
runner: std::env::var("RUNNER_RUNNER").ok(),
fallback: std::env::var("RUNNER_FALLBACK").ok(),
on_mismatch: std::env::var("RUNNER_ON_MISMATCH").ok(),
no_warnings: std::env::var("RUNNER_NO_WARNINGS").ok(),
explain: std::env::var("RUNNER_EXPLAIN").ok(),
keep_going: std::env::var("RUNNER_KEEP_GOING").ok(),
kill_on_fail: std::env::var("RUNNER_KILL_ON_FAIL").ok(),
}
}
fn sources<'a>(
&'a self,
cli: CliSides<'a>,
config: Option<&'a LoadedConfig>,
) -> OverrideSources<'a> {
OverrideSources {
pm: SourceValue {
cli: cli.pm,
env: self.pm.as_deref(),
},
runner: SourceValue {
cli: cli.runner,
env: self.runner.as_deref(),
},
fallback: SourceValue {
cli: cli.fallback,
env: self.fallback.as_deref(),
},
on_mismatch: SourceValue {
cli: cli.on_mismatch,
env: self.on_mismatch.as_deref(),
},
no_warnings: ExplainSource {
cli: cli.diagnostics.no_warnings,
env: self.no_warnings.as_deref(),
},
explain: ExplainSource {
cli: cli.diagnostics.explain,
env: self.explain.as_deref(),
},
keep_going: ExplainSource {
cli: cli.failure.keep_going,
env: self.keep_going.as_deref(),
},
kill_on_fail: ExplainSource {
cli: cli.failure.kill_on_fail,
env: self.kill_on_fail.as_deref(),
},
config,
}
}
}
fn lenient_env_field(
field: &mut SourceValue<'_>,
var: &'static str,
warnings: &mut Vec<DetectionWarning>,
validate: impl Fn(&str) -> Result<()>,
) {
if field.cli.map(str::trim).is_some_and(|s| !s.is_empty()) {
return;
}
let Some(raw) = field.env.map(str::trim).filter(|s| !s.is_empty()) else {
return;
};
if let Err(err) = validate(raw) {
let sanitized = sanitize_raw_label(raw);
warnings.push(DetectionWarning::InvalidEnvOverride {
var,
raw: sanitized.clone(),
message: sanitize_error_message(raw, &sanitized, &format!("{err}")),
});
field.env = None;
}
}
fn sanitize_error_message(raw: &str, sanitized: &str, message: &str) -> String {
let escaped: String = raw.chars().flat_map(char::escape_debug).collect();
message.replace(raw, sanitized).replace(&escaped, sanitized)
}
const PM_SOURCE_NAMES: SourceNames = SourceNames {
cli: "--pm",
env: "RUNNER_PM",
example: "pnpm",
};
const RUNNER_SOURCE_NAMES: SourceNames = SourceNames {
cli: "--runner",
env: "RUNNER_RUNNER",
example: "just",
};
struct SourceNames {
cli: &'static str,
env: &'static str,
example: &'static str,
}
impl SourceNames {
fn decorate(&self, err: &anyhow::Error, raw: &str, origin: &OverrideOrigin) -> anyhow::Error {
let from_env = matches!(origin, OverrideOrigin::EnvVar);
let source = if from_env { self.env } else { self.cli };
let hint = if raw.contains('\n') || raw.contains('\r') {
let example = if from_env {
format!(
"$env:{}='{}' (quote the value in PowerShell)",
self.env, self.example
)
} else {
format!("{} {}", self.cli, self.example)
};
format!(
"\n hint: the value contains line breaks and looks like captured command \
output; pass a plain name instead, e.g. {example}"
)
} else {
String::new()
};
anyhow!("{source}: {err}{hint}")
}
}
fn parse_override<T, P, V, B>(
cli: Option<&str>,
env: Option<&str>,
names: &SourceNames,
parse: V,
build: B,
) -> Result<Option<T>>
where
V: Fn(&str) -> Result<P>,
B: Fn(P, OverrideOrigin) -> T,
{
if let Some(raw) = cli.map(str::trim).filter(|s| !s.is_empty()) {
let parsed =
parse(raw).map_err(|err| names.decorate(&err, raw, &OverrideOrigin::CliFlag))?;
return Ok(Some(build(parsed, OverrideOrigin::CliFlag)));
}
if let Some(raw) = env.map(str::trim).filter(|s| !s.is_empty()) {
let parsed =
parse(raw).map_err(|err| names.decorate(&err, raw, &OverrideOrigin::EnvVar))?;
return Ok(Some(build(parsed, OverrideOrigin::EnvVar)));
}
Ok(None)
}