From 7e0321fa8d980db625fbeb91144a0c93f855c618 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:15:00 +0100 Subject: [PATCH 1/2] feat: add color scheme tests and update README --- CHANGELOG.md | 2 +- README.md | 50 ++++++++++++++---- src/cli.rs | 2 +- src/config.rs | 125 ++++++++++++++++++++++++++++++++------------ src/output/table.rs | 9 +++- src/uninstall.rs | 68 +++++++++++++++++------- 6 files changed, 188 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d716e7..24f8bba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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`) + - `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`) diff --git a/README.md b/README.md index cddd856..7905d88 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,8 @@ So I built pycu. - Filter by bump level: major, minor, or patch only - JSON output for scripting - Self-updates via `--self-update` -- Color-coded output showing exactly which version component changed +- Color-coded output showing exactly which version component changed, with 5 built-in color schemes including + color-blind safe options - SHA-256 verified self-update downloads ## Installation @@ -76,6 +77,8 @@ cargo install --git https://github.com/Logic-py/python-check-updates pycu --uninstall ``` +This removes the `pycu` binary and its configuration directory. + ## Usage Run in a directory that contains a `pyproject.toml` or `requirements.txt`: @@ -92,16 +95,17 @@ pycu --file path/to/requirements.txt ### Options -| Flag | Short | Description | -|---------------------|-------|------------------------------------------------------------------| -| `--file ` | | Path to the dependency file (auto-detected if omitted) | -| `--upgrade` | `-u` | Rewrite the file in-place with updated constraints | -| `--target ` | `-t` | Only show `major`, `minor`, or `patch` bumps (default: `latest`) | -| `--json` | | Output results as JSON | -| `--concurrency ` | | Max concurrent PyPI requests (default: `10`) | -| `--self-update` | | Update pycu itself to the latest release | -| `--uninstall` | | Remove pycu from your system | -| `--version` | | Print the version | +| Flag | Short | Description | +|-------------------------------|-------|------------------------------------------------------------------| +| `--file ` | | Path to the dependency file (auto-detected if omitted) | +| `--upgrade` | `-u` | Rewrite the file in-place with updated constraints | +| `--target ` | `-t` | Only show `major`, `minor`, or `patch` bumps (default: `latest`) | +| `--json` | | Output results as JSON | +| `--concurrency ` | | Max concurrent PyPI requests (default: `10`) | +| `--set-color-scheme [SCHEME]` | | Preview all color schemes, or save one persistently | +| `--self-update` | | Update pycu itself to the latest release | +| `--uninstall` | | Remove pycu from your system | +| `--version` | | Print the version | ### Examples @@ -122,6 +126,30 @@ pycu --file requirements-dev.txt pycu --json ``` +### Color schemes + +pycu ships with five built-in color schemes. Your preference is saved to +`~/.config/pycu/config.toml` (Linux/macOS) or `%APPDATA%\pycu\config.toml` (Windows) and +applied automatically on every run. + +**Preview all schemes** (shown with live colored examples in your terminal): + +```sh +pycu --set-color-scheme +``` + +**Set a scheme:** + +```sh +pycu --set-color-scheme default # red / blue / green - GitHub-style SemVer severity +pycu --set-color-scheme okabe-ito # orange / blue / teal - color-blind safe (Okabe-Ito) +pycu --set-color-scheme traffic-light # red / yellow / green - classic CI model +pycu --set-color-scheme severity # purple / blue / gray - monitoring/observability style +pycu --set-color-scheme high-contrast # magenta / blue / yellow - maximum distinction +``` + +On first run, pycu will show the preview and prompt you to choose a scheme interactively. + ### JSON output ```json diff --git a/src/cli.rs b/src/cli.rs index c343607..f9d4dc5 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -61,7 +61,7 @@ pub enum TargetLevel { pub enum ColorScheme { /// #D73A49 / #0366D6 / #28A745 - GitHub-style SemVer severity (default) Default, - /// #E69F00 / #0072B2 / #009E73 - Okabe–Ito, color-blind safe + /// #E69F00 / #0072B2 / #009E73 - Okabe-Ito, color-blind safe OkabeIto, /// #E74C3C / #F1C40F / #2ECC71 - traffic-light (red/yellow/green) TrafficLight, diff --git a/src/config.rs b/src/config.rs index bd34f06..49c096f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,6 @@ use std::fs; use std::io::{self, Write}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; @@ -35,11 +35,7 @@ pub fn load() -> Result> { 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)) + load_from_path(&path).map(Some) } pub fn save(config: &Config) -> Result<()> { @@ -48,15 +44,7 @@ pub fn save(config: &Config) -> Result<()> { 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(()) + save_to_path(config, &path) } /// Interactive first-run prompt. Shows the color scheme preview, asks the user to pick, @@ -71,16 +59,8 @@ pub fn first_run_setup() -> Result { 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 color_scheme = parse_scheme_input(&input); let config = Config { color_scheme }; save(&config)?; @@ -92,6 +72,36 @@ pub fn first_run_setup() -> Result { Ok(config) } +// ── Internal helpers (pub(crate) for testing) ──────────────────────────────── + +pub(crate) fn load_from_path(path: &Path) -> Result { + let contents = fs::read_to_string(path) + .with_context(|| format!("Failed to read config file: {}", path.display()))?; + toml::from_str(&contents) + .with_context(|| format!("Failed to parse config file: {}", path.display())) +} + +pub(crate) fn save_to_path(config: &Config, path: &Path) -> Result<()> { + 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())) +} + +/// Parse a raw user input string into a `ColorScheme`, defaulting to `Default`. +pub(crate) fn parse_scheme_input(input: &str) -> ColorScheme { + match input.trim().to_lowercase().as_str() { + "okabe-ito" => ColorScheme::OkabeIto, + "traffic-light" => ColorScheme::TrafficLight, + "severity" => ColorScheme::Severity, + "high-contrast" => ColorScheme::HighContrast, + _ => ColorScheme::Default, + } +} + #[cfg(test)] mod tests { use super::*; @@ -131,23 +141,70 @@ mod tests { } #[test] - fn test_nonexistent_path_has_no_file() { - let fake_path = PathBuf::from("/nonexistent/pycu/config.toml"); - assert!(!fake_path.exists()); + fn test_save_to_path_creates_dirs_and_file() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("nested").join("pycu").join("config.toml"); + let cfg = Config { + color_scheme: ColorScheme::Severity, + }; + save_to_path(&cfg, &path).unwrap(); + assert!(path.exists()); } #[test] - fn test_save_and_load_roundtrip() { + fn test_load_from_path_roundtrip() { let dir = TempDir::new().unwrap(); let path = dir.path().join("config.toml"); - let cfg = Config { - color_scheme: ColorScheme::OkabeIto, + color_scheme: ColorScheme::TrafficLight, }; - let contents = toml::to_string(&cfg).unwrap(); - std::fs::write(&path, contents).unwrap(); + save_to_path(&cfg, &path).unwrap(); + let back = load_from_path(&path).unwrap(); + assert_eq!(back.color_scheme, ColorScheme::TrafficLight); + } - let back: Config = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); - assert_eq!(back.color_scheme, ColorScheme::OkabeIto); + #[test] + fn test_load_from_path_invalid_toml_returns_err() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("config.toml"); + fs::write(&path, "this is not valid toml !!!").unwrap(); + let result = load_from_path(&path); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Failed to parse")); + } + + #[test] + fn test_load_from_path_missing_file_returns_err() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("nonexistent.toml"); + let result = load_from_path(&path); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Failed to read")); + } + + #[test] + fn test_parse_scheme_input_all_variants() { + assert_eq!(parse_scheme_input("okabe-ito"), ColorScheme::OkabeIto); + assert_eq!( + parse_scheme_input("traffic-light"), + ColorScheme::TrafficLight + ); + assert_eq!(parse_scheme_input("severity"), ColorScheme::Severity); + assert_eq!( + parse_scheme_input("high-contrast"), + ColorScheme::HighContrast + ); + assert_eq!(parse_scheme_input("default"), ColorScheme::Default); + assert_eq!(parse_scheme_input(""), ColorScheme::Default); + assert_eq!(parse_scheme_input("unknown"), ColorScheme::Default); + } + + #[test] + fn test_parse_scheme_input_trims_whitespace_and_ignores_case() { + assert_eq!(parse_scheme_input(" OKABE-ITO\n"), ColorScheme::OkabeIto); + assert_eq!( + parse_scheme_input(" Traffic-Light "), + ColorScheme::TrafficLight + ); } } diff --git a/src/output/table.rs b/src/output/table.rs index 6dc78be..92b55f2 100644 --- a/src/output/table.rs +++ b/src/output/table.rs @@ -19,7 +19,7 @@ fn palette_for(scheme: &ColorScheme) -> Palette { minor: (3, 102, 214), // #0366D6 blue patch: (40, 167, 69), // #28A745 green }, - // Okabe–Ito color-blind safe + // Okabe-Ito color-blind safe ColorScheme::OkabeIto => Palette { major: (230, 159, 0), // #E69F00 orange minor: (0, 114, 178), // #0072B2 blue @@ -105,7 +105,7 @@ pub fn print_color_scheme_preview() { ( "okabe-ito", ColorScheme::OkabeIto, - "Color-blind safe - Okabe–Ito palette", + "Color-blind safe - Okabe-Ito palette", ["orange #E69F00", "blue #0072B2", "teal #009E73"], ), ( @@ -251,6 +251,11 @@ mod tests { } } + #[test] + fn test_print_color_scheme_preview_does_not_panic() { + print_color_scheme_preview(); + } + #[test] fn test_print_table_empty() { print_table(&[], false, &ColorScheme::Default); diff --git a/src/uninstall.rs b/src/uninstall.rs index af160dd..1a2876e 100644 --- a/src/uninstall.rs +++ b/src/uninstall.rs @@ -1,4 +1,5 @@ use anyhow::{Context, Result}; +use std::path::Path; pub fn run() -> Result<()> { let exe = std::env::current_exe() @@ -7,39 +8,37 @@ pub fn run() -> Result<()> { remove_exe(&exe)?; println!("Removed {}.", exe.display()); - remove_config(); + if let Some(dir) = dirs::config_dir().map(|d| d.join("pycu")) { + remove_config_at(&dir); + } 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 - ), - }, +/// Remove a config directory. Failures are non-fatal - the binary is already gone. +pub(crate) fn remove_config_at(dir: &Path) { + if !dir.exists() { + return; + } + 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<()> { +fn remove_exe(exe: &Path) -> Result<()> { // On Unix, unlinking a running executable is safe: the inode stays alive // until the process exits, but the directory entry is gone immediately. std::fs::remove_file(exe).with_context(|| format!("Failed to remove {}", exe.display())) } #[cfg(target_os = "windows")] -fn remove_exe(exe: &std::path::Path) -> Result<()> { +fn remove_exe(exe: &Path) -> Result<()> { // Windows locks running executables, so we cannot delete directly. // Rename to .old - the file disappears from its original location // immediately and the renamed file is cleaned up on next boot or @@ -50,3 +49,34 @@ fn remove_exe(exe: &std::path::Path) -> Result<()> { let _ = std::fs::remove_file(&old); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_remove_config_at_existing_dir_removes_it() { + let tmp = TempDir::new().unwrap(); + let config_dir = tmp.path().join("pycu"); + std::fs::create_dir(&config_dir).unwrap(); + std::fs::write( + config_dir.join("config.toml"), + "color_scheme = \"default\"\n", + ) + .unwrap(); + + remove_config_at(&config_dir); + + assert!(!config_dir.exists()); + } + + #[test] + fn test_remove_config_at_nonexistent_dir_is_noop() { + let tmp = TempDir::new().unwrap(); + let config_dir = tmp.path().join("does_not_exist"); + // Must not panic + remove_config_at(&config_dir); + assert!(!config_dir.exists()); + } +} From 76de0a444e0a6410bc459f136c81ca79f8a16821 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:19:32 +0100 Subject: [PATCH 2/2] chore: update changelog and bump version --- CHANGELOG.md | 36 ++++++++++++++++++++++++++++-------- Cargo.lock | 2 +- Cargo.toml | 2 +- 3 files changed, 30 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24f8bba..d1d108a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,19 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.1.1] - 2026-03-09 + +### Added + +- Tests for `load_from_path`, `save_to_path`, and `parse_scheme_input` config helpers +- Tests for `remove_config_at` covering both existing and non-existent directories +- Smoke test for `print_color_scheme_preview` +- README documentation for color schemes: options table entry, dedicated section with all 5 schemes, first-run prompt + note, and uninstall clarification + +### Changed + +- Extracted `load_from_path`, `save_to_path`, and `parse_scheme_input` as testable helpers in `config.rs` +- Extracted `remove_config_at` as a testable helper in `uninstall.rs` + ## [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`) + - `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) +- `--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 @@ -42,7 +59,10 @@ 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.1.0...HEAD +[Unreleased]: https://github.com/Logic-py/python-check-updates/compare/1.1.1...HEAD + +[1.1.1]: https://github.com/Logic-py/python-check-updates/compare/1.1.0...1.1.1 [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 3994973..4b9fdbe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1089,7 +1089,7 @@ dependencies = [ [[package]] name = "pycu" -version = "1.1.0" +version = "1.1.1" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index 0018ff5..a19fc72 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pycu" -version = "1.1.0" +version = "1.1.1" edition = "2024" rust-version = "1.85" description = "Check your Python dependencies for newer versions on PyPI"