use std::collections::{BTreeMap, HashSet};
use std::fmt::Debug;
use std::path::{Path, PathBuf};
use std::sync::{Arc, LazyLock, Mutex};
use eyre::{Result, bail};
use crate::config::{Config, Settings};
use crate::env;
use crate::file::display_filename;
pub(crate) use engine::{DepsEngine, DepsOptions, DepsStepResult};
pub(crate) use rule::DepsConfig;
pub(crate) use rule::DepsTemplateContext;
pub(crate) mod deps_ordering;
mod engine;
pub(crate) mod providers;
mod rule;
pub(crate) mod state;
#[derive(Debug, Clone)]
pub(crate) enum FreshnessResult {
Fresh,
OutputsMissing,
Stale(String),
NoSources,
Forced,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum DepsProviderApplicability {
Applicable,
Inactive(String),
}
impl DepsProviderApplicability {
pub(crate) fn require_file(path: &Path) -> Self {
if path.is_file() {
Self::Applicable
} else {
let name = display_filename(path);
Self::Inactive(format!("missing {name}"))
}
}
pub(crate) fn require_any_file(paths: &[&Path]) -> Self {
if paths.iter().any(|path| path.is_file()) {
Self::Applicable
} else {
let names = paths
.iter()
.map(display_filename)
.collect::<Vec<_>>()
.join(" or ");
Self::Inactive(format!("missing {names}"))
}
}
pub(crate) fn require_nonempty_file(path: &Path) -> Self {
let name = display_filename(path);
if !path.is_file() {
return Self::Inactive(format!("missing {name}"));
}
if path.metadata().map(|m| m.len() > 0).unwrap_or(false) {
Self::Applicable
} else {
Self::Inactive(format!("empty {name}"))
}
}
pub(crate) fn require_run(run: Option<&str>) -> Self {
match run {
Some(run) if !run.trim().is_empty() => Self::Applicable,
Some(_) => Self::Inactive("run command is empty".to_string()),
None => Self::Inactive("missing run command".to_string()),
}
}
}
impl FreshnessResult {
pub(crate) fn is_fresh(&self) -> bool {
matches!(self, FreshnessResult::Fresh | FreshnessResult::NoSources)
}
pub(crate) fn reason(&self) -> &str {
match self {
FreshnessResult::Fresh => "outputs are up to date",
FreshnessResult::OutputsMissing => "outputs missing",
FreshnessResult::Stale(reason) => reason,
FreshnessResult::NoSources => "no sources to check",
FreshnessResult::Forced => "forced",
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct DepsCommand {
pub inline: bool,
pub program: String,
pub args: Vec<String>,
pub env: BTreeMap<String, String>,
pub cwd: Option<PathBuf>,
pub description: String,
}
impl DepsCommand {
pub(crate) fn from_string(
run: &str,
project_root: &Path,
config: &rule::DepsProviderConfig,
) -> Result<Self> {
if run.trim().is_empty() {
bail!("deps run command cannot be empty");
}
let shell = Settings::get().default_inline_shell()?;
let (program, shell_args) = shell.split_first().ok_or_else(|| {
eyre::eyre!("default inline shell is empty; check unix_default_inline_shell_args / windows_default_inline_shell_args")
})?;
let mut args: Vec<String> = shell_args.to_vec();
args.push(run.to_string());
Ok(Self {
inline: true,
program: program.to_string(),
args,
env: config.env.clone(),
cwd: config
.dir
.as_ref()
.map(|d| project_root.join(d))
.or_else(|| Some(project_root.to_path_buf())),
description: config
.description
.clone()
.unwrap_or_else(|| run.to_string()),
})
}
pub(crate) fn freshness_hash(&self) -> String {
fn update(hasher: &mut blake3::Hasher, bytes: &[u8]) {
hasher.update(&(bytes.len() as u64).to_le_bytes());
hasher.update(bytes);
}
let mut hasher = blake3::Hasher::new();
update(&mut hasher, &[u8::from(self.inline)]);
update(&mut hasher, self.program.as_bytes());
update(&mut hasher, &(self.args.len() as u64).to_le_bytes());
for arg in &self.args {
update(&mut hasher, arg.as_bytes());
}
update(&mut hasher, &(self.env.len() as u64).to_le_bytes());
for (key, value) in &self.env {
update(&mut hasher, key.as_bytes());
update(&mut hasher, value.as_bytes());
}
match &self.cwd {
Some(cwd) => {
update(&mut hasher, &[1]);
update(&mut hasher, cwd.as_os_str().as_encoded_bytes());
}
None => update(&mut hasher, &[0]),
}
hasher.finalize().to_hex().to_string()
}
}
pub(crate) trait DepsProvider: Debug + Send + Sync {
fn base(&self) -> &providers::ProviderBase;
fn rendered(&self, effective_env: &BTreeMap<String, String>) -> Result<Box<dyn DepsProvider>> {
let base = self.base();
let config = base.config.rendered(effective_env)?;
crate::deps::DepsEngine::build_provider(&base.id, &base.project_root, config)
.ok_or_else(|| eyre::eyre!("unknown deps provider '{}'", base.id))
}
fn id(&self) -> &str {
&self.base().id
}
fn sources(&self) -> Vec<PathBuf>;
fn outputs(&self) -> Vec<PathBuf>;
fn optional_outputs(&self) -> Vec<PathBuf> {
vec![]
}
fn install_command(&self) -> Result<DepsCommand>;
fn install_command_with_env(
&self,
effective_env: &BTreeMap<String, String>,
) -> Result<DepsCommand> {
self.rendered(effective_env)?.install_command()
}
fn applicability(&self) -> DepsProviderApplicability;
fn is_auto(&self) -> bool {
self.base().is_auto()
}
fn depends(&self) -> Vec<String> {
self.base().config.depends.clone()
}
fn timeout(&self) -> Option<std::time::Duration> {
self.base().config.timeout.as_deref().and_then(|t| {
match crate::duration::parse_duration(t) {
Ok(d) => Some(d),
Err(err) => {
warn!("deps: {}: invalid timeout {t:?}: {err}", self.id());
None
}
}
})
}
fn add_command(&self, _packages: &[&str], _dev: bool) -> Result<DepsCommand> {
bail!("provider '{}' does not support adding packages", self.id())
}
fn remove_command(&self, _packages: &[&str]) -> Result<DepsCommand> {
bail!(
"provider '{}' does not support removing packages",
self.id()
)
}
}
pub(crate) fn notify_if_stale(config: &Arc<Config>, effective_env: &BTreeMap<String, String>) {
if *env::__MISE_SHIM || Settings::get().quiet {
return;
}
if !Settings::get().status.show_deps_stale {
return;
}
let Ok(engine) = DepsEngine::new(config) else {
return;
};
let stale = engine.check_staleness(effective_env);
if !stale.is_empty() {
let providers: Vec<String> = stale
.iter()
.map(|(id, reason)| format!("{id} ({reason})"))
.collect();
let summary = providers.join(", ");
warn!("deps: {summary} — run `mise deps`");
}
}
static STALE_OUTPUTS: LazyLock<Mutex<HashSet<PathBuf>>> =
LazyLock::new(|| Mutex::new(HashSet::new()));
pub(crate) fn mark_output_stale(path: PathBuf) {
if let Ok(mut set) = STALE_OUTPUTS.lock() {
set.insert(path);
}
}
pub(crate) fn is_output_stale(path: &PathBuf) -> bool {
STALE_OUTPUTS
.lock()
.map(|set| set.contains(path))
.unwrap_or(false)
}
pub(crate) fn clear_output_stale(path: &PathBuf) {
if let Ok(mut set) = STALE_OUTPUTS.lock() {
set.remove(path);
}
}
pub(crate) fn detect_applicable_providers(project_root: &Path) -> Vec<String> {
use DepsProviderApplicability::Applicable;
use providers::*;
use rule::DepsProviderConfig;
let default_config = DepsProviderConfig::default();
let mut applicable = Vec::new();
let checks: &[(&str, Box<dyn DepsProvider>)] = &[
(
"npm",
Box::new(NpmDepsProvider::new(project_root, default_config.clone())),
),
(
"yarn",
Box::new(YarnDepsProvider::new(project_root, default_config.clone())),
),
(
"pnpm",
Box::new(PnpmDepsProvider::new(project_root, default_config.clone())),
),
(
"bun",
Box::new(BunDepsProvider::new(project_root, default_config.clone())),
),
(
"deno",
Box::new(DenoDepsProvider::new(project_root, default_config.clone())),
),
(
"aube",
Box::new(AubeDepsProvider::new(project_root, default_config.clone())),
),
(
"go",
Box::new(GoDepsProvider::new(project_root, default_config.clone())),
),
(
"pip",
Box::new(PipDepsProvider::new(project_root, default_config.clone())),
),
(
"poetry",
Box::new(PoetryDepsProvider::new(
project_root,
default_config.clone(),
)),
),
(
"uv",
Box::new(UvDepsProvider::new(project_root, default_config.clone())),
),
(
"bundler",
Box::new(BundlerDepsProvider::new(
project_root,
default_config.clone(),
)),
),
(
"composer",
Box::new(ComposerDepsProvider::new(
project_root,
default_config.clone(),
)),
),
(
"git-submodule",
Box::new(GitSubmoduleDepsProvider::new(
project_root,
default_config.clone(),
)),
),
];
for (name, provider) in checks {
if matches!(provider.applicability(), Applicable) {
applicable.push(name.to_string());
}
}
applicable
}
pub(crate) fn create_provider(
ecosystem: &str,
project_root: &Path,
config: Option<&crate::config::Config>,
) -> Result<Box<dyn DepsProvider>> {
let mut configured = None;
if let Some(config) = config {
for cf in config.config_files.values() {
if let Some(deps_config) = cf.deps_config()?
&& let Some(provider_config) = deps_config.providers.get(ecosystem)
{
configured = Some((cf.config_root(), provider_config.clone()));
break;
}
}
}
let (provider_root, provider_config) = configured.unwrap_or_else(|| {
(
project_root.to_path_buf(),
rule::DepsProviderConfig::default(),
)
});
DepsEngine::build_provider(ecosystem, &provider_root, provider_config)
.ok_or_else(|| eyre::eyre!("unknown deps provider '{ecosystem}'"))
}
#[cfg(test)]
mod tests {
use super::*;
fn command() -> DepsCommand {
DepsCommand {
inline: true,
program: "sh".to_string(),
args: vec!["-c".to_string(), "echo first".to_string()],
env: BTreeMap::from([("MODE".to_string(), "debug".to_string())]),
cwd: Some(PathBuf::from("project")),
description: "first description".to_string(),
}
}
#[test]
fn deps_command_freshness_hash_tracks_execution_inputs() {
let original = command();
let mut changed = original.clone();
changed.inline = !original.inline;
assert_ne!(original.freshness_hash(), changed.freshness_hash());
assert_eq!(original.freshness_hash(), command().freshness_hash());
let mut changed = command();
changed.program = "bash".to_string();
assert_ne!(original.freshness_hash(), changed.freshness_hash());
let mut changed = command();
changed.args.push("extra".to_string());
assert_ne!(original.freshness_hash(), changed.freshness_hash());
let mut changed = command();
changed
.env
.insert("MODE".to_string(), "release".to_string());
assert_ne!(original.freshness_hash(), changed.freshness_hash());
let mut changed = command();
changed.cwd = Some(PathBuf::from("other-project"));
assert_ne!(original.freshness_hash(), changed.freshness_hash());
let mut changed = command();
changed.description = "cosmetic change".to_string();
assert_eq!(original.freshness_hash(), changed.freshness_hash());
}
}