use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "kebab-case")]
pub enum PipSource {
#[default]
UvLock,
Pyproject,
}
#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "kebab-case")]
pub enum PythonInstaller {
#[default]
Uv,
Pip,
}
#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq, Default)]
#[serde(rename_all = "kebab-case")]
pub enum NpmSource {
#[default]
PackageLock,
PackageJson,
}
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
#[serde(deny_unknown_fields)]
pub struct DependenciesConfig {
#[serde(default)]
pub pip: Vec<String>,
#[serde(default)]
pub pip_source: PipSource,
#[serde(default)]
pub python_installer: PythonInstaller,
#[serde(default)]
pub npm: Vec<String>,
#[serde(default)]
pub npm_source: NpmSource,
#[serde(default)]
pub gem: Vec<String>,
#[serde(default)]
pub cargo: Vec<String>,
#[serde(default)]
pub system: Vec<String>,
#[serde(default)]
pub eatmydata: bool,
}
impl DependenciesConfig {
pub const fn is_empty(&self) -> bool {
self.pip.is_empty()
&& self.npm.is_empty()
&& self.gem.is_empty()
&& self.cargo.is_empty()
&& self.system.is_empty()
}
pub fn effective_pip(&self, project_root: &Path) -> Result<Vec<String>> {
let from_project = self.project_python_reqs(project_root, uv_lock_pinned_deps)?;
Ok(self.merge_with_pip(from_project))
}
pub fn effective_pip_uv(
&self,
project_root: &Path,
export: impl FnOnce() -> Result<Vec<String>>,
) -> Result<Vec<String>> {
let from_project = self.project_python_reqs(project_root, |_lock| export())?;
Ok(self.merge_with_pip(from_project))
}
fn project_python_reqs(
&self,
project_root: &Path,
pinned: impl FnOnce(&Path) -> Result<Vec<String>>,
) -> Result<Vec<String>> {
let pyproject = project_root.join("pyproject.toml");
match self.pip_source {
PipSource::UvLock => {
let lock = project_root.join("uv.lock");
if lock.exists() {
pinned(&lock)
} else if pyproject_python_deps(&pyproject)?.is_empty() {
Ok(Vec::new())
} else {
anyhow::bail!(
"{} declares Python dependencies but {} does not exist; \
run `uv lock` to create it, or set `pip_source = \"pyproject\"` \
under [dependencies] to resolve the declared names at install time",
pyproject.display(),
lock.display(),
);
}
}
PipSource::Pyproject => pyproject_python_deps(&pyproject),
}
}
fn merge_with_pip(&self, from_project: Vec<String>) -> Vec<String> {
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut merged: Vec<String> = Vec::new();
for req in self.pip.iter().cloned().chain(from_project) {
let key = format!(
"{}[{}]",
normalized_distribution_name(&req),
requirement_extras(&req)
);
if seen.insert(key) {
merged.push(req);
}
}
merged
}
pub fn node_deps(&self, project_root: &Path) -> Result<Vec<String>> {
let manifest = project_root.join("package.json");
let names = package_json_deps(&manifest)?;
if names.is_empty() {
return Ok(names);
}
let lock = project_root.join("package-lock.json");
if self.npm_source == NpmSource::PackageLock && !lock.exists() {
anyhow::bail!(
"{} declares Node.js dependencies but {} does not exist; \
run `npm install --package-lock-only` to create it, or set \
`npm_source = \"package-json\"` under [dependencies] to resolve \
the declared ranges at install time",
manifest.display(),
lock.display(),
);
}
Ok(names)
}
}
pub fn package_json_deps(package_json: &Path) -> Result<Vec<String>> {
if !package_json.exists() {
return Ok(Vec::new());
}
let content = fs::read_to_string(package_json)
.with_context(|| format!("Failed to read {}", package_json.display()))?;
let root: serde_json::Value = serde_json::from_str(&content).map_err(|e| {
crate::exit_code::config_error(format!("Failed to parse {}: {e}", package_json.display()))
})?;
let mut names: Vec<String> = Vec::new();
for section in ["dependencies", "devDependencies", "optionalDependencies"] {
if let Some(map) = root.get(section).and_then(serde_json::Value::as_object) {
for name in map.keys() {
if !names.contains(name) {
names.push(name.clone());
}
}
}
}
Ok(names)
}
pub fn package_lock_pins(lock: &Path) -> Result<std::collections::BTreeMap<String, String>> {
let content =
fs::read_to_string(lock).with_context(|| format!("Failed to read {}", lock.display()))?;
let root: serde_json::Value = serde_json::from_str(&content).map_err(|e| {
crate::exit_code::config_error(format!("Failed to parse {}: {e}", lock.display()))
})?;
let mut pins = std::collections::BTreeMap::new();
let Some(packages) = root.get("packages").and_then(serde_json::Value::as_object) else {
return Ok(pins);
};
for (path, entry) in packages {
let Some(name) = path.strip_prefix("node_modules/") else {
continue;
};
if name.contains("/node_modules/") {
continue;
}
if let Some(version) = entry.get("version").and_then(serde_json::Value::as_str) {
pins.insert(name.to_string(), version.to_string());
}
}
Ok(pins)
}
pub fn installed_node_package_version(project_root: &Path, name: &str) -> Option<String> {
let manifest = project_root
.join("node_modules")
.join(name)
.join("package.json");
let content = fs::read_to_string(manifest).ok()?;
let root: serde_json::Value = serde_json::from_str(&content).ok()?;
root.get("version")
.and_then(serde_json::Value::as_str)
.map(str::to_string)
}
pub fn parse_uv_export(text: &str) -> Vec<String> {
text.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#') && !line.starts_with('-'))
.map(str::to_string)
.collect()
}
pub fn pyproject_python_deps(pyproject: &Path) -> Result<Vec<String>> {
if !pyproject.exists() {
return Ok(Vec::new());
}
let content = fs::read_to_string(pyproject)
.with_context(|| format!("Failed to read {}", pyproject.display()))?;
let root: toml::Value = toml::from_str(&content).map_err(|e| {
crate::exit_code::config_error(format!("Failed to parse {}: {e}", pyproject.display()))
})?;
let string_items = |v: &toml::Value| -> Vec<String> {
v.as_array()
.map(|items| {
items
.iter()
.filter_map(|i| i.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default()
};
let mut deps: Vec<String> = Vec::new();
if let Some(project) = root.get("project") {
if let Some(list) = project.get("dependencies") {
deps.extend(string_items(list));
}
if let Some(extras) = project
.get("optional-dependencies")
.and_then(toml::Value::as_table)
{
for list in extras.values() {
deps.extend(string_items(list));
}
}
}
if let Some(groups) = root
.get("dependency-groups")
.and_then(toml::Value::as_table)
{
for list in groups.values() {
deps.extend(string_items(list));
}
}
Ok(deps)
}
pub fn uv_lock_pinned_deps(lock: &Path) -> Result<Vec<String>> {
let content =
fs::read_to_string(lock).with_context(|| format!("Failed to read {}", lock.display()))?;
let root: toml::Value = toml::from_str(&content).map_err(|e| {
crate::exit_code::config_error(format!("Failed to parse {}: {e}", lock.display()))
})?;
let packages = root
.get("package")
.and_then(toml::Value::as_array)
.map_or(&[] as &[toml::Value], Vec::as_slice);
let mut pins: Vec<String> = Vec::new();
for pkg in packages {
let name = pkg
.get("name")
.and_then(toml::Value::as_str)
.with_context(|| format!("{}: package entry without a name", lock.display()))?;
let source = pkg.get("source").and_then(toml::Value::as_table);
let is_project =
source.is_some_and(|s| s.contains_key("editable") || s.contains_key("virtual"));
if is_project {
continue;
}
let is_registry = source.is_some_and(|s| s.contains_key("registry"));
if !is_registry {
anyhow::bail!(
"{}: package {name} has a source kind install-deps does not support \
(only registry packages and the project itself are understood)",
lock.display(),
);
}
let version = pkg
.get("version")
.and_then(toml::Value::as_str)
.with_context(|| format!("{}: package {name} has no version", lock.display()))?;
pins.push(format!("{name}=={version}"));
}
Ok(pins)
}
pub fn requirement_extras(requirement: &str) -> String {
let Some(open) = requirement.find('[') else {
return String::new();
};
let Some(close) = requirement[open..].find(']') else {
return String::new();
};
let mut extras: Vec<String> = requirement[open + 1..open + close]
.split(',')
.map(|e| e.trim().to_lowercase())
.filter(|e| !e.is_empty())
.collect();
extras.sort();
extras.join(",")
}
pub fn normalized_distribution_name(requirement: &str) -> String {
let name = requirement
.split(['[', '<', '>', '=', '!', '~', ';', ' ', '\t'])
.next()
.unwrap_or(requirement);
let mut normalized = String::with_capacity(name.len());
let mut prev_sep = false;
for c in name.chars() {
if matches!(c, '-' | '_' | '.') {
if !prev_sep {
normalized.push('-');
}
prev_sep = true;
} else {
normalized.extend(c.to_lowercase());
prev_sep = false;
}
}
normalized
}