mod aube;
mod bun;
mod bundler;
mod composer;
mod custom;
mod dart;
mod deno;
mod git_submodule;
mod go;
mod npm;
mod pip;
mod pnpm;
mod poetry;
mod uv;
mod yarn;
pub(crate) use aube::AubeDepsProvider;
pub(crate) use bun::BunDepsProvider;
pub(crate) use bundler::BundlerDepsProvider;
pub(crate) use composer::ComposerDepsProvider;
pub(crate) use custom::CustomDepsProvider;
pub(crate) use dart::DartDepsProvider;
pub(crate) use deno::DenoDepsProvider;
pub(crate) use git_submodule::GitSubmoduleDepsProvider;
pub(crate) use go::GoDepsProvider;
pub(crate) use npm::NpmDepsProvider;
pub(crate) use pip::PipDepsProvider;
pub(crate) use pnpm::PnpmDepsProvider;
pub(crate) use poetry::PoetryDepsProvider;
pub(crate) use uv::UvDepsProvider;
pub(crate) use yarn::YarnDepsProvider;
use std::path::{Path, PathBuf};
use glob::glob;
use crate::deps::rule::DepsProviderConfig;
use crate::task::task_source_checker::expand_glob_braces;
#[derive(Debug)]
pub(crate) struct ProviderBase {
pub(crate) id: String,
pub(crate) project_root: PathBuf,
pub(crate) config: DepsProviderConfig,
}
impl ProviderBase {
pub(crate) fn new(
id: impl Into<String>,
project_root: &Path,
config: DepsProviderConfig,
) -> Self {
Self {
id: id.into(),
project_root: project_root.to_path_buf(),
config,
}
}
pub(crate) fn is_auto(&self) -> bool {
self.config.auto
}
pub(crate) fn config_root(&self) -> PathBuf {
match &self.config.dir {
Some(dir) => self.project_root.join(dir),
None => self.project_root.clone(),
}
}
pub(crate) fn sources(&self, default: Vec<PathBuf>) -> Vec<PathBuf> {
self.config
.sources
.as_deref()
.map(|patterns| self.resolve_path_patterns(patterns, false))
.unwrap_or(default)
}
pub(crate) fn outputs(&self, default: Vec<PathBuf>) -> Vec<PathBuf> {
self.config
.outputs
.as_deref()
.map(|patterns| self.resolve_path_patterns(patterns, true))
.unwrap_or(default)
}
pub(crate) fn optional_outputs(&self, default: Vec<PathBuf>) -> Vec<PathBuf> {
if self.config.outputs.is_some() {
vec![]
} else {
default
}
}
pub(crate) fn output_rules_hash(&self) -> String {
let rules = serde_json::to_string(&(&self.config.dir, &self.config.outputs))
.expect("deps output rules should serialize");
crate::hash::hash_blake3_to_str(&rules)
}
fn resolve_path_patterns(&self, patterns: &[String], require_glob_match: bool) -> Vec<PathBuf> {
let mut paths = vec![];
for pattern in patterns {
let expanded = match expand_glob_braces(pattern) {
Ok(expanded) => expanded,
Err(err) => {
debug!("invalid deps path pattern {pattern:?}: {err}");
vec![pattern.clone()]
}
};
for pattern in expanded {
let path = PathBuf::from(&pattern);
let full_pattern = if path.is_relative() {
self.config_root().join(&pattern)
} else {
path
};
if pattern.contains('*') || pattern.contains('?') || pattern.contains('[') {
if let Ok(entries) = glob(full_pattern.to_string_lossy().as_ref()) {
let matches: Vec<_> = entries.flatten().collect();
if matches.is_empty() && require_glob_match {
paths.push(full_pattern);
} else {
paths.extend(matches);
}
} else if require_glob_match {
paths.push(full_pattern);
}
} else {
paths.push(full_pattern);
}
}
}
paths
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn path_overrides_distinguish_omitted_explicit_and_empty() {
let tmp = tempfile::tempdir().unwrap();
let default_source = tmp.path().join("default.lock");
let default_output = tmp.path().join("default-output");
let optional_output = tmp.path().join("optional-output");
let base = ProviderBase::new("test", tmp.path(), DepsProviderConfig::default());
assert_eq!(
base.sources(vec![default_source.clone()]),
vec![default_source.clone()]
);
assert_eq!(
base.outputs(vec![default_output.clone()]),
vec![default_output.clone()]
);
assert_eq!(
base.optional_outputs(vec![optional_output.clone()]),
vec![optional_output]
);
let explicit = ProviderBase::new(
"test",
tmp.path(),
DepsProviderConfig {
sources: Some(vec!["custom.lock".into()]),
outputs: Some(vec!["custom-output".into()]),
..Default::default()
},
);
assert_eq!(
explicit.sources(vec![default_source]),
vec![tmp.path().join("custom.lock")]
);
assert_eq!(
explicit.outputs(vec![default_output]),
vec![tmp.path().join("custom-output")]
);
assert!(explicit.optional_outputs(vec![]).is_empty());
let empty = ProviderBase::new(
"test",
tmp.path(),
DepsProviderConfig {
sources: Some(vec![]),
outputs: Some(vec![]),
..Default::default()
},
);
assert!(empty.sources(vec![tmp.path().join("ignored")]).is_empty());
assert!(empty.outputs(vec![tmp.path().join("ignored")]).is_empty());
assert!(
empty
.optional_outputs(vec![tmp.path().join("ignored")])
.is_empty()
);
}
#[test]
fn path_overrides_resolve_from_dir_and_expand_globs() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("project");
let work = root.join("packages/app");
fs::create_dir_all(work.join("inputs")).unwrap();
fs::write(work.join("inputs/a.lock"), "a").unwrap();
fs::write(work.join("inputs/b.lock"), "b").unwrap();
fs::write(work.join("inputs/a.json"), "a").unwrap();
fs::write(work.join("inputs/b.json"), "b").unwrap();
let absolute = tmp.path().join("absolute-output");
let base = ProviderBase::new(
"test",
&root,
DepsProviderConfig {
dir: Some("packages/app".into()),
sources: Some(vec!["inputs/*.lock".into()]),
outputs: Some(vec![absolute.to_string_lossy().into_owned()]),
..Default::default()
},
);
assert_eq!(
base.sources(vec![]),
vec![work.join("inputs/a.lock"), work.join("inputs/b.lock")]
);
assert_eq!(base.outputs(vec![]), vec![absolute]);
let bracket_pattern = ProviderBase::new(
"test",
&root,
DepsProviderConfig {
dir: Some("packages/app".into()),
sources: Some(vec!["inputs/[ab].lock".into()]),
..Default::default()
},
);
assert_eq!(
bracket_pattern.sources(vec![]),
vec![work.join("inputs/a.lock"), work.join("inputs/b.lock")]
);
let brace_pattern = ProviderBase::new(
"test",
&root,
DepsProviderConfig {
dir: Some("packages/app".into()),
sources: Some(vec!["inputs/{a,b}.json".into()]),
outputs: Some(vec!["outputs/{app,vendor}.js".into()]),
..Default::default()
},
);
assert_eq!(
brace_pattern.sources(vec![]),
vec![work.join("inputs/a.json"), work.join("inputs/b.json")]
);
assert_eq!(
brace_pattern.outputs(vec![]),
vec![work.join("outputs/app.js"), work.join("outputs/vendor.js")]
);
let output_pattern = ProviderBase::new(
"test",
&root,
DepsProviderConfig {
dir: Some("packages/app".into()),
outputs: Some(vec!["outputs/*.js".into()]),
..Default::default()
},
);
assert_eq!(
output_pattern.outputs(vec![]),
vec![work.join("outputs/*.js")]
);
fs::create_dir_all(work.join("outputs")).unwrap();
fs::write(work.join("outputs/app.js"), "output").unwrap();
assert_eq!(
output_pattern.outputs(vec![]),
vec![work.join("outputs/app.js")]
);
}
#[test]
fn output_rules_hash_tracks_declared_rules_not_glob_matches() {
let tmp = tempfile::tempdir().unwrap();
let config = DepsProviderConfig {
outputs: Some(vec!["dist/*.js".into()]),
..Default::default()
};
let base = ProviderBase::new("test", tmp.path(), config.clone());
let hash = base.output_rules_hash();
fs::create_dir_all(tmp.path().join("dist")).unwrap();
fs::write(tmp.path().join("dist/app.js"), "output").unwrap();
assert_eq!(base.output_rules_hash(), hash);
let omitted = ProviderBase::new("test", tmp.path(), DepsProviderConfig::default());
let empty = ProviderBase::new(
"test",
tmp.path(),
DepsProviderConfig {
outputs: Some(vec![]),
..Default::default()
},
);
let replacement = ProviderBase::new(
"test",
tmp.path(),
DepsProviderConfig {
outputs: Some(vec!["build/*.js".into()]),
..Default::default()
},
);
let different_dir = ProviderBase::new(
"test",
tmp.path(),
DepsProviderConfig {
dir: Some("package".into()),
..config
},
);
assert_ne!(omitted.output_rules_hash(), empty.output_rules_hash());
assert_ne!(hash, replacement.output_rules_hash());
assert_ne!(hash, different_dir.output_rules_hash());
}
}