1use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17pub struct LanguageCode(String);
18
19impl LanguageCode {
20 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 = ®ion[1..]; 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#[derive(Debug)]
55#[non_exhaustive]
56pub enum CliError {
57 InvalidPath {
59 field: String,
61 details: String,
63 },
64
65 MissingArgument(String),
67
68 InvalidUrl(String),
70
71 IoError(std::io::Error),
73
74 TomlError(toml::de::Error),
76
77 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 #[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 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 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 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 let io = std::io::Error::other("x");
244 let err: CliError = io.into();
245 assert!(format!("{err:?}").starts_with("IoError"));
249 }
250
251 #[test]
252 fn cli_error_from_toml_error_via_from_impl() {
253 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}