Skip to main content

temp_convert/
lib.rs

1//! > **Command line tool to convert between common temperature units**
2//!
3//! ## Install
4//! ```console
5//! $ cargo install temp_convert
6//! ```
7//!
8//! ## Example
9//!```console
10//! $ temp_convert 7 -u c -c k
11//! ```
12//!
13//! ## Usage
14//! ```console
15//! temp_convert OPTIONS VALUE
16//! ```
17//!
18//! Arguments:
19//!
20//! VALUE, Temperature value to convert
21//!
22//! Options:
23//!   
24//!   -u, --unit
25//!           Temperature unit of the provided value (Celsius, Fahrenheit, or Kelvin)
26//!
27//!   -c, --convert
28//!           Target temperature unit to convert the value to (Celsius, Fahrenheit, or Kelvin)
29//!
30//!   -h, --help
31//!           Print help (see a summary with '-h')
32//!
33//!   -V, --version
34//!           Print version
35//!
36
37/// Constant/helpers
38pub mod utils;
39
40use std::error::Error;
41
42use crate::utils::{
43    ABS_ZERO_CELSIUS, ABS_ZERO_FAHRENHEIT, ABS_ZERO_KELVIN, COLOR_GREEN, COLOR_RESET,
44};
45use clap::Parser;
46
47/// Tempeature unit
48#[derive(clap::ValueEnum, Clone, Debug)]
49enum Unit {
50    #[value(alias = "celsius")]
51    C,
52
53    #[value(alias = "fahrenheit")]
54    F,
55
56    #[value(alias = "kelvin")]
57    K,
58}
59
60impl Unit {
61    /// Unit absolute zero value
62    fn absolute_zero(&self) -> f64 {
63        match self {
64            Unit::C => ABS_ZERO_CELSIUS,
65            Unit::F => ABS_ZERO_FAHRENHEIT,
66            Unit::K => ABS_ZERO_KELVIN,
67        }
68    }
69
70    fn full_name(&self) -> &str {
71        match self {
72            Unit::C => "Celsius",
73            Unit::F => "Fahrenheit",
74            Unit::K => "Kelvin",
75        }
76    }
77
78    /// Convert a temperature value from the current unit to Celsius
79    fn to_celsius(&self, value: f64) -> f64 {
80        match self {
81            Unit::C => value,
82            Unit::F => (value - 32.0) * 5.0 / 9.0,
83            Unit::K => value - 273.15,
84        }
85    }
86
87    /// Convert a temperature value from Celsius to the current unit
88    fn from_celsius(&self, celsius: f64) -> f64 {
89        match self {
90            Unit::C => celsius,
91            Unit::F => (celsius * 9.0 / 5.0) + 32.0,
92            Unit::K => celsius + 273.15,
93        }
94    }
95}
96
97/// Converts temperature values between Celsius, Fahrenheit, and Kelvin
98#[derive(Parser, Debug)]
99#[command(
100    version,
101    about = "Convert temperatures between Celsius, Fahrenheit, and Kelvin.",
102    long_about = "Converts temperature values between Celsius, Fahrenheit, and Kelvin."
103)]
104pub struct Args {
105    /// Temperature value to convert
106    #[arg(allow_hyphen_values = true)]
107    value: f64,
108
109    /// Temperature unit of the provided value (Celsius, Fahrenheit, or Kelvin)
110    #[arg(short = 'u', long = "unit", ignore_case = true, default_value = "f")]
111    value_unit: Unit,
112
113    /// Target temperature unit to convert the value to (Celsius, Fahrenheit, or Kelvin)
114    #[arg(
115        short = 'c',
116        long = "convert",
117        value_enum,
118        ignore_case = true,
119        default_value = "c"
120    )]
121    convert: Unit,
122}
123
124impl Args {
125    /// Run/execute command line arguments
126    pub fn run(self) -> Result<String, Box<dyn Error>> {
127        // Validate value
128        let min: f64 = self.value_unit.absolute_zero();
129        if self.value < min {
130            return Err(format!(
131                "Value {} is below absolute zero for {} ({})",
132                self.value,
133                self.value_unit.full_name(),
134                min
135            )
136            .into());
137        }
138
139        // Convert value
140        let result: f64 = self
141            .convert
142            .from_celsius(self.value_unit.to_celsius(self.value));
143
144        Ok(format!(
145            "{}{:.2}°{} is {:.2}°{}{}",
146            COLOR_GREEN,
147            self.value,
148            self.value_unit.full_name(),
149            result,
150            self.convert.full_name(),
151            COLOR_RESET
152        ))
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    // Constants and helpers
161    const PACKAGE_NAME: &str = env!("CARGO_PKG_NAME");
162
163    /// Small constant to handle floating-point precision issues
164    const EPSILON: f64 = 1e-10;
165    fn assert_approx_eq(a: f64, b: f64) {
166        assert!(
167            (a - b).abs() < EPSILON,
168            "Assertion failed: {} is not approximately {}",
169            a,
170            b
171        );
172    }
173
174    /// Check if the output/result string contains the expected substrings.
175    /// Ignores color code constant/strings
176    fn contains_all(output: &str, sub_strings: &[&str]) -> bool {
177        sub_strings.iter().all(|&n| output.contains(n))
178    }
179
180    #[test]
181    fn test_absolute_zero_values() {
182        assert_eq!(Unit::C.absolute_zero(), ABS_ZERO_CELSIUS);
183        assert_eq!(Unit::F.absolute_zero(), ABS_ZERO_FAHRENHEIT);
184        assert_eq!(Unit::K.absolute_zero(), ABS_ZERO_KELVIN);
185    }
186
187    #[test]
188    fn test_full_names() {
189        assert_eq!(Unit::C.full_name(), "Celsius");
190        assert_eq!(Unit::F.full_name(), "Fahrenheit");
191        assert_eq!(Unit::K.full_name(), "Kelvin");
192    }
193
194    #[test]
195    fn test_to_celsius() {
196        // From Fahrenheit
197        assert_approx_eq(Unit::F.to_celsius(32.0), 0.0);
198        assert_approx_eq(Unit::F.to_celsius(212.0), 100.0);
199        assert_approx_eq(Unit::F.to_celsius(-40.0), -40.0);
200
201        // From Kelvin
202        assert_approx_eq(Unit::K.to_celsius(273.15), 0.0);
203        assert_approx_eq(Unit::K.to_celsius(0.0), -273.15);
204
205        // From Celsius
206        assert_approx_eq(Unit::C.to_celsius(25.0), 25.0);
207    }
208
209    #[test]
210    fn test_from_celsius() {
211        // To Fahrenheit
212        assert_approx_eq(Unit::F.from_celsius(0.0), 32.0);
213        assert_approx_eq(Unit::F.from_celsius(100.0), 212.0);
214        assert_approx_eq(Unit::F.from_celsius(-40.0), -40.0);
215
216        // To Kelvin
217        assert_approx_eq(Unit::K.from_celsius(0.0), 273.15);
218        assert_approx_eq(Unit::K.from_celsius(-273.15), 0.0);
219
220        // To Celsius
221        assert_approx_eq(Unit::C.from_celsius(36.6), 36.6);
222    }
223
224    #[test]
225    fn test_round_trip_conversion() {
226        let original_temp: f64 = 98.6; // Body temp in Fahrenheit
227        let celsius: f64 = Unit::F.to_celsius(original_temp);
228        let back_to_f: f64 = Unit::F.from_celsius(celsius);
229
230        assert_approx_eq(original_temp, back_to_f);
231    }
232
233    // CLI/Args
234    #[test]
235    fn test_valid_conversion_f_to_c() {
236        let args: Args = Args {
237            value: 32.0,
238            value_unit: Unit::F,
239            convert: Unit::C,
240        };
241
242        let output: String = args.run().expect("Failed conversion");
243        assert!(contains_all(
244            &output,
245            &["32.00", Unit::F.full_name(), "0.00", Unit::C.full_name()]
246        ));
247    }
248
249    #[test]
250    fn test_valid_conversion_c_to_k() {
251        let args: Args = Args {
252            value: 0.0,
253            value_unit: Unit::C,
254            convert: Unit::K,
255        };
256
257        let output: String = args.run().expect("Failed conversion");
258        assert!(contains_all(
259            &output,
260            &["0.00", Unit::C.full_name(), "273.15", Unit::K.full_name()]
261        ));
262    }
263
264    #[test]
265    fn test_absolute_zero_c_error() {
266        let args: Args = Args {
267            value: ABS_ZERO_CELSIUS - 1.0,
268            value_unit: Unit::C,
269            convert: Unit::F,
270        };
271
272        let output: Result<String, Box<dyn Error>> = args.run();
273        assert!(output.is_err());
274        let error_msg: String = output.unwrap_err().to_string();
275        assert!(error_msg.contains("below absolute zero"));
276        assert!(error_msg.contains(Unit::C.full_name()));
277        assert!(error_msg.contains(&ABS_ZERO_CELSIUS.to_string()));
278    }
279
280    #[test]
281    fn test_absolute_zero_f_error() {
282        let args: Args = Args {
283            value: ABS_ZERO_FAHRENHEIT - 1.0,
284            value_unit: Unit::F,
285            convert: Unit::C,
286        };
287
288        let output: Result<String, Box<dyn Error>> = args.run();
289        assert!(output.is_err());
290        let error_msg: String = output.unwrap_err().to_string();
291        assert!(error_msg.contains("below absolute zero"));
292        assert!(error_msg.contains(Unit::F.full_name()));
293        assert!(error_msg.contains(&ABS_ZERO_FAHRENHEIT.to_string()));
294    }
295
296    #[test]
297    fn test_absolute_zero_k_error() {
298        let args: Args = Args {
299            value: ABS_ZERO_KELVIN - 1.0,
300            value_unit: Unit::K,
301            convert: Unit::C,
302        };
303
304        let output: Result<String, Box<dyn Error>> = args.run();
305        assert!(output.is_err());
306        let error_msg: String = output.unwrap_err().to_string();
307        assert!(error_msg.contains("below absolute zero"));
308        assert!(error_msg.contains(Unit::K.full_name()));
309        assert!(error_msg.contains(&ABS_ZERO_KELVIN.to_string()));
310    }
311
312    #[test]
313    fn test_negative_c_allowed() {
314        let args: Args = Args {
315            value: -40.0,
316            value_unit: Unit::C,
317            convert: Unit::F,
318        };
319
320        let output: String = args
321            .run()
322            .expect("Should allow negative Celsius above absolute zero");
323        assert!(output.contains("-40.00"));
324    }
325
326    #[test]
327    fn test_negative_f_allowed() {
328        let args: Args = Args {
329            value: -40.0,
330            value_unit: Unit::F,
331            convert: Unit::C,
332        };
333
334        let output: String = args
335            .run()
336            .expect("Should allow negative Fahrenheit above absolute zero");
337        assert!(output.contains("-40.00"));
338    }
339
340    #[test]
341    fn test_conversion_crossover_point() {
342        // -40 Celsius is -40 Fahrenheit
343        let args = Args {
344            value: -40.0,
345            value_unit: Unit::C,
346            convert: Unit::F,
347        };
348
349        let output: String = args.run().expect("Failed conversion");
350        assert!(output.contains("-40.00"));
351    }
352
353    #[test]
354    fn test_parsing_defaults() {
355        let args: Args = Args::parse_from([PACKAGE_NAME, "100"]);
356        assert_eq!(args.value, 100.0);
357        assert!(matches!(args.value_unit, Unit::F));
358        assert!(matches!(args.convert, Unit::C));
359    }
360}