1use super::error::CliError;
7use super::validation::{validate_path_safety, validate_url};
8use super::{default_config, MAX_CONFIG_SIZE};
9use clap::ArgMatches;
10use log::{debug, error, info};
11use serde::{Deserialize, Serialize};
12use std::{
13 fs,
14 path::{Path, PathBuf},
15 str::FromStr,
16};
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct SsgConfig {
21 pub site_name: String,
23 pub content_dir: PathBuf,
25 pub output_dir: PathBuf,
27 pub template_dir: PathBuf,
29 pub serve_dir: Option<PathBuf>,
31 pub base_url: String,
33 pub site_title: String,
35 pub site_description: String,
37 pub language: String,
39 #[serde(default)]
41 pub i18n: Option<crate::i18n::I18nConfig>,
42}
43
44impl Default for SsgConfig {
45 fn default() -> Self {
46 default_config().as_ref().clone()
47 }
48}
49
50impl SsgConfig {
51 fn override_with_cli(
53 mut self,
54 matches: &ArgMatches,
55 ) -> Result<Self, CliError> {
56 if let Some(site_name) = matches.get_one::<String>("new") {
58 self.site_name.clone_from(site_name);
59 }
60
61 if let Some(content_dir) = matches.get_one::<PathBuf>("content") {
63 self.content_dir.clone_from(content_dir);
64 }
65
66 if let Some(output_dir) = matches.get_one::<PathBuf>("output") {
68 self.output_dir.clone_from(output_dir);
69 }
70
71 if let Some(template_dir) = matches.get_one::<PathBuf>("template") {
73 self.template_dir.clone_from(template_dir);
74 }
75
76 if let Some(serve_dir) = matches.get_one::<PathBuf>("serve") {
78 self.serve_dir = Some(serve_dir.clone());
79 }
80
81 self.validate()?;
85 Ok(self)
86 }
87 pub fn from_matches(matches: &ArgMatches) -> Result<Self, CliError> {
104 if let Some(config_path) = matches.get_one::<PathBuf>("config") {
105 let loaded_config = Self::from_file(config_path)?;
106 return Ok(loaded_config);
107 }
108
109 let config = Self::default();
111
112 let config = config.override_with_cli(matches)?;
114
115 Ok(config)
117 }
118 pub fn from_file(path: &Path) -> Result<Self, CliError> {
134 let metadata = fs::metadata(path)?;
135 if metadata.len() > MAX_CONFIG_SIZE as u64 {
136 return Err(CliError::ValidationError(format!(
137 "Config file too large (max {MAX_CONFIG_SIZE} bytes)"
138 )));
139 }
140
141 let content = fs::read_to_string(path)?;
142 let config: Self = toml::from_str(&content)?;
143 config.validate()?;
144 Ok(config)
145 }
146
147 pub fn validate(&self) -> Result<(), CliError> {
149 debug!("Validating config: {self:?}");
150
151 if self.site_name.trim().is_empty() {
152 error!("site_name cannot be empty");
153 return Err(CliError::ValidationError(
154 "site_name cannot be empty".into(),
155 ));
156 }
157
158 if !self.base_url.is_empty() {
159 validate_url(&self.base_url)?;
160 }
161
162 validate_path_safety(&self.content_dir, "content_dir")?;
163 validate_path_safety(&self.output_dir, "output_dir")?;
164 validate_path_safety(&self.template_dir, "template_dir")?;
165 if let Some(ref serve_dir) = self.serve_dir {
166 validate_path_safety(serve_dir, "serve_dir")?;
167 }
168
169 info!("Config validation successful");
170 Ok(())
171 }
172
173 #[must_use]
175 pub fn builder() -> SsgConfigBuilder {
176 SsgConfigBuilder::default()
177 }
178}
179
180impl FromStr for SsgConfig {
181 type Err = CliError;
182
183 fn from_str(s: &str) -> Result<Self, Self::Err> {
184 let config: Self = toml::from_str(s)?;
185 config.validate()?;
186 Ok(config)
187 }
188}
189
190#[derive(Debug, Clone, Default)]
192pub struct SsgConfigBuilder {
193 config: SsgConfig,
194}
195
196impl SsgConfigBuilder {
206 #[must_use]
208 pub fn site_name(mut self, name: String) -> Self {
209 self.config.site_name = name;
210 self
211 }
212 #[must_use]
214 pub fn base_url(mut self, url: String) -> Self {
215 self.config.base_url = url;
216 self
217 }
218 #[must_use]
220 pub fn content_dir(mut self, dir: PathBuf) -> Self {
221 self.config.content_dir = dir;
222 self
223 }
224 #[must_use]
226 pub fn output_dir(mut self, dir: PathBuf) -> Self {
227 self.config.output_dir = dir;
228 self
229 }
230 #[must_use]
232 pub fn template_dir(mut self, dir: PathBuf) -> Self {
233 self.config.template_dir = dir;
234 self
235 }
236 #[must_use]
238 pub fn serve_dir(mut self, dir: Option<PathBuf>) -> Self {
239 self.config.serve_dir = dir;
240 self
241 }
242 #[must_use]
244 pub fn site_title(mut self, title: String) -> Self {
245 self.config.site_title = title;
246 self
247 }
248 #[must_use]
250 pub fn site_description(mut self, desc: String) -> Self {
251 self.config.site_description = desc;
252 self
253 }
254 #[must_use]
256 pub fn language(mut self, lang: String) -> Self {
257 self.config.language = lang;
258 self
259 }
260 #[must_use]
262 pub fn i18n(mut self, i18n: Option<crate::i18n::I18nConfig>) -> Self {
263 self.config.i18n = i18n;
264 self
265 }
266 pub fn build(self) -> Result<SsgConfig, CliError> {
268 self.config.validate()?;
269 Ok(self.config)
270 }
271}
272
273#[cfg(test)]
274#[allow(clippy::unwrap_used, clippy::expect_used)]
275mod tests {
276 use super::*;
277 use crate::cmd::Cli;
278 use std::fs::File;
279 use std::io::Write;
280 use tempfile::tempdir;
281
282 #[test]
283 fn test_config_validation() {
284 let config = SsgConfig::builder().site_name(String::new()).build();
285 assert!(matches!(config, Err(CliError::ValidationError(_))));
286 }
287
288 #[test]
289 fn test_config_file_size_limit() {
290 let temp_dir = tempdir().unwrap();
291 let config_path = temp_dir.path().join("large.toml");
292 let mut file = File::create(&config_path).unwrap();
293
294 write!(file, "{}", "x".repeat(MAX_CONFIG_SIZE + 1)).unwrap();
295
296 assert!(matches!(
297 SsgConfig::from_file(&config_path),
298 Err(CliError::ValidationError(_))
299 ));
300 }
301
302 #[test]
303 fn test_config_from_str() {
304 let config_str = r#"
305 site_name = "test"
306 content_dir = "./examples/content"
307 output_dir = "./examples/public"
308 template_dir = "./examples/templates"
309 base_url = "http://example.com"
310 site_title = "Test Site"
311 site_description = "Test Description"
312 language = "en-GB"
313 "#;
314
315 let config: Result<SsgConfig, _> = config_str.parse();
316 assert!(config.is_ok());
317 }
318
319 #[test]
320 fn test_config_builder_all_fields() {
321 let temp_dir = tempdir().unwrap();
322 let serve_dir = temp_dir.path().join("serve");
323
324 fs::create_dir_all(&serve_dir).unwrap();
325
326 let config = SsgConfig::builder()
327 .site_name("test".to_string())
328 .base_url("http://example.com".to_string())
329 .content_dir(PathBuf::from("./examples/content"))
330 .output_dir(PathBuf::from("./examples/public"))
331 .template_dir(PathBuf::from("./examples/templates"))
332 .serve_dir(Some(serve_dir))
333 .site_title("Test Site".to_string())
334 .site_description("Test Desc".to_string())
335 .language("en-GB".to_string())
336 .build();
337
338 assert!(config.is_ok());
339 }
340
341 #[test]
342 fn test_invalid_config_file() {
343 let temp_dir = tempdir().unwrap();
344 let config_path = temp_dir.path().join("invalid.toml");
345 let mut file = File::create(&config_path).unwrap();
346 write!(file, "invalid toml content").unwrap();
347
348 assert!(matches!(
349 SsgConfig::from_file(&config_path),
350 Err(CliError::TomlError(_))
351 ));
352 }
353
354 #[test]
355 fn test_from_matches() {
356 let matches = Cli::build().get_matches_from(vec!["ssg"]);
357 let config = SsgConfig::from_matches(&matches);
358 assert!(config.is_ok());
359 }
360
361 #[test]
362 fn test_config_builder_empty_required_fields() {
363 let config = SsgConfig::builder()
364 .site_name(String::new())
365 .site_title(String::new())
366 .build();
367 assert!(matches!(config, Err(CliError::ValidationError(_))));
368 }
369
370 #[test]
371 fn test_config_file_not_found() {
372 let non_existent = Path::new("non_existent.toml");
373 assert!(matches!(
374 SsgConfig::from_file(non_existent),
375 Err(CliError::IoError(_))
376 ));
377 }
378
379 #[test]
380 fn test_from_matches_with_config_file() {
381 let temp_dir = tempdir().unwrap();
382 let config_path = temp_dir.path().join("config.toml");
383 let config_content = r#"
384site_name = "from-file"
385content_dir = "./examples/content"
386output_dir = "./examples/public"
387template_dir = "./examples/templates"
388base_url = "http://example.com"
389site_title = "File Site"
390site_description = "From file"
391language = "en-GB"
392"#;
393 fs::write(&config_path, config_content).unwrap();
394
395 let cmd = Cli::build();
396 let matches = cmd.get_matches_from(vec![
397 "ssg",
398 "--config",
399 config_path.to_str().unwrap(),
400 ]);
401 let config = SsgConfig::from_matches(&matches).unwrap();
402 assert_eq!(config.site_name, "from-file");
403 }
404
405 #[test]
406 fn test_override_with_cli_all_flags() {
407 let cmd = Cli::build();
408 let matches = cmd.get_matches_from(vec![
409 "ssg",
410 "--new",
411 "cli-site",
412 "--content",
413 "./examples/content",
414 "--output",
415 "./examples/public",
416 "--template",
417 "./examples/templates",
418 "--serve",
419 "./examples/public",
420 ]);
421 let config = SsgConfig::from_matches(&matches).unwrap();
422 assert_eq!(config.site_name, "cli-site");
423 assert_eq!(config.content_dir, PathBuf::from("./examples/content"));
424 assert_eq!(config.output_dir, PathBuf::from("./examples/public"));
425 assert_eq!(config.template_dir, PathBuf::from("./examples/templates"));
426 assert!(config.serve_dir.is_some());
427 }
428
429 #[test]
430 fn test_override_with_watch_flag() {
431 let cmd = Cli::build();
432 let matches = cmd.get_matches_from(vec!["ssg", "--watch"]);
433 let config = SsgConfig::from_matches(&matches).unwrap();
434 assert!(!config.site_name.is_empty());
435 }
436
437 #[test]
438 fn test_validate_empty_url() {
439 let config = SsgConfig::builder()
440 .site_name("test".to_string())
441 .base_url(String::new())
442 .build();
443 assert!(config.is_ok());
444 }
445
446 #[test]
451 fn test_config_from_file_valid_toml() {
452 let temp_dir = tempdir().unwrap();
453 let config_path = temp_dir.path().join("valid.toml");
454 let toml_content = r#"
455site_name = "TestSite"
456content_dir = "./examples/content"
457output_dir = "./examples/public"
458template_dir = "./examples/templates"
459base_url = "http://test.example.com"
460site_title = "Test Title"
461site_description = "A test site"
462language = "en-GB"
463"#;
464 fs::write(&config_path, toml_content).unwrap();
465
466 let config = SsgConfig::from_file(&config_path).unwrap();
467 assert_eq!(config.site_name, "TestSite");
468 assert_eq!(config.site_title, "Test Title");
469 assert_eq!(config.base_url, "http://test.example.com");
470 }
471}