use clap::{Parser, ValueEnum};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Parser)]
#[command(
name = "pycu",
about = "Check Python dependencies for updates on PyPI",
version // exposes --version using the version in Cargo.toml
)]
pub struct Cli {
#[arg(long)]
pub file: Option<PathBuf>,
#[arg(long)]
pub json: bool,
#[arg(short = 'u', long)]
pub upgrade: bool,
#[arg(long, default_value = "10")]
pub concurrency: usize,
#[arg(short = 't', long, value_name = "LEVEL", default_value = "latest")]
pub target: TargetLevel,
#[arg(long, value_name = "SCHEME", num_args = 0..=1, default_missing_value = "")]
pub set_color_scheme: Option<String>,
#[arg(long)]
pub self_update: bool,
#[arg(long)]
pub uninstall: bool,
}
#[derive(ValueEnum, Clone, PartialEq, Debug)]
pub enum TargetLevel {
Latest,
Major,
Minor,
Patch,
}
#[derive(ValueEnum, Serialize, Deserialize, Clone, PartialEq, Debug)]
#[serde(rename_all = "kebab-case")]
pub enum ColorScheme {
Default,
OkabeIto,
TrafficLight,
Severity,
HighContrast,
}
pub async fn run() -> anyhow::Result<()> {
let cli = Cli::parse();
if let Some(raw) = cli.set_color_scheme {
if raw.is_empty() {
crate::output::table::print_color_scheme_preview();
} else {
use clap::ValueEnum;
let scheme = ColorScheme::from_str(&raw, true).map_err(|e| anyhow::anyhow!(
"Unknown color scheme '{}'. {}\nRun `pycu --set-color-scheme` to see all options.",
raw, e
))?;
let config = crate::config::Config {
color_scheme: scheme.clone(),
};
crate::config::save(&config)?;
let path = crate::config::config_path().unwrap_or_else(|| PathBuf::from("config.toml"));
println!(
"Color scheme set to '{}' and saved to {}.",
raw,
path.display()
);
}
return Ok(());
}
if cli.self_update {
let client = crate::pypi::client::PypiClient::new()?.into_inner();
return crate::self_update::run(&client).await;
}
if cli.uninstall {
return crate::uninstall::run();
}
let config = match crate::config::load() {
Ok(Some(cfg)) => cfg,
Ok(None) | Err(_) => crate::config::first_run_setup()?,
};
let file_path = match cli.file {
Some(p) => p,
None => resolve_default_file()?,
};
if !file_path.exists() {
anyhow::bail!("File not found: {}", file_path.display());
}
eprintln!("Checking {}", file_path.display());
let parser = crate::parsers::detect_parser(&file_path)?;
let deps = parser.parse(&file_path)?;
if deps.is_empty() {
println!("No dependencies found.");
return Ok(());
}
let client = crate::pypi::client::PypiClient::new()?;
let all_updates = crate::version::compare::find_updates(deps, client, cli.concurrency).await?;
use crate::version::compare::BumpKind;
let filter_bump: Option<BumpKind> = match cli.target {
TargetLevel::Latest => None,
TargetLevel::Major => Some(BumpKind::Major),
TargetLevel::Minor => Some(BumpKind::Minor),
TargetLevel::Patch => Some(BumpKind::Patch),
};
let updates: Vec<_> = all_updates
.into_iter()
.filter(|u| filter_bump.as_ref().is_none_or(|b| &u.bump_kind == b))
.collect();
if cli.upgrade {
crate::output::table::print_table(&updates, false, &config.color_scheme);
let count = crate::upgrade::apply_upgrades(&file_path, &updates)?;
if count > 0 {
println!(
"{} package{} upgraded in {}.",
count,
if count == 1 { "" } else { "s" },
file_path.display()
);
}
return Ok(());
}
if cli.json {
crate::output::json::print_json(&updates)?;
} else {
crate::output::table::print_table(&updates, true, &config.color_scheme);
}
Ok(())
}
fn resolve_default_file() -> anyhow::Result<PathBuf> {
for name in &["pyproject.toml", "requirements.txt"] {
let p = PathBuf::from(name);
if p.exists() {
return Ok(p);
}
}
anyhow::bail!(
"No pyproject.toml or requirements.txt found in the current directory.\n\
Use --file to specify a path."
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_resolve_default_file_not_found() {
let result = resolve_default_file();
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("No pyproject.toml or requirements.txt found"));
}
}