Skip to main content

ssg/cmd/
error.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! CLI error types and the type-safe `LanguageCode` wrapper.
5
6use serde::{Deserialize, Serialize};
7
8/// Type-safe representation of a language code.
9///
10/// # Examples
11/// ```
12/// use ssg::cmd::LanguageCode;
13/// assert!(LanguageCode::new("en-GB").is_ok());
14/// assert!(LanguageCode::new("invalid").is_err());
15/// ```
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17pub struct LanguageCode(String);
18
19impl LanguageCode {
20    /// Creates a new `LanguageCode` instance from a string.
21    pub fn new(code: &str) -> Result<Self, CliError> {
22        if code.len() != 5 || code.chars().nth(2) != Some('-') {
23            return Err(CliError::ValidationError(
24                "Invalid language code format".into(),
25            ));
26        }
27
28        let (lang, region) = code.split_at(2);
29        let region = &region[1..]; // Skip hyphen
30
31        if !lang.chars().all(|c| c.is_ascii_lowercase()) {
32            return Err(CliError::ValidationError(
33                "Language code must be lowercase".into(),
34            ));
35        }
36
37        if !region.chars().all(|c| c.is_ascii_uppercase()) {
38            return Err(CliError::ValidationError(
39                "Region code must be uppercase".into(),
40            ));
41        }
42
43        Ok(Self(code.to_string()))
44    }
45}
46
47impl std::fmt::Display for LanguageCode {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        write!(f, "{}", self.0)
50    }
51}
52
53/// Possible errors that can occur during CLI operations.
54#[derive(Debug)]
55#[non_exhaustive]
56pub enum CliError {
57    /// Error indicating an invalid path with additional details.
58    InvalidPath {
59        /// Field name where the path is used.
60        field: String,
61        /// Additional details about the invalid path.
62        details: String,
63    },
64
65    /// Error indicating a missing required argument.
66    MissingArgument(String),
67
68    /// Error indicating an invalid URL.
69    InvalidUrl(String),
70
71    /// Error indicating an I/O error.
72    IoError(std::io::Error),
73
74    /// Error indicating a TOML parsing error.
75    TomlError(toml::de::Error),
76
77    /// Error indicating a validation error.
78    ValidationError(String),
79}
80
81impl std::fmt::Display for CliError {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        match self {
84            Self::InvalidPath { field, details } => {
85                write!(f, "Invalid path '{field}': {details}")
86            }
87            Self::MissingArgument(arg) => {
88                write!(f, "Required argument missing: {arg}")
89            }
90            Self::InvalidUrl(url) => write!(f, "Invalid URL: {url}"),
91            Self::IoError(e) => write!(f, "IO error: {e}"),
92            Self::TomlError(e) => write!(f, "TOML parsing error: {e}"),
93            Self::ValidationError(msg) => {
94                write!(f, "Validation error: {msg}")
95            }
96        }
97    }
98}
99
100impl std::error::Error for CliError {
101    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
102        match self {
103            Self::IoError(e) => Some(e),
104            Self::TomlError(e) => Some(e),
105            _ => None,
106        }
107    }
108}
109
110impl From<std::io::Error> for CliError {
111    fn from(e: std::io::Error) -> Self {
112        Self::IoError(e)
113    }
114}
115
116impl From<toml::de::Error> for CliError {
117    fn from(e: toml::de::Error) -> Self {
118        Self::TomlError(e)
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn test_language_code() {
128        assert!(LanguageCode::new("en-GB").is_ok());
129        assert!(LanguageCode::new("en-gb").is_err());
130        assert!(LanguageCode::new("EN-GB").is_err());
131        assert!(LanguageCode::new("e-GB").is_err());
132    }
133
134    #[test]
135    fn test_language_code_display() {
136        let code = LanguageCode::new("en-GB").unwrap();
137        assert_eq!(code.to_string(), "en-GB");
138    }
139
140    #[test]
141    fn test_language_code_edge_cases() {
142        assert!(LanguageCode::new("enGB").is_err());
143        assert!(LanguageCode::new("e-G").is_err());
144        assert!(LanguageCode::new("").is_err());
145    }
146
147    // -----------------------------------------------------------------
148    // CliError Display impl -- each variant
149    // -----------------------------------------------------------------
150
151    #[test]
152    fn cli_error_display_invalid_path() {
153        let err = CliError::InvalidPath {
154            field: "content_dir".into(),
155            details: "contains backslashes".into(),
156        };
157        let msg = format!("{err}");
158        assert!(msg.contains("content_dir"));
159        assert!(msg.contains("contains backslashes"));
160    }
161
162    #[test]
163    fn cli_error_display_missing_argument() {
164        let err = CliError::MissingArgument("site_name".into());
165        let msg = format!("{err}");
166        assert!(msg.contains("site_name"));
167        assert!(msg.contains("missing"));
168    }
169
170    #[test]
171    fn cli_error_display_invalid_url() {
172        let err = CliError::InvalidUrl("bad://url".into());
173        let msg = format!("{err}");
174        assert!(msg.contains("bad://url"));
175    }
176
177    #[test]
178    fn cli_error_display_io_error() {
179        let io_err =
180            std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
181        let err = CliError::IoError(io_err);
182        let msg = format!("{err}");
183        assert!(msg.contains("file not found"));
184    }
185
186    #[test]
187    fn cli_error_display_toml_error() {
188        let toml_err: toml::de::Error =
189            toml::from_str::<crate::cmd::SsgConfig>("invalid {{{").unwrap_err();
190        let err = CliError::TomlError(toml_err);
191        let msg = format!("{err}");
192        assert!(msg.contains("TOML"));
193    }
194
195    #[test]
196    fn cli_error_display_validation_error() {
197        let err = CliError::ValidationError("name too long".into());
198        let msg = format!("{err}");
199        assert!(msg.contains("name too long"));
200        assert!(msg.contains("Validation"));
201    }
202
203    #[test]
204    fn cli_error_source_io_returns_some() {
205        // Covers line 103 — IoError arm of Error::source().
206        let io = std::io::Error::other("x");
207        let err = CliError::IoError(io);
208        use std::error::Error;
209        assert!(err.source().is_some());
210    }
211
212    #[test]
213    fn cli_error_source_toml_returns_some() {
214        // Covers line 104 — TomlError arm of Error::source().
215        let toml_err: toml::de::Error =
216            toml::from_str::<crate::cmd::SsgConfig>("invalid {{{").unwrap_err();
217        let err = CliError::TomlError(toml_err);
218        use std::error::Error;
219        assert!(err.source().is_some());
220    }
221
222    #[test]
223    fn cli_error_source_other_variants_return_none() {
224        // Covers line 105 — `_ => None` arm.
225        use std::error::Error;
226        let cases = [
227            CliError::InvalidPath {
228                field: "f".into(),
229                details: "d".into(),
230            },
231            CliError::MissingArgument("a".into()),
232            CliError::InvalidUrl("u".into()),
233            CliError::ValidationError("v".into()),
234        ];
235        for err in &cases {
236            assert!(err.source().is_none(), "expected None for {err:?}");
237        }
238    }
239
240    #[test]
241    fn cli_error_from_io_error_via_from_impl() {
242        // Covers the From<io::Error> conversion.
243        let io = std::io::Error::other("x");
244        let err: CliError = io.into();
245        // Debug-format check keeps this assertion region-free — a
246        // `matches!` here would leave its never-taken false arm
247        // uncovered.
248        assert!(format!("{err:?}").starts_with("IoError"));
249    }
250
251    #[test]
252    fn cli_error_from_toml_error_via_from_impl() {
253        // Covers the From<toml::de::Error> conversion.
254        let toml_err: toml::de::Error =
255            toml::from_str::<crate::cmd::SsgConfig>("bad {{{").unwrap_err();
256        let err: CliError = toml_err.into();
257        assert!(format!("{err:?}").starts_with("TomlError"));
258    }
259}