1pub 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#[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 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 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 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#[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 #[arg(allow_hyphen_values = true)]
107 value: f64,
108
109 #[arg(short = 'u', long = "unit", ignore_case = true, default_value = "f")]
111 value_unit: Unit,
112
113 #[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 pub fn run(self) -> Result<String, Box<dyn Error>> {
127 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 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 const PACKAGE_NAME: &str = env!("CARGO_PKG_NAME");
162
163 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 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 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 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 assert_approx_eq(Unit::C.to_celsius(25.0), 25.0);
207 }
208
209 #[test]
210 fn test_from_celsius() {
211 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 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 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; 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 #[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 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}