Beginner Projects

Temperature Converter

A temperature converter parses temperature measurements across Celsius (C), Fahrenheit (F), and Kelvin (K), validates physical lower bounds (Absolute Zero: $0\text{ K} = -273.15^\circ\text{C} = -459.67^\circ\text{F}$), and converts accurately between scales using Kelvin as the canonical intermediate representation.

Canonical Intermediate Conversion Pattern (N Scales = 2N Functions Instead of N*(N-1)!):

  Celsius (`C`)    ---[ C + 273.15 ]--->              ---[ K - 273.15 ]---> Celsius (`C`)
  Fahrenheit (`F`) ---[ (F+459.67)*5/9 ]-> [ Kelvin ] ---[ K*9/5 - 459.67 ]-> Fahrenheit (`F`)
  Kelvin (`K`)     ---[ Identity ]------->  (`>= 0K`) ---[ Identity ]-------> Kelvin (`K`)

Scale Formulas and Physical Bounds

Scale Symbol Absolute Zero Minimum To Kelvin (to_kelvin) From Kelvin (from_kelvin)
Celsius °C $-273.15^\circ\text{C}$ $K = C + 273.15$ $C = K - 273.15$
Fahrenheit °F $-459.67^\circ\text{F}$ $K = (F - 32) \times \frac{5}{9} + 273.15$ $F = (K - 273.15) \times \frac{9}{5} + 32$
Kelvin K $0.0\text{ K}$ $K = K$ $K = K$

Complete Runnable Implementation

use std::fmt;
use std::str::FromStr;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scale {
    Celsius,
    Fahrenheit,
    Kelvin,
}

impl Scale {
    pub const fn symbol(self) -> &'static str {
        match self {
            Scale::Celsius => "°C",
            Scale::Fahrenheit => "°F",
            Scale::Kelvin => "K",
        }
    }
}

#[derive(Debug, PartialEq)]
pub enum TempError {
    EmptyInput,
    InvalidFormat(String),
    UnknownScale(String),
    BelowAbsoluteZero { value: f64, scale: Scale },
    NonFiniteValue,
}

impl fmt::Display for TempError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            TempError::EmptyInput => write!(f, "empty temperature input"),
            TempError::InvalidFormat(s) => write!(f, "invalid temperature format: '{s}'"),
            TempError::UnknownScale(u) => write!(f, "unknown temperature unit: '{u}'"),
            TempError::BelowAbsoluteZero { value, scale } => write!(
                f,
                "{value:.2}{} is below absolute zero (0 K)",
                scale.symbol()
            ),
            TempError::NonFiniteValue => write!(f, "temperature must be a finite number"),
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Temperature {
    kelvin: f64, // Canonical internal representation (`kelvin >= 0.0`)
    scale: Scale,
}

impl Temperature {
    pub fn new(value: f64, scale: Scale) -> Result<Self, TempError> {
        if !value.is_finite() {
            return Err(TempError::NonFiniteValue);
        }
        let kelvin = match scale {
            Scale::Celsius => value + 273.15,
            Scale::Fahrenheit => (value - 32.0) * (5.0 / 9.0) + 273.15,
            Scale::Kelvin => value,
        };
        // Allow tiny IEEE 754 rounding tolerance (-1e-9) at exact absolute zero:
        if kelvin < -1e-9 {
            return Err(TempError::BelowAbsoluteZero { value, scale });
        }
        Ok(Self {
            kelvin: kelvin.max(0.0),
            scale,
        })
    }

    pub const fn convert_to(self, target: Scale) -> Self {
        Self {
            kelvin: self.kelvin,
            scale: target,
        }
    }

    pub fn value(self) -> f64 {
        match self.scale {
            Scale::Celsius => self.kelvin - 273.15,
            Scale::Fahrenheit => (self.kelvin - 273.15) * (9.0 / 5.0) + 32.0,
            Scale::Kelvin => self.kelvin,
        }
    }

    pub const fn scale(self) -> Scale {
        self.scale
    }
}

impl fmt::Display for Temperature {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{:.2}{}", self.value(), self.scale.symbol())
    }
}

/// Parses strings such as `"100C"`, `"-40 °F"`, or `"300 K"` into a validated `Temperature`.
impl FromStr for Temperature {
    type Err = TempError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let trimmed = s.trim();
        if trimmed.is_empty() {
            return Err(TempError::EmptyInput);
        }

        // Find the split point where the numeric prefix ends and the unit suffix begins:
        let unit_start = trimmed
            .find(|c: char| !c.is_ascii_digit() && c != '.' && c != '-' && c != '+')
            .ok_or_else(|| TempError::InvalidFormat(trimmed.to_string()))?;

        let (num_part, unit_part) = trimmed.split_at(unit_start);
        let value = num_part
            .trim()
            .parse::<f64>()
            .map_err(|_| TempError::InvalidFormat(trimmed.to_string()))?;

        let unit_clean = unit_part.trim().trim_start_matches('°').to_ascii_uppercase();
        let scale = match unit_clean.as_str() {
            "C" | "CELSIUS" => Scale::Celsius,
            "F" | "FAHRENHEIT" => Scale::Fahrenheit,
            "K" | "KELVIN" => Scale::Kelvin,
            _ => return Err(TempError::UnknownScale(unit_part.trim().to_string())),
        };

        Self::new(value, scale)
    }
}

fn main() {
    let boiling: Temperature = "100 °C".parse().unwrap();
    let in_f = boiling.convert_to(Scale::Fahrenheit);
    let in_k = boiling.convert_to(Scale::Kelvin);

    assert!((in_f.value() - 212.0).abs() < 1e-6);
    assert!((in_k.value() - 373.15).abs() < 1e-6);
    println!("{boiling} = {in_f} = {in_k}");

    // Verify -40°C == -40°F crossover point:
    let minus_40_c: Temperature = "-40C".parse().unwrap();
    println!("{minus_40_c} in Fahrenheit = {}", minus_40_c.convert_to(Scale::Fahrenheit));

    // Verify temperatures below Absolute Zero (-300°C) are rejected:
    let impossible = "-300C".parse::<Temperature>();
    println!("Parsing '-300C' -> {}", impossible.unwrap_err());
}
100.00°C = 212.00°F = 373.15K
-40.00°C in Fahrenheit = -40.00°F
Parsing '-300C' -> -300.00°C is below absolute zero (0 K)