use anyhow::Result;
use clap::Parser;
use colored::Colorize;
use indicatif::{ProgressBar, ProgressStyle};
use pcu::cli::Args;
use pcu::detector::ProjectDetector;
use pcu::global::{
generate_upgrade_commands, GlobalCheck, GlobalPackageDiscovery, UpgradeCommand,
};
use pcu::output::{GlobalTableRenderer, TableRenderer, UvPythonTableRenderer};
use pcu::parsers::{
CondaParser, DependencyParser, LockfileParser, PyProjectParser, RequirementsParser,
};
use pcu::pypi::PyPiClient;
use pcu::python::get_python_info;
use pcu::updater::FileUpdater;
use pcu::uv_python::{generate_uv_python_upgrade_commands, UvPythonDiscovery};
use check_updates_core::{DependencyCheck, DependencyResolver};
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
#[tokio::main]
async fn main() -> Result<()> {
let args = Args::parse();
if args.global {
run_global_mode(&args).await
} else {
run_project_mode(&args).await
}
}
async fn run_global_mode(args: &Args) -> Result<()> {
if args.update {
println!(
"Note: --update flag is ignored in global mode. Commands will be shown instead.\n"
);
}
let discovery = GlobalPackageDiscovery::new(args.pre_release);
let uv_python_discovery = UvPythonDiscovery::new();
let (packages, python_info, uv_python_checks) = tokio::join!(
async { discovery.discover() },
get_python_info(true),
async { uv_python_discovery.discover_and_check().await }
);
if let Some(py_info) = python_info {
let version_str = if let Some(ref latest) = py_info.latest {
if py_info.has_update() {
format!(
"Python {} ({} available)",
py_info.current,
latest.to_string().yellow()
)
} else {
format!("Python {} (latest)", py_info.current)
}
} else {
format!("Python {}", py_info.current)
};
println!("{version_str}\n");
}
if packages.is_empty() {
println!("No globally installed packages found.");
println!("Checked: uv tools, pipx, pip --user");
return Ok(());
}
let package_names: Vec<String> = packages
.iter()
.map(|p| p.name.clone())
.collect::<HashSet<_>>()
.into_iter()
.collect();
let pypi_client = PyPiClient::new(args.pre_release);
let progress_bar = ProgressBar::new(package_names.len() as u64);
progress_bar.set_style(
ProgressStyle::default_bar()
.template(
"{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta})",
)
.expect("valid progress template")
.progress_chars("#>-"),
);
let pb_clone = Arc::new(Mutex::new(progress_bar.clone()));
let result = pypi_client
.get_packages(&package_names, move |current, _total| {
let pb = pb_clone.lock().expect("lock poisoned");
pb.set_position(current as u64);
})
.await?;
progress_bar.finish_and_clear();
let package_infos = result.packages;
let fetch_errors = result.errors;
let mut checks: Vec<GlobalCheck> = Vec::new();
for package in packages {
if let Some(info) = package_infos.get(&package.name) {
let target = if args.minor {
info.versions
.iter()
.filter(|v| v.major == package.installed_version.major)
.max()
.cloned()
.unwrap_or_else(|| package.installed_version.clone())
} else {
info.latest.clone()
};
let has_update = target > package.installed_version;
checks.push(GlobalCheck {
package,
latest: target,
has_update,
});
}
}
let renderer = GlobalTableRenderer::new(true);
renderer.render(&checks);
if let Ok(uv_checks) = &uv_python_checks
&& !uv_checks.is_empty() {
println!();
let uv_renderer = UvPythonTableRenderer::new(true);
uv_renderer.render(uv_checks);
}
let mut commands = generate_upgrade_commands(&checks);
if let Ok(uv_checks) = &uv_python_checks {
commands.extend(generate_uv_python_upgrade_commands(uv_checks));
}
if !commands.is_empty() {
println!();
println!("To upgrade, run:\n");
for cmd in &commands {
match cmd {
UpgradeCommand::Command(c) => println!(" $ {c}"),
UpgradeCommand::Comment(c) => println!(" # {}", c.dimmed()),
}
}
}
if !fetch_errors.is_empty() {
println!();
println!("{}", "Packages not found on PyPI:".dimmed());
for error in &fetch_errors {
println!(" {}", error.dimmed());
}
}
Ok(())
}
async fn run_project_mode(args: &Args) -> Result<()> {
let project_path = args.project_path();
if !project_path.exists() {
anyhow::bail!("Project path does not exist: {project_path:?}");
}
if !project_path.is_dir() {
anyhow::bail!("Project path is not a directory: {project_path:?}");
}
let detector = ProjectDetector::new(project_path.clone());
let detected_files = detector.detect()?;
if detected_files.is_empty() {
println!("No dependency files found in {project_path:?}");
return Ok(());
}
let requirements_parser = RequirementsParser::new();
let pyproject_parser = PyProjectParser::new();
let conda_parser = CondaParser::new();
let lockfile_parser = LockfileParser::new();
let mut all_dependencies = Vec::new();
for detected in &detected_files {
let deps = if requirements_parser.can_parse(&detected.path) {
requirements_parser.parse(&detected.path)?
} else if pyproject_parser.can_parse(&detected.path) {
pyproject_parser.parse(&detected.path)?
} else if conda_parser.can_parse(&detected.path) {
conda_parser.parse(&detected.path)?
} else {
Vec::new()
};
all_dependencies.extend(deps);
}
if all_dependencies.is_empty() {
println!("No dependencies found in any files");
return Ok(());
}
let installed_versions = lockfile_parser.find_and_parse(&project_path)?;
let package_names: Vec<String> = all_dependencies
.iter()
.map(|d| d.name.clone())
.collect::<HashSet<_>>()
.into_iter()
.collect();
let pypi_client = PyPiClient::new(args.pre_release);
let progress_bar = ProgressBar::new(package_names.len() as u64);
progress_bar.set_style(
ProgressStyle::default_bar()
.template(
"{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta})",
)
.expect("valid progress template")
.progress_chars("#>-"),
);
let progress_bar_clone = Arc::new(Mutex::new(progress_bar.clone()));
let (pypi_result, python_info) = tokio::join!(
pypi_client.get_packages(&package_names, move |current, _total| {
let pb = progress_bar_clone.lock().expect("lock poisoned");
pb.set_position(current as u64);
}),
get_python_info(true)
);
let pypi_result = pypi_result?;
let package_infos = pypi_result.packages;
let fetch_errors = pypi_result.errors;
progress_bar.finish_and_clear();
if let Some(py_info) = python_info {
let version_str = if let Some(ref latest) = py_info.latest {
if py_info.has_update() {
format!(
"Python {} ({} available)",
py_info.current,
latest.to_string().yellow()
)
} else {
format!("Python {} (latest)", py_info.current)
}
} else {
format!("Python {}", py_info.current)
};
println!("{version_str}\n");
}
if !fetch_errors.is_empty() {
println!("{}", "Packages not found on PyPI:".dimmed());
for error in &fetch_errors {
println!(" {}", error.dimmed());
}
println!();
}
let resolver = DependencyResolver::new();
let mut checks: Vec<DependencyCheck> = Vec::new();
for dependency in &all_dependencies {
if let Some(package_info) = package_infos.get(&dependency.name) {
let installed = installed_versions.get(&dependency.name);
let check = resolver.resolve(dependency, package_info, installed);
checks.push(check);
}
}
let mut seen: HashSet<String> = HashSet::new();
let deduplicated: Vec<&DependencyCheck> = checks
.iter()
.filter(|c| {
if !c.has_update() {
return false;
}
let key = format!(
"{}:{}",
c.dependency.name,
c.target.as_ref().map(std::string::ToString::to_string).unwrap_or_default()
);
seen.insert(key)
})
.collect();
let renderer = TableRenderer::new(true);
renderer.render_deduped(&deduplicated);
if args.update {
let updater = FileUpdater::new();
let result = updater.apply_updates(&checks, args.minor, args.force)?;
println!();
if !result.modified_files.is_empty() {
println!("Updated {} file(s):", result.modified_files.len());
for file in &result.modified_files {
println!(" - {}", file.display());
}
}
result.print_summary();
} else if !deduplicated.is_empty() {
println!();
println!(
"Run {} to upgrade patch, {} to upgrade patch+minors, and {} to force upgrade all.",
"-u".cyan(),
"-um".cyan(),
"-uf".cyan()
);
}
Ok(())
}