clap_version_flag/
lib.rs

1// Project: clap-version-flag
2// File: src\lib.rs
3// Author: Hadi Cahyadi <cumulus13@gmail.com>
4// Date: 2025-12-12
5// Description: 
6// License: MIT
7
8//! A production-ready crate for adding colorful version output to clap applications.
9//!
10//! This crate provides utilities to override the default `-V`/`--version` flag behavior
11//! in clap applications with colorful output using hex color codes.
12
13mod error;
14
15pub use error::VersionError;
16
17use clap::{Arg, ArgAction, ArgMatches, Command, FromArgMatches};
18use colored::Colorize;
19use std::process;
20use std::fmt;
21
22/// Configuration for colorful version output
23#[derive(Clone, Debug)]
24pub struct ColorfulVersion {
25    package_name: &'static str,
26    version: &'static str,
27    author: &'static str,
28    colors: Colors,
29}
30
31#[derive(Clone, Debug)]
32struct Colors {
33    name_fg: (u8, u8, u8),       // RGB for name foreground
34    name_bg: (u8, u8, u8),       // RGB for name background
35    version_color: (u8, u8, u8), // RGB for version
36    author_color: (u8, u8, u8),  // RGB for author
37}
38
39impl Default for Colors {
40    fn default() -> Self {
41        Self {
42            // Default colors as specified in requirements:
43            // name: light #FFFFFF on #AA00FF
44            // version: light #FFFF00
45            // author: light #00FFFF
46            name_fg: (255, 255, 255),     // #FFFFFF
47            name_bg: (170, 0, 255),       // #AA00FF
48            version_color: (255, 255, 0), // #FFFF00
49            author_color: (0, 255, 255),  // #00FFFF
50        }
51    }
52}
53
54impl ColorfulVersion {
55    /// Creates a new ColorfulVersion using values from Cargo.toml
56    #[must_use]
57    pub fn from_cargo() -> Self {
58        Self {
59            package_name: env!("CARGO_PKG_NAME"),
60            version: env!("CARGO_PKG_VERSION"),
61            author: env!("CARGO_PKG_AUTHORS"),
62            colors: Colors::default(),
63        }
64    }
65
66    /// Creates a new ColorfulVersion with custom values
67    #[must_use]
68    pub fn new(package_name: &'static str, version: &'static str, author: &'static str) -> Self {
69        Self {
70            package_name,
71            version,
72            author,
73            colors: Colors::default(),
74        }
75    }
76
77    /// Sets custom hex colors for the version output
78    ///
79    /// # Arguments
80    /// * `name_fg` - Hex color for package name foreground (e.g., "#FFFFFF")
81    /// * `name_bg` - Hex color for package name background (e.g., "#AA00FF")
82    /// * `version` - Hex color for version text (e.g., "#FFFF00")
83    /// * `author` - Hex color for author text (e.g., "#00FFFF")
84    ///
85    /// # Errors
86    /// Returns `VersionError::InvalidHexColor` if any hex color is invalid
87    pub fn with_hex_colors(
88        mut self,
89        name_fg: &str,
90        name_bg: &str,
91        version: &str,
92        author: &str,
93    ) -> Result<Self, VersionError> {
94        self.colors.name_fg = parse_hex(name_fg)?;
95        self.colors.name_bg = parse_hex(name_bg)?;
96        self.colors.version_color = parse_hex(version)?;
97        self.colors.author_color = parse_hex(author)?;
98        Ok(self)
99    }
100
101    /// Sets custom RGB colors for the version output
102    #[must_use]
103    pub fn with_rgb_colors(
104        mut self,
105        name_fg: (u8, u8, u8),
106        name_bg: (u8, u8, u8),
107        version: (u8, u8, u8),
108        author: (u8, u8, u8),
109    ) -> Self {
110        self.colors.name_fg = name_fg;
111        self.colors.name_bg = name_bg;
112        self.colors.version_color = version;
113        self.colors.author_color = author;
114        self
115    }
116
117    /// Prints the colorful version to stdout and exits the process
118    pub fn print_and_exit(&self) -> ! {
119        self.print();
120        process::exit(0);
121    }
122
123    /// Prints the colorful version to stdout
124    /// Format: "{package_name} v{version} by {author}"
125    pub fn print(&self) {
126        let name = self
127            .package_name
128            .truecolor(
129                self.colors.name_fg.0,
130                self.colors.name_fg.1,
131                self.colors.name_fg.2,
132            )
133            .on_truecolor(
134                self.colors.name_bg.0,
135                self.colors.name_bg.1,
136                self.colors.name_bg.2,
137            );
138
139        let version_text = format!(" v{}", self.version).truecolor(
140            self.colors.version_color.0,
141            self.colors.version_color.1,
142            self.colors.version_color.2,
143        );
144
145        let author_text = format!(" by {}", self.author).truecolor(
146            self.colors.author_color.0,
147            self.colors.author_color.1,
148            self.colors.author_color.2,
149        );
150
151        println!("{name}{version_text}{author_text}");
152    }
153
154    /// Returns a plain text version string (for clap's version flag)
155    /// Format: "{package_name} v{version} by {author}"
156    #[must_use]
157    pub fn as_plain_string(&self) -> String {
158        format!("{} v{} by {}", self.package_name, self.version, self.author)
159    }
160
161    /// Returns a colored version string if terminal supports colors
162    /// Format: "{package_name} v{version} by {author}"
163    #[must_use]
164    pub fn to_colored_string(&self) -> String {
165        format!(
166            "{}{}{}",
167            self.package_name
168                .truecolor(
169                    self.colors.name_fg.0,
170                    self.colors.name_fg.1,
171                    self.colors.name_fg.2
172                )
173                .on_truecolor(
174                    self.colors.name_bg.0,
175                    self.colors.name_bg.1,
176                    self.colors.name_bg.2
177                ),
178            format!(" v{}", self.version).truecolor(
179                self.colors.version_color.0,
180                self.colors.version_color.1,
181                self.colors.version_color.2
182            ),
183            format!(" by {}", self.author).truecolor(
184                self.colors.author_color.0,
185                self.colors.author_color.1,
186                self.colors.author_color.2
187            )
188        )
189    }
190
191    /// Checks if the version flag was used and handles it
192    ///
193    /// This method should be called after parsing command-line arguments.
194    /// If the version flag is found, it prints the colorful version and exits.
195    pub fn check_and_exit(&self, matches: &ArgMatches) {
196        if matches.get_flag("clap_version_flag_version") {
197            self.print_and_exit();
198        }
199    }
200
201    /// Returns the package name
202    #[must_use]
203    pub fn package_name(&self) -> &str {
204        self.package_name
205    }
206
207    /// Returns the version
208    #[must_use]
209    pub fn version(&self) -> &str {
210        self.version
211    }
212
213    /// Returns the author
214    #[must_use]
215    pub fn author(&self) -> &str {
216        self.author
217    }
218}
219
220/// Implement Display trait for ColorfulVersion
221impl fmt::Display for ColorfulVersion {
222    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
223        write!(
224            f,
225            "{} v{} by {}",
226            self.package_name, self.version, self.author
227        )
228    }
229}
230
231/// Parses a hex color string to RGB values
232///
233/// # Arguments
234/// * `hex` - Hex color string (e.g., "#FFFFFF", "#FFF", or "FFFFFF")
235///
236/// # Errors
237/// Returns `VersionError::InvalidHexColor` if the hex string is invalid
238fn parse_hex(hex: &str) -> Result<(u8, u8, u8), VersionError> {
239    let hex = hex.trim_start_matches('#');
240
241    match hex.len() {
242        6 => {
243            let r =
244                u8::from_str_radix(&hex[0..2], 16).map_err(|_| VersionError::invalid_hex(hex))?;
245            let g =
246                u8::from_str_radix(&hex[2..4], 16).map_err(|_| VersionError::invalid_hex(hex))?;
247            let b =
248                u8::from_str_radix(&hex[4..6], 16).map_err(|_| VersionError::invalid_hex(hex))?;
249            Ok((r, g, b))
250        }
251        3 => {
252            // Expand #RGB to #RRGGBB
253            let r = u8::from_str_radix(&hex[0..1].repeat(2), 16)
254                .map_err(|_| VersionError::invalid_hex(hex))?;
255            let g = u8::from_str_radix(&hex[1..2].repeat(2), 16)
256                .map_err(|_| VersionError::invalid_hex(hex))?;
257            let b = u8::from_str_radix(&hex[2..3].repeat(2), 16)
258                .map_err(|_| VersionError::invalid_hex(hex))?;
259            Ok((r, g, b))
260        }
261        _ => Err(VersionError::invalid_hex(hex)),
262    }
263}
264
265/// Macro for easy creation of ColorfulVersion from Cargo.toml
266///
267/// # Examples
268/// ```
269/// use clap_version_flag::colorful_version;
270///
271/// // With default colors
272/// let version = colorful_version!();
273///
274/// // With custom hex colors
275/// let version = colorful_version!("#FFFFFF", "#AA00FF", "#FFFF00", "#00FFFF");
276/// ```
277#[macro_export]
278macro_rules! colorful_version {
279    () => {
280        $crate::ColorfulVersion::from_cargo()
281    };
282
283    ($name_fg:expr, $name_bg:expr, $version:expr, $author:expr) => {
284        $crate::ColorfulVersion::from_cargo()
285            .with_hex_colors($name_fg, $name_bg, $version, $author)
286            .unwrap_or_else(|e| panic!("clap-version-flag: Invalid hex color format: {}", e))
287    };
288}
289
290/// Extension trait for clap::Command to add colorful version flag
291pub trait ColorfulVersionExt {
292    /// Adds a version flag that will display colorful output when used
293    fn with_colorful_version(self, version: &ColorfulVersion) -> Self;
294}
295
296impl ColorfulVersionExt for Command {
297    fn with_colorful_version(self, _version: &ColorfulVersion) -> Self {
298        // Just add the flag - actual handling is done in parse_with_version
299        self.arg(
300            Arg::new("clap_version_flag_version")
301                .short('V')
302                .long("version")
303                .action(ArgAction::SetTrue)
304                .help("Print version information")
305                .global(true),
306        )
307    }
308}
309
310/// Helper function to parse command-line arguments with version handling
311///
312/// This function should be used instead of directly calling `get_matches()`.
313/// If the version flag is found, it prints the colorful version and exits.
314///
315/// # Examples
316/// ```no_run
317/// use clap::{Parser, CommandFactory};
318/// use clap_version_flag::{colorful_version, parse_with_version};
319///
320/// #[derive(Parser)]
321/// struct Cli {
322///     input: String,
323/// }
324///
325/// fn main() -> Result<(), Box<dyn std::error::Error>> {
326///     let version = colorful_version!();
327///     let cli: Cli = parse_with_version(Cli::command(), &version)?;
328///     
329///     // Normal program execution continues here
330///     Ok(())
331/// }
332/// ```
333pub fn parse_with_version<T: FromArgMatches>(
334    command: Command,
335    version: &ColorfulVersion,
336) -> Result<T, clap::Error> {
337    let command = command.with_colorful_version(version);
338    let matches = command.get_matches();
339
340    // Check if version flag was used
341    if matches.get_flag("clap_version_flag_version") {
342        version.print();
343        process::exit(0);
344    }
345
346    T::from_arg_matches(&matches)
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    #[test]
354    fn test_hex_parsing() {
355        assert_eq!(parse_hex("#FFFFFF").unwrap(), (255, 255, 255));
356        assert_eq!(parse_hex("#000000").unwrap(), (0, 0, 0));
357        assert_eq!(parse_hex("#FF0000").unwrap(), (255, 0, 0));
358        assert_eq!(parse_hex("#FFF").unwrap(), (255, 255, 255));
359        assert_eq!(parse_hex("#F00").unwrap(), (255, 0, 0));
360        assert_eq!(parse_hex("FFFFFF").unwrap(), (255, 255, 255));
361
362        assert!(parse_hex("INVALID").is_err());
363        assert!(parse_hex("#GGG").is_err());
364        assert!(parse_hex("#12345").is_err());
365    }
366
367    #[test]
368    fn test_default_colors() {
369        let version = ColorfulVersion::from_cargo();
370        assert_eq!(version.colors.name_fg, (255, 255, 255));
371        assert_eq!(version.colors.name_bg, (170, 0, 255));
372        assert_eq!(version.colors.version_color, (255, 255, 0));
373        assert_eq!(version.colors.author_color, (0, 255, 255));
374    }
375
376    #[test]
377    fn test_macro() {
378        let version = colorful_version!();
379        assert!(!version.package_name().is_empty());
380
381        let custom = colorful_version!("#FFFFFF", "#AA00FF", "#FFFF00", "#00FFFF");
382        assert_eq!(custom.colors.name_fg, (255, 255, 255));
383    }
384
385    #[test]
386    fn test_command_extension() {
387        let version = colorful_version!();
388        let cmd = Command::new("testapp").with_colorful_version(&version);
389
390        // Should compile without errors
391        assert_eq!(cmd.get_name(), "testapp");
392    }
393
394    #[test]
395    fn test_version_string_format() {
396        let version = ColorfulVersion::new("testapp", "1.2.3", "Test Author");
397        let plain = version.to_string();
398
399        // Verify format: "{name} v{version} by {author}"
400        assert_eq!(plain, "testapp v1.2.3 by Test Author");
401    }
402
403    #[test]
404    fn test_cargo_env_format() {
405        let version = ColorfulVersion::from_cargo();
406        let plain = version.to_string();
407
408        // Should follow format: "{CARGO_PKG_NAME} v{CARGO_PKG_VERSION} by {CARGO_PKG_AUTHORS}"
409        assert!(plain.contains(" v"));
410        assert!(plain.contains(" by "));
411        assert_eq!(
412            plain,
413            format!(
414                "{} v{} by {}",
415                env!("CARGO_PKG_NAME"),
416                env!("CARGO_PKG_VERSION"),
417                env!("CARGO_PKG_AUTHORS")
418            )
419        );
420    }
421
422    #[test]
423    fn test_display_trait() {
424        let version = ColorfulVersion::new("testapp", "1.2.3", "Test Author");
425        let display = format!("{}", version);
426        assert_eq!(display, "testapp v1.2.3 by Test Author");
427    }
428
429    #[test]
430    fn test_as_plain_string() {
431        let version = ColorfulVersion::new("myapp", "2.0.0", "John Doe");
432        let plain = version.as_plain_string();
433        assert_eq!(plain, "myapp v2.0.0 by John Doe");
434    }
435}