Skip to content

Commit 4271b91

Browse files
authored
feat: add color scheme tests and update README (#5)
* feat: add color scheme tests and update README * chore: update changelog and bump version
1 parent 515e4b1 commit 4271b91

File tree

8 files changed

+217
-77
lines changed

8 files changed

+217
-77
lines changed

CHANGELOG.md

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [1.1.1] - 2026-03-09
11+
12+
### Added
13+
14+
- Tests for `load_from_path`, `save_to_path`, and `parse_scheme_input` config helpers
15+
- Tests for `remove_config_at` covering both existing and non-existent directories
16+
- Smoke test for `print_color_scheme_preview`
17+
- README documentation for color schemes: options table entry, dedicated section with all 5 schemes, first-run prompt
18+
note, and uninstall clarification
19+
20+
### Changed
21+
22+
- Extracted `load_from_path`, `save_to_path`, and `parse_scheme_input` as testable helpers in `config.rs`
23+
- Extracted `remove_config_at` as a testable helper in `uninstall.rs`
24+
1025
## [1.1.0] - 2026-03-09
1126

1227
### Added
1328

1429
- Five built-in color schemes for table output, selectable and persisted per user:
15-
- `default` - GitHub-style SemVer severity (`#D73A49` / `#0366D6` / `#28A745`)
16-
- `okabe-ito` - Color-blind safe OkabeIto palette (`#E69F00` / `#0072B2` / `#009E73`)
17-
- `traffic-light` - Classic red/yellow/green (`#E74C3C` / `#F1C40F` / `#2ECC71`)
18-
- `severity` - Monitoring/observability style (`#8E44AD` / `#3498DB` / `#95A5A6`)
19-
- `high-contrast` - Maximum distinction, color-blind safe (`#CC79A7` / `#0072B2` / `#F0E442`)
30+
- `default` - GitHub-style SemVer severity (`#D73A49` / `#0366D6` / `#28A745`)
31+
- `okabe-ito` - Color-blind safe Okabe-Ito palette (`#E69F00` / `#0072B2` / `#009E73`)
32+
- `traffic-light` - Classic red/yellow/green (`#E74C3C` / `#F1C40F` / `#2ECC71`)
33+
- `severity` - Monitoring/observability style (`#8E44AD` / `#3498DB` / `#95A5A6`)
34+
- `high-contrast` - Maximum distinction, color-blind safe (`#CC79A7` / `#0072B2` / `#F0E442`)
2035
- All colors rendered with true-color (24-bit) escape codes for exact hex fidelity
21-
- `--set-color-scheme` flag: run without a value to preview all schemes visually, or pass a scheme name to save it permanently
22-
- Color scheme preference persisted to `~/.config/pycu/config.toml` (Linux/macOS) or `%APPDATA%\pycu\config.toml` (Windows)
36+
- `--set-color-scheme` flag: run without a value to preview all schemes visually, or pass a scheme name to save it
37+
permanently
38+
- Color scheme preference persisted to `~/.config/pycu/config.toml` (Linux/macOS) or `%APPDATA%\pycu\config.toml` (
39+
Windows)
2340
- First-run interactive prompt to choose a color scheme on initial install
2441
- `--uninstall` now also removes the `pycu/` config directory
2542

@@ -42,7 +59,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4259
- Progress bar during PyPI lookups
4360
- Install scripts for Linux/macOS (`install.sh`) and Windows (`install.ps1`)
4461

45-
[Unreleased]: https://github.com/Logic-py/python-check-updates/compare/1.1.0...HEAD
62+
[Unreleased]: https://github.com/Logic-py/python-check-updates/compare/1.1.1...HEAD
63+
64+
[1.1.1]: https://github.com/Logic-py/python-check-updates/compare/1.1.0...1.1.1
4665

4766
[1.1.0]: https://github.com/Logic-py/python-check-updates/compare/1.0.0...1.1.0
67+
4868
[1.0.0]: https://github.com/Logic-py/python-check-updates/releases/tag/1.0.0

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "pycu"
3-
version = "1.1.0"
3+
version = "1.1.1"
44
edition = "2024"
55
rust-version = "1.85"
66
description = "Check your Python dependencies for newer versions on PyPI"

README.md

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ So I built pycu.
3939
- Filter by bump level: major, minor, or patch only
4040
- JSON output for scripting
4141
- Self-updates via `--self-update`
42-
- Color-coded output showing exactly which version component changed
42+
- Color-coded output showing exactly which version component changed, with 5 built-in color schemes including
43+
color-blind safe options
4344
- SHA-256 verified self-update downloads
4445

4546
## Installation
@@ -76,6 +77,8 @@ cargo install --git https://github.com/Logic-py/python-check-updates
7677
pycu --uninstall
7778
```
7879

80+
This removes the `pycu` binary and its configuration directory.
81+
7982
## Usage
8083

8184
Run in a directory that contains a `pyproject.toml` or `requirements.txt`:
@@ -92,16 +95,17 @@ pycu --file path/to/requirements.txt
9295

9396
### Options
9497

95-
| Flag | Short | Description |
96-
|---------------------|-------|------------------------------------------------------------------|
97-
| `--file <PATH>` | | Path to the dependency file (auto-detected if omitted) |
98-
| `--upgrade` | `-u` | Rewrite the file in-place with updated constraints |
99-
| `--target <LEVEL>` | `-t` | Only show `major`, `minor`, or `patch` bumps (default: `latest`) |
100-
| `--json` | | Output results as JSON |
101-
| `--concurrency <N>` | | Max concurrent PyPI requests (default: `10`) |
102-
| `--self-update` | | Update pycu itself to the latest release |
103-
| `--uninstall` | | Remove pycu from your system |
104-
| `--version` | | Print the version |
98+
| Flag | Short | Description |
99+
|-------------------------------|-------|------------------------------------------------------------------|
100+
| `--file <PATH>` | | Path to the dependency file (auto-detected if omitted) |
101+
| `--upgrade` | `-u` | Rewrite the file in-place with updated constraints |
102+
| `--target <LEVEL>` | `-t` | Only show `major`, `minor`, or `patch` bumps (default: `latest`) |
103+
| `--json` | | Output results as JSON |
104+
| `--concurrency <N>` | | Max concurrent PyPI requests (default: `10`) |
105+
| `--set-color-scheme [SCHEME]` | | Preview all color schemes, or save one persistently |
106+
| `--self-update` | | Update pycu itself to the latest release |
107+
| `--uninstall` | | Remove pycu from your system |
108+
| `--version` | | Print the version |
105109

106110
### Examples
107111

@@ -122,6 +126,30 @@ pycu --file requirements-dev.txt
122126
pycu --json
123127
```
124128

129+
### Color schemes
130+
131+
pycu ships with five built-in color schemes. Your preference is saved to
132+
`~/.config/pycu/config.toml` (Linux/macOS) or `%APPDATA%\pycu\config.toml` (Windows) and
133+
applied automatically on every run.
134+
135+
**Preview all schemes** (shown with live colored examples in your terminal):
136+
137+
```sh
138+
pycu --set-color-scheme
139+
```
140+
141+
**Set a scheme:**
142+
143+
```sh
144+
pycu --set-color-scheme default # red / blue / green - GitHub-style SemVer severity
145+
pycu --set-color-scheme okabe-ito # orange / blue / teal - color-blind safe (Okabe-Ito)
146+
pycu --set-color-scheme traffic-light # red / yellow / green - classic CI model
147+
pycu --set-color-scheme severity # purple / blue / gray - monitoring/observability style
148+
pycu --set-color-scheme high-contrast # magenta / blue / yellow - maximum distinction
149+
```
150+
151+
On first run, pycu will show the preview and prompt you to choose a scheme interactively.
152+
125153
### JSON output
126154

127155
```json

src/cli.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ pub enum TargetLevel {
6161
pub enum ColorScheme {
6262
/// #D73A49 / #0366D6 / #28A745 - GitHub-style SemVer severity (default)
6363
Default,
64-
/// #E69F00 / #0072B2 / #009E73 - OkabeIto, color-blind safe
64+
/// #E69F00 / #0072B2 / #009E73 - Okabe-Ito, color-blind safe
6565
OkabeIto,
6666
/// #E74C3C / #F1C40F / #2ECC71 - traffic-light (red/yellow/green)
6767
TrafficLight,

src/config.rs

Lines changed: 91 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use std::fs;
22
use std::io::{self, Write};
3-
use std::path::PathBuf;
3+
use std::path::{Path, PathBuf};
44

55
use anyhow::{Context, Result};
66
use serde::{Deserialize, Serialize};
@@ -35,11 +35,7 @@ pub fn load() -> Result<Option<Config>> {
3535
return Ok(None);
3636
}
3737

38-
let contents = fs::read_to_string(&path)
39-
.with_context(|| format!("Failed to read config file: {}", path.display()))?;
40-
let config: Config = toml::from_str(&contents)
41-
.with_context(|| format!("Failed to parse config file: {}", path.display()))?;
42-
Ok(Some(config))
38+
load_from_path(&path).map(Some)
4339
}
4440

4541
pub fn save(config: &Config) -> Result<()> {
@@ -48,15 +44,7 @@ pub fn save(config: &Config) -> Result<()> {
4844
None => anyhow::bail!("Could not determine config directory"),
4945
};
5046

51-
if let Some(parent) = path.parent() {
52-
fs::create_dir_all(parent)
53-
.with_context(|| format!("Failed to create config directory: {}", parent.display()))?;
54-
}
55-
56-
let contents = toml::to_string(config).context("Failed to serialize config")?;
57-
fs::write(&path, contents)
58-
.with_context(|| format!("Failed to write config file: {}", path.display()))?;
59-
Ok(())
47+
save_to_path(config, &path)
6048
}
6149

6250
/// Interactive first-run prompt. Shows the color scheme preview, asks the user to pick,
@@ -71,16 +59,8 @@ pub fn first_run_setup() -> Result<Config> {
7159

7260
let mut input = String::new();
7361
io::stdin().read_line(&mut input)?;
74-
let input = input.trim().to_lowercase();
75-
76-
let color_scheme = match input.as_str() {
77-
"okabe-ito" => ColorScheme::OkabeIto,
78-
"traffic-light" => ColorScheme::TrafficLight,
79-
"severity" => ColorScheme::Severity,
80-
"high-contrast" => ColorScheme::HighContrast,
81-
_ => ColorScheme::Default,
82-
};
8362

63+
let color_scheme = parse_scheme_input(&input);
8464
let config = Config { color_scheme };
8565
save(&config)?;
8666

@@ -92,6 +72,36 @@ pub fn first_run_setup() -> Result<Config> {
9272
Ok(config)
9373
}
9474

75+
// ── Internal helpers (pub(crate) for testing) ────────────────────────────────
76+
77+
pub(crate) fn load_from_path(path: &Path) -> Result<Config> {
78+
let contents = fs::read_to_string(path)
79+
.with_context(|| format!("Failed to read config file: {}", path.display()))?;
80+
toml::from_str(&contents)
81+
.with_context(|| format!("Failed to parse config file: {}", path.display()))
82+
}
83+
84+
pub(crate) fn save_to_path(config: &Config, path: &Path) -> Result<()> {
85+
if let Some(parent) = path.parent() {
86+
fs::create_dir_all(parent)
87+
.with_context(|| format!("Failed to create config directory: {}", parent.display()))?;
88+
}
89+
let contents = toml::to_string(config).context("Failed to serialize config")?;
90+
fs::write(path, contents)
91+
.with_context(|| format!("Failed to write config file: {}", path.display()))
92+
}
93+
94+
/// Parse a raw user input string into a `ColorScheme`, defaulting to `Default`.
95+
pub(crate) fn parse_scheme_input(input: &str) -> ColorScheme {
96+
match input.trim().to_lowercase().as_str() {
97+
"okabe-ito" => ColorScheme::OkabeIto,
98+
"traffic-light" => ColorScheme::TrafficLight,
99+
"severity" => ColorScheme::Severity,
100+
"high-contrast" => ColorScheme::HighContrast,
101+
_ => ColorScheme::Default,
102+
}
103+
}
104+
95105
#[cfg(test)]
96106
mod tests {
97107
use super::*;
@@ -131,23 +141,70 @@ mod tests {
131141
}
132142

133143
#[test]
134-
fn test_nonexistent_path_has_no_file() {
135-
let fake_path = PathBuf::from("/nonexistent/pycu/config.toml");
136-
assert!(!fake_path.exists());
144+
fn test_save_to_path_creates_dirs_and_file() {
145+
let dir = TempDir::new().unwrap();
146+
let path = dir.path().join("nested").join("pycu").join("config.toml");
147+
let cfg = Config {
148+
color_scheme: ColorScheme::Severity,
149+
};
150+
save_to_path(&cfg, &path).unwrap();
151+
assert!(path.exists());
137152
}
138153

139154
#[test]
140-
fn test_save_and_load_roundtrip() {
155+
fn test_load_from_path_roundtrip() {
141156
let dir = TempDir::new().unwrap();
142157
let path = dir.path().join("config.toml");
143-
144158
let cfg = Config {
145-
color_scheme: ColorScheme::OkabeIto,
159+
color_scheme: ColorScheme::TrafficLight,
146160
};
147-
let contents = toml::to_string(&cfg).unwrap();
148-
std::fs::write(&path, contents).unwrap();
161+
save_to_path(&cfg, &path).unwrap();
162+
let back = load_from_path(&path).unwrap();
163+
assert_eq!(back.color_scheme, ColorScheme::TrafficLight);
164+
}
149165

150-
let back: Config = toml::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
151-
assert_eq!(back.color_scheme, ColorScheme::OkabeIto);
166+
#[test]
167+
fn test_load_from_path_invalid_toml_returns_err() {
168+
let dir = TempDir::new().unwrap();
169+
let path = dir.path().join("config.toml");
170+
fs::write(&path, "this is not valid toml !!!").unwrap();
171+
let result = load_from_path(&path);
172+
assert!(result.is_err());
173+
assert!(result.unwrap_err().to_string().contains("Failed to parse"));
174+
}
175+
176+
#[test]
177+
fn test_load_from_path_missing_file_returns_err() {
178+
let dir = TempDir::new().unwrap();
179+
let path = dir.path().join("nonexistent.toml");
180+
let result = load_from_path(&path);
181+
assert!(result.is_err());
182+
assert!(result.unwrap_err().to_string().contains("Failed to read"));
183+
}
184+
185+
#[test]
186+
fn test_parse_scheme_input_all_variants() {
187+
assert_eq!(parse_scheme_input("okabe-ito"), ColorScheme::OkabeIto);
188+
assert_eq!(
189+
parse_scheme_input("traffic-light"),
190+
ColorScheme::TrafficLight
191+
);
192+
assert_eq!(parse_scheme_input("severity"), ColorScheme::Severity);
193+
assert_eq!(
194+
parse_scheme_input("high-contrast"),
195+
ColorScheme::HighContrast
196+
);
197+
assert_eq!(parse_scheme_input("default"), ColorScheme::Default);
198+
assert_eq!(parse_scheme_input(""), ColorScheme::Default);
199+
assert_eq!(parse_scheme_input("unknown"), ColorScheme::Default);
200+
}
201+
202+
#[test]
203+
fn test_parse_scheme_input_trims_whitespace_and_ignores_case() {
204+
assert_eq!(parse_scheme_input(" OKABE-ITO\n"), ColorScheme::OkabeIto);
205+
assert_eq!(
206+
parse_scheme_input(" Traffic-Light "),
207+
ColorScheme::TrafficLight
208+
);
152209
}
153210
}

src/output/table.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ fn palette_for(scheme: &ColorScheme) -> Palette {
1919
minor: (3, 102, 214), // #0366D6 blue
2020
patch: (40, 167, 69), // #28A745 green
2121
},
22-
// OkabeIto color-blind safe
22+
// Okabe-Ito color-blind safe
2323
ColorScheme::OkabeIto => Palette {
2424
major: (230, 159, 0), // #E69F00 orange
2525
minor: (0, 114, 178), // #0072B2 blue
@@ -105,7 +105,7 @@ pub fn print_color_scheme_preview() {
105105
(
106106
"okabe-ito",
107107
ColorScheme::OkabeIto,
108-
"Color-blind safe - OkabeIto palette",
108+
"Color-blind safe - Okabe-Ito palette",
109109
["orange #E69F00", "blue #0072B2", "teal #009E73"],
110110
),
111111
(
@@ -251,6 +251,11 @@ mod tests {
251251
}
252252
}
253253

254+
#[test]
255+
fn test_print_color_scheme_preview_does_not_panic() {
256+
print_color_scheme_preview();
257+
}
258+
254259
#[test]
255260
fn test_print_table_empty() {
256261
print_table(&[], false, &ColorScheme::Default);

0 commit comments

Comments
 (0)