From 979566c1cc1e23f92146a2c2b31479cdfb7dc44d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Motheu?= <66940960+Logic-py@users.noreply.github.com> Date: Mon, 9 Mar 2026 12:59:12 +0100 Subject: [PATCH 1/2] feat: add persistent color scheme support with 5 built-in palettes --- CHANGELOG.md | 19 +++- Cargo.lock | 59 ++++++++++++ Cargo.toml | 2 + src/cli.rs | 57 +++++++++++- src/config.rs | 153 +++++++++++++++++++++++++++++++ src/main.rs | 1 + src/output/table.rs | 214 +++++++++++++++++++++++++++++++++++++------- src/uninstall.rs | 23 ++++- 8 files changed, 494 insertions(+), 34 deletions(-) create mode 100644 src/config.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b940a5..1d716e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.1.0] - 2026-03-09 + +### Added + +- Five built-in color schemes for table output, selectable and persisted per user: + - `default` - GitHub-style SemVer severity (`#D73A49` / `#0366D6` / `#28A745`) + - `okabe-ito` - Color-blind safe Okabe–Ito palette (`#E69F00` / `#0072B2` / `#009E73`) + - `traffic-light` - Classic red/yellow/green (`#E74C3C` / `#F1C40F` / `#2ECC71`) + - `severity` - Monitoring/observability style (`#8E44AD` / `#3498DB` / `#95A5A6`) + - `high-contrast` - Maximum distinction, color-blind safe (`#CC79A7` / `#0072B2` / `#F0E442`) +- All colors rendered with true-color (24-bit) escape codes for exact hex fidelity +- `--set-color-scheme` flag: run without a value to preview all schemes visually, or pass a scheme name to save it permanently +- Color scheme preference persisted to `~/.config/pycu/config.toml` (Linux/macOS) or `%APPDATA%\pycu\config.toml` (Windows) +- First-run interactive prompt to choose a color scheme on initial install +- `--uninstall` now also removes the `pycu/` config directory + ## [1.0.0] - 2026-03-08 ### Added @@ -26,6 +42,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Progress bar during PyPI lookups - Install scripts for Linux/macOS (`install.sh`) and Windows (`install.ps1`) -[Unreleased]: https://github.com/Logic-py/python-check-updates/compare/1.0.0...HEAD +[Unreleased]: https://github.com/Logic-py/python-check-updates/compare/1.1.0...HEAD +[1.1.0]: https://github.com/Logic-py/python-check-updates/compare/1.0.0...1.1.0 [1.0.0]: https://github.com/Logic-py/python-check-updates/releases/tag/1.0.0 diff --git a/Cargo.lock b/Cargo.lock index 1f896d3..2e7ddcf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -324,6 +324,27 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -363,6 +384,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "filetime" version = "0.2.27" @@ -979,6 +1006,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "owo-colors" version = "4.3.0" @@ -1060,6 +1093,7 @@ version = "1.0.1" dependencies = [ "anyhow", "clap", + "dirs", "flate2", "futures", "indicatif", @@ -1070,6 +1104,7 @@ dependencies = [ "serde_json", "sha2", "tar", + "tempfile", "tokio", "toml", "wiremock", @@ -1185,6 +1220,17 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + [[package]] name = "regex" version = "1.12.3" @@ -1563,6 +1609,19 @@ dependencies = [ "xattr", ] +[[package]] +name = "tempfile" +version = "3.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" diff --git a/Cargo.toml b/Cargo.toml index 491b339..f36e93e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ path = "src/main.rs" [dependencies] anyhow = "1" +dirs = "6" clap = { version = "4", features = ["derive"] } flate2 = "1" futures = "0.3" @@ -34,4 +35,5 @@ toml = "1" zip = { version = "8.2.0", default-features = false, features = ["deflate"] } [dev-dependencies] +tempfile = "3" wiremock = "0.6" diff --git a/src/cli.rs b/src/cli.rs index e1c1fb3..c343607 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,4 +1,5 @@ use clap::{Parser, ValueEnum}; +use serde::{Deserialize, Serialize}; use std::path::PathBuf; #[derive(Parser)] @@ -28,6 +29,12 @@ pub struct Cli { #[arg(short = 't', long, value_name = "LEVEL", default_value = "latest")] pub target: TargetLevel, + /// Preview all color schemes, or set one persistently. + /// Without a value: show all schemes visually. + /// With a value: save that scheme and exit. + #[arg(long, value_name = "SCHEME", num_args = 0..=1, default_missing_value = "")] + pub set_color_scheme: Option, + /// Update pycu itself to the latest release #[arg(long)] pub self_update: bool, @@ -49,9 +56,49 @@ pub enum TargetLevel { Patch, } +#[derive(ValueEnum, Serialize, Deserialize, Clone, PartialEq, Debug)] +#[serde(rename_all = "kebab-case")] +pub enum ColorScheme { + /// #D73A49 / #0366D6 / #28A745 - GitHub-style SemVer severity (default) + Default, + /// #E69F00 / #0072B2 / #009E73 - Okabe–Ito, color-blind safe + OkabeIto, + /// #E74C3C / #F1C40F / #2ECC71 - traffic-light (red/yellow/green) + TrafficLight, + /// #8E44AD / #3498DB / #95A5A6 - monitoring style (purple/blue/gray) + Severity, + /// #CC79A7 / #0072B2 / #F0E442 - maximum distinction, color-blind safe + HighContrast, +} + pub async fn run() -> anyhow::Result<()> { let cli = Cli::parse(); + if let Some(raw) = cli.set_color_scheme { + if raw.is_empty() { + // --set-color-scheme used without a value: show the preview + crate::output::table::print_color_scheme_preview(); + } else { + // --set-color-scheme : parse, save, confirm + 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(()); + } + // Self-update is independent of any project file if cli.self_update { let client = crate::pypi::client::PypiClient::new()?.into_inner(); @@ -62,6 +109,12 @@ pub async fn run() -> anyhow::Result<()> { return crate::uninstall::run(); } + // Load persisted config, running first-run setup if no config exists or is unreadable + 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()?, @@ -97,7 +150,7 @@ pub async fn run() -> anyhow::Result<()> { .collect(); if cli.upgrade { - crate::output::table::print_table(&updates, false); + crate::output::table::print_table(&updates, false, &config.color_scheme); let count = crate::upgrade::apply_upgrades(&file_path, &updates)?; if count > 0 { println!( @@ -113,7 +166,7 @@ pub async fn run() -> anyhow::Result<()> { if cli.json { crate::output::json::print_json(&updates)?; } else { - crate::output::table::print_table(&updates, true); + crate::output::table::print_table(&updates, true, &config.color_scheme); } Ok(()) diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..bd34f06 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,153 @@ +use std::fs; +use std::io::{self, Write}; +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +use crate::cli::ColorScheme; + +#[derive(Serialize, Deserialize, PartialEq, Debug)] +pub struct Config { + pub color_scheme: ColorScheme, +} + +impl Default for Config { + fn default() -> Self { + Self { + color_scheme: ColorScheme::Default, + } + } +} + +pub fn config_path() -> Option { + dirs::config_dir().map(|d| d.join("pycu").join("config.toml")) +} + +/// Load config from disk. Returns `None` if the file doesn't exist yet (first run). +pub fn load() -> Result> { + let path = match config_path() { + Some(p) => p, + None => return Ok(Some(Config::default())), + }; + + if !path.exists() { + return Ok(None); + } + + let contents = fs::read_to_string(&path) + .with_context(|| format!("Failed to read config file: {}", path.display()))?; + let config: Config = toml::from_str(&contents) + .with_context(|| format!("Failed to parse config file: {}", path.display()))?; + Ok(Some(config)) +} + +pub fn save(config: &Config) -> Result<()> { + let path = match config_path() { + Some(p) => p, + None => anyhow::bail!("Could not determine config directory"), + }; + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create config directory: {}", parent.display()))?; + } + + let contents = toml::to_string(config).context("Failed to serialize config")?; + fs::write(&path, contents) + .with_context(|| format!("Failed to write config file: {}", path.display()))?; + Ok(()) +} + +/// Interactive first-run prompt. Shows the color scheme preview, asks the user to pick, +/// saves the choice, and returns the chosen `Config`. +pub fn first_run_setup() -> Result { + crate::output::table::print_color_scheme_preview(); + + print!( + "Choose a color scheme [default/okabe-ito/traffic-light/severity/high-contrast]\n(press Enter for default): " + ); + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + let input = input.trim().to_lowercase(); + + let color_scheme = match input.as_str() { + "okabe-ito" => ColorScheme::OkabeIto, + "traffic-light" => ColorScheme::TrafficLight, + "severity" => ColorScheme::Severity, + "high-contrast" => ColorScheme::HighContrast, + _ => ColorScheme::Default, + }; + + let config = Config { color_scheme }; + save(&config)?; + + let path = config_path().unwrap_or_else(|| PathBuf::from("config.toml")); + println!("Color scheme saved to {}.", path.display()); + println!("Run `pycu --set-color-scheme ` at any time to change it."); + println!(); + + Ok(config) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_config_default_is_default_scheme() { + let cfg = Config::default(); + assert_eq!(cfg.color_scheme, ColorScheme::Default); + } + + #[test] + fn test_config_roundtrip_default() { + let cfg = Config { + color_scheme: ColorScheme::Default, + }; + let s = toml::to_string(&cfg).unwrap(); + let back: Config = toml::from_str(&s).unwrap(); + assert_eq!(back.color_scheme, ColorScheme::Default); + } + + #[test] + fn test_config_roundtrip_all_schemes() { + for scheme in [ + ColorScheme::OkabeIto, + ColorScheme::TrafficLight, + ColorScheme::Severity, + ColorScheme::HighContrast, + ] { + let cfg = Config { + color_scheme: scheme.clone(), + }; + let s = toml::to_string(&cfg).unwrap(); + let back: Config = toml::from_str(&s).unwrap(); + assert_eq!(back.color_scheme, scheme); + } + } + + #[test] + fn test_nonexistent_path_has_no_file() { + let fake_path = PathBuf::from("/nonexistent/pycu/config.toml"); + assert!(!fake_path.exists()); + } + + #[test] + fn test_save_and_load_roundtrip() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("config.toml"); + + let cfg = Config { + color_scheme: ColorScheme::OkabeIto, + }; + let contents = toml::to_string(&cfg).unwrap(); + std::fs::write(&path, contents).unwrap(); + + let back: Config = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(back.color_scheme, ColorScheme::OkabeIto); + } +} diff --git a/src/main.rs b/src/main.rs index 807071d..050b922 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ mod cli; +mod config; mod output; mod parsers; mod pypi; diff --git a/src/output/table.rs b/src/output/table.rs index 3f791e0..6dc78be 100644 --- a/src/output/table.rs +++ b/src/output/table.rs @@ -1,9 +1,52 @@ use owo_colors::OwoColorize; +use crate::cli::ColorScheme; use crate::version::compare::Update; use crate::version::constraint::{extract_base_version, get_prefix}; -pub fn print_table(updates: &[Update], summary: bool) { +/// RGB triple for a single bump level. +struct Palette { + major: (u8, u8, u8), + minor: (u8, u8, u8), + patch: (u8, u8, u8), +} + +fn palette_for(scheme: &ColorScheme) -> Palette { + match scheme { + // GitHub-style SemVer severity + ColorScheme::Default => Palette { + major: (215, 58, 73), // #D73A49 red + minor: (3, 102, 214), // #0366D6 blue + patch: (40, 167, 69), // #28A745 green + }, + // Okabe–Ito color-blind safe + ColorScheme::OkabeIto => Palette { + major: (230, 159, 0), // #E69F00 orange + minor: (0, 114, 178), // #0072B2 blue + patch: (0, 158, 115), // #009E73 teal + }, + // Traffic-light + ColorScheme::TrafficLight => Palette { + major: (231, 76, 60), // #E74C3C red + minor: (241, 196, 15), // #F1C40F yellow + patch: (46, 204, 113), // #2ECC71 green + }, + // Monitoring / logging severity + ColorScheme::Severity => Palette { + major: (142, 68, 173), // #8E44AD purple + minor: (52, 152, 219), // #3498DB blue + patch: (149, 165, 166), // #95A5A6 gray + }, + // High-contrast accessibility + ColorScheme::HighContrast => Palette { + major: (204, 121, 167), // #CC79A7 magenta + minor: (0, 114, 178), // #0072B2 blue + patch: (240, 228, 66), // #F0E442 yellow + }, + } +} + +pub fn print_table(updates: &[Update], summary: bool, color_scheme: &ColorScheme) { if updates.is_empty() { println!("All dependencies are up to date."); return; @@ -26,7 +69,8 @@ pub fn print_table(updates: &[Update], summary: bool) { for u in updates { let name_padded = format!("{:width$}", u.current, width = curr_w); - let colored_new = color_updated_constraint(&u.current, &u.latest, &u.updated_constraint); + let colored_new = + color_updated_constraint(&u.current, &u.latest, &u.updated_constraint, color_scheme); println!( "{} {} {} {}", name_padded.bold(), @@ -47,13 +91,88 @@ pub fn print_table(updates: &[Update], summary: bool) { } } -/// Color the updated constraint based on which version component changed: -/// - Major (X): new version is red -/// - Minor (Y): X. is plain, Y.Z... is blue -/// - Patch (Z): X.Y. is plain, Z... is green +/// Print a visual preview of every available color scheme. +/// Called by `--list-color-schemes` and on first run. +pub fn print_color_scheme_preview() { + // (cli name, scheme, description, [major label, minor label, patch label]) + let schemes: &[(&str, ColorScheme, &str, [&str; 3])] = &[ + ( + "default", + ColorScheme::Default, + "SemVer severity - GitHub style", + ["red #D73A49", "blue #0366D6", "green #28A745"], + ), + ( + "okabe-ito", + ColorScheme::OkabeIto, + "Color-blind safe - Okabe–Ito palette", + ["orange #E69F00", "blue #0072B2", "teal #009E73"], + ), + ( + "traffic-light", + ColorScheme::TrafficLight, + "Traffic-light - common CI/dashboard model", + ["red #E74C3C", "yellow #F1C40F", "green #2ECC71"], + ), + ( + "severity", + ColorScheme::Severity, + "Monitoring style - Datadog/Grafana inspired", + ["purple #8E44AD", "blue #3498DB", "gray #95A5A6"], + ), + ( + "high-contrast", + ColorScheme::HighContrast, + "High-contrast accessibility - color-blind safe", + ["magenta #CC79A7", "blue #0072B2", "yellow #F0E442"], + ), + ]; + + // Sample rows: (current, latest, updated_constraint, bump label) + let samples: &[(&str, &str, &str, &str)] = &[ + ("1.0.0", "2.0.0", "2.0.0", "major"), + ("1.0.0", "1.1.0", "1.1.0", "minor"), + ("1.0.0", "1.0.1", "1.0.1", "patch"), + ]; + + println!(); + println!( + "{}", + "Available color schemes (use --set-color-scheme ):".bold() + ); + + for (name, scheme, description, color_labels) in schemes { + println!(); + println!(" {} - {}", name.bold().underline(), description.dimmed()); + for (i, (current, latest, updated, bump)) in samples.iter().enumerate() { + let colored = color_updated_constraint(current, latest, updated, scheme); + let label_text = format!("{} ({})", bump, color_labels[i]); + let label = label_text.dimmed(); + println!( + " {} {} {} {} {}", + "my-package".bold(), + current.dimmed(), + "→".cyan(), + colored, + label, + ); + } + } + println!(); +} + +/// Color the updated constraint based on which version component changed. +/// +/// The exact RGB color for each bump level comes from the active `ColorScheme`'s palette, +/// applied via true-color escape codes so the hex values are honored precisely. /// /// For compound constraints (`>=new, String { +fn color_updated_constraint( + current: &str, + latest: &str, + updated_constraint: &str, + color_scheme: &ColorScheme, +) -> String { let old_base = match extract_base_version(current) { Some(v) => v, None => return updated_constraint.to_string(), @@ -71,7 +190,6 @@ fn color_updated_constraint(current: &str, latest: &str, updated_constraint: &st // Build the colored lower-bound portion (prefix + colored version) let lower_part = if updated_constraint.contains(',') { - // Compound: first component is ">=new_version" updated_constraint .split_once(',') .map_or(updated_constraint, |(before, _)| before) @@ -80,16 +198,22 @@ fn color_updated_constraint(current: &str, latest: &str, updated_constraint: &st }; let prefix = get_prefix(lower_part); + let p = palette_for(color_scheme); let colored_version = match diff_idx { - 0 => latest.red().to_string(), + 0 => { + let (r, g, b) = p.major; + latest.truecolor(r, g, b).to_string() + } 1 => { + let (r, g, b) = p.minor; let plain = format!("{}.", new_parts[0]); - let colored = new_parts[1..].join(".").blue().to_string(); + let colored = new_parts[1..].join(".").truecolor(r, g, b).to_string(); format!("{}{}", plain, colored) } 2 => { + let (r, g, b) = p.patch; let plain = format!("{}.{}.", new_parts[0], new_parts[1]); - let colored = new_parts[2..].join(".").green().to_string(); + let colored = new_parts[2..].join(".").truecolor(r, g, b).to_string(); format!("{}{}", plain, colored) } _ => latest.to_string(), @@ -108,6 +232,7 @@ fn color_updated_constraint(current: &str, latest: &str, updated_constraint: &st #[cfg(test)] mod tests { use super::*; + use crate::cli::ColorScheme; use crate::version::compare::{BumpKind, Update}; fn make_update( @@ -128,8 +253,8 @@ mod tests { #[test] fn test_print_table_empty() { - print_table(&[], false); - print_table(&[], true); + print_table(&[], false, &ColorScheme::Default); + print_table(&[], true, &ColorScheme::Default); } #[test] @@ -144,13 +269,19 @@ mod tests { ), make_update("pydantic", ">=1.0.0", "2.0.0", ">=2.0.0", BumpKind::Major), ]; - print_table(&updates, true); - print_table(&updates, false); + for scheme in &[ + ColorScheme::Default, + ColorScheme::OkabeIto, + ColorScheme::TrafficLight, + ColorScheme::Severity, + ColorScheme::HighContrast, + ] { + print_table(&updates, true, scheme); + } } #[test] fn test_print_table_single_package_summary() { - // Exercises the `n == 1` branch: "1 package can be updated." let updates = vec![make_update( "fastapi", ">=0.109.0", @@ -158,52 +289,75 @@ mod tests { ">=0.135.1", BumpKind::Minor, )]; - print_table(&updates, true); + print_table(&updates, true, &ColorScheme::Default); } #[test] fn test_color_updated_constraint_major() { - let result = color_updated_constraint(">=1.0.0", "2.0.0", ">=2.0.0"); - // Major bump: entire version is colored red; "2.0.0" is still a substring + let result = color_updated_constraint(">=1.0.0", "2.0.0", ">=2.0.0", &ColorScheme::Default); assert!(result.contains("2.0.0")); assert!(result.contains(">=")); } #[test] fn test_color_updated_constraint_minor() { - let result = color_updated_constraint(">=0.109.0", "0.110.0", ">=0.110.0"); - // Minor bump: prefix "0." is plain, "110.0" colored blue + let result = + color_updated_constraint(">=0.109.0", "0.110.0", ">=0.110.0", &ColorScheme::Default); assert!(result.contains("0.")); assert!(result.contains("110")); } #[test] fn test_color_updated_constraint_patch() { - let result = color_updated_constraint(">=1.0.0", "1.0.1", ">=1.0.1"); - // Patch bump: "1.0." is plain, "1" (the new patch) colored green + let result = color_updated_constraint(">=1.0.0", "1.0.1", ">=1.0.1", &ColorScheme::Default); assert!(result.contains("1.0.")); } #[test] fn test_color_updated_constraint_compound() { - let result = color_updated_constraint(">=0.7.3,<0.8.0", "1.0.0", ">=1.0.0,<2.0.0"); + let result = color_updated_constraint( + ">=0.7.3,<0.8.0", + "1.0.0", + ">=1.0.0,<2.0.0", + &ColorScheme::Default, + ); assert!(result.contains("1.0.0")); - assert!(result.contains("2.0.0")); // upper bound present (dimmed but still a substring) + assert!(result.contains("2.0.0")); } #[test] fn test_color_updated_constraint_no_base_version() { - // Bare wildcard - returns the constraint as-is - let result = color_updated_constraint("*", "1.0.0", "*"); + let result = color_updated_constraint("*", "1.0.0", "*", &ColorScheme::Default); assert_eq!(result, "*"); } #[test] fn test_color_updated_constraint_four_component_version() { - // diff_idx falls through to the `_` arm: first 3 components are equal, - // difference is in the 4th component → rendered uncolored. - let result = color_updated_constraint(">=1.0.0.0", "1.0.0.1", ">=1.0.0.1"); + let result = + color_updated_constraint(">=1.0.0.0", "1.0.0.1", ">=1.0.0.1", &ColorScheme::Default); assert!(result.contains("1.0.0.1")); assert!(result.contains(">=")); } + + #[test] + fn test_all_schemes_produce_output() { + // Verify every scheme runs without panic for all bump levels + let cases = [ + ("1.0.0", "2.0.0", "2.0.0"), + ("1.0.0", "1.1.0", "1.1.0"), + ("1.0.0", "1.0.1", "1.0.1"), + ]; + for scheme in &[ + ColorScheme::Default, + ColorScheme::OkabeIto, + ColorScheme::TrafficLight, + ColorScheme::Severity, + ColorScheme::HighContrast, + ] { + for (cur, _lat, upd) in &cases { + let result = color_updated_constraint(cur, _lat, upd, scheme); + assert!(!result.is_empty()); + } + } + } } diff --git a/src/uninstall.rs b/src/uninstall.rs index 4d0bb60..af160dd 100644 --- a/src/uninstall.rs +++ b/src/uninstall.rs @@ -5,11 +5,32 @@ pub fn run() -> Result<()> { .context("Could not determine the path of the current executable")?; remove_exe(&exe)?; - println!("Removed {}.", exe.display()); + + remove_config(); + Ok(()) } +/// Remove the `pycu/` config directory (contains `config.toml`). +/// Failures are non-fatal - the binary is already gone at this point. +fn remove_config() { + let config_dir = dirs::config_dir().map(|d| d.join("pycu")); + + match config_dir { + None => {} + Some(dir) if !dir.exists() => {} + Some(dir) => match std::fs::remove_dir_all(&dir) { + Ok(()) => println!("Removed config directory {}.", dir.display()), + Err(e) => eprintln!( + "Warning: could not remove config directory {}: {}", + dir.display(), + e + ), + }, + } +} + #[cfg(not(target_os = "windows"))] fn remove_exe(exe: &std::path::Path) -> Result<()> { // On Unix, unlinking a running executable is safe: the inode stays alive From 1346f81d75e3046ee47affb0053acc5ab85dbef8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Motheu?= <66940960+Logic-py@users.noreply.github.com> Date: Mon, 9 Mar 2026 13:02:36 +0100 Subject: [PATCH 2/2] chore: run cargo build --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2e7ddcf..3994973 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1089,7 +1089,7 @@ dependencies = [ [[package]] name = "pycu" -version = "1.0.1" +version = "1.1.0" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index f36e93e..0018ff5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pycu" -version = "1.0.1" +version = "1.1.0" edition = "2024" rust-version = "1.85" description = "Check your Python dependencies for newer versions on PyPI"