1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4
5use crate::python_config::PythonConfigParser;
6
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
8#[serde(default)]
9pub struct BuildConfig {
10 pub parallel_jobs: Option<usize>,
12
13 pub max_cache_size_mb: usize,
15
16 pub cache_expiration_hours: u64,
18
19 pub output: OutputConfig,
21
22 pub theme: ThemeConfig,
24
25 pub extensions: Vec<String>,
27
28 pub template_dirs: Vec<PathBuf>,
30
31 pub static_dirs: Vec<PathBuf>,
33
34 pub optimization: OptimizationConfig,
36
37 pub project: String,
40
41 pub version: Option<String>,
43
44 pub release: Option<String>,
46
47 pub copyright: Option<String>,
49
50 pub language: Option<String>,
52
53 pub root_doc: Option<String>,
55
56 pub html_style: Vec<String>,
58
59 pub html_css_files: Vec<String>,
61
62 pub html_js_files: Vec<String>,
64
65 pub html_static_path: Vec<PathBuf>,
67
68 pub html_logo: Option<String>,
70
71 pub html_favicon: Option<String>,
73
74 pub html_title: Option<String>,
76
77 pub html_short_title: Option<String>,
79
80 pub html_show_copyright: Option<bool>,
82
83 pub html_show_sphinx: Option<bool>,
85
86 pub html_copy_source: Option<bool>,
88
89 pub html_show_sourcelink: Option<bool>,
91
92 pub html_sourcelink_suffix: Option<String>,
94
95 pub html_use_index: Option<bool>,
97
98 pub html_use_opensearch: Option<bool>,
100
101 pub html_last_updated_fmt: Option<String>,
103
104 pub templates_path: Vec<PathBuf>,
106
107 pub fail_on_warning: bool,
109
110 pub include_patterns: Vec<String>,
113
114 pub exclude_patterns: Vec<String>,
118
119 pub nitpicky: bool,
121
122 pub tags: Vec<String>,
124
125 pub doctree_dir: Option<std::path::PathBuf>,
128
129 pub html_context: std::collections::HashMap<String, serde_json::Value>,
131
132 pub validate_directives: bool,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
137#[serde(default)]
138pub struct OutputConfig {
139 pub html_theme: String,
141
142 pub syntax_highlighting: bool,
144
145 pub highlight_theme: String,
147
148 pub search_index: bool,
150
151 pub minify_html: bool,
153
154 pub compress_output: bool,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
159#[serde(default)]
160pub struct ThemeConfig {
161 pub name: String,
163
164 pub options: serde_json::Value,
166
167 pub custom_css: Vec<PathBuf>,
169
170 pub custom_js: Vec<PathBuf>,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
175#[serde(default)]
176pub struct OptimizationConfig {
177 pub parallel_processing: bool,
179
180 pub incremental_builds: bool,
182
183 pub document_caching: bool,
185
186 pub image_optimization: bool,
188
189 pub asset_bundling: bool,
191}
192
193impl Default for BuildConfig {
194 fn default() -> Self {
195 Self {
196 parallel_jobs: None,
197 max_cache_size_mb: 500,
198 cache_expiration_hours: 24,
199 output: OutputConfig::default(),
200 theme: ThemeConfig::default(),
201 extensions: vec![
202 "sphinx.ext.autodoc".to_string(),
203 "sphinx.ext.viewcode".to_string(),
204 "sphinx.ext.intersphinx".to_string(),
205 ],
206 template_dirs: vec![],
207 static_dirs: vec![],
208 optimization: OptimizationConfig::default(),
209
210 project: "Sphinx Ultra Project".to_string(),
212 version: Some("1.0.0".to_string()),
213 release: Some("1.0.0".to_string()),
214 copyright: Some("2024, Sphinx Ultra".to_string()),
215 language: Some("en".to_string()),
216 root_doc: Some("index".to_string()),
217 html_style: vec!["sphinx_rtd_theme.css".to_string()],
218 html_css_files: vec![],
219 html_js_files: vec![],
220 html_static_path: vec![PathBuf::from("_static")],
221 html_logo: None,
222 html_favicon: None,
223 html_title: None,
224 html_short_title: None,
225 html_show_copyright: Some(true),
226 html_show_sphinx: Some(true),
227 html_copy_source: Some(true),
228 html_show_sourcelink: Some(true),
229 html_sourcelink_suffix: Some(".txt".to_string()),
230 html_use_index: Some(true),
231 html_use_opensearch: Some(false),
232 html_last_updated_fmt: Some("%b %d, %Y".to_string()),
233 templates_path: vec![PathBuf::from("_templates")],
234
235 fail_on_warning: false,
237
238 include_patterns: vec!["**".to_string()],
240 exclude_patterns: vec![],
241
242 nitpicky: false,
243 tags: vec![],
244 doctree_dir: None,
245 html_context: std::collections::HashMap::new(),
246 validate_directives: true,
247 }
248 }
249}
250
251impl Default for OutputConfig {
252 fn default() -> Self {
253 Self {
254 html_theme: "sphinx_rtd_theme".to_string(),
255 syntax_highlighting: true,
256 highlight_theme: "github".to_string(),
257 search_index: true,
258 minify_html: false,
259 compress_output: false,
260 }
261 }
262}
263
264impl Default for ThemeConfig {
265 fn default() -> Self {
266 Self {
267 name: "sphinx_rtd_theme".to_string(),
268 options: serde_json::json!({}),
269 custom_css: vec![],
270 custom_js: vec![],
271 }
272 }
273}
274
275impl Default for OptimizationConfig {
276 fn default() -> Self {
277 Self {
278 parallel_processing: true,
279 incremental_builds: true,
280 document_caching: true,
281 image_optimization: false,
282 asset_bundling: false,
283 }
284 }
285}
286
287impl BuildConfig {
288 pub fn from_file<P: AsRef<std::path::Path>>(path: P) -> Result<Self> {
289 let path = path.as_ref();
290
291 let is_python = path.file_name().and_then(|s| s.to_str()) == Some("conf.py")
294 || path.extension().and_then(|s| s.to_str()) == Some("py");
295 if is_python {
296 return Self::from_conf_py(path);
297 }
298
299 let content = std::fs::read_to_string(path)
300 .map_err(|e| anyhow::anyhow!("cannot read config file {}: {e}", path.display()))?;
301 let config = if path.extension().and_then(|s| s.to_str()) == Some("yaml")
302 || path.extension().and_then(|s| s.to_str()) == Some("yml")
303 {
304 serde_yaml::from_str(&content)
305 .map_err(|e| anyhow::anyhow!("invalid config file {}: {e}", path.display()))?
306 } else {
307 serde_json::from_str(&content)
308 .map_err(|e| anyhow::anyhow!("invalid config file {}: {e}", path.display()))?
309 };
310 Ok(config)
311 }
312
313 pub fn from_conf_py<P: AsRef<std::path::Path>>(conf_py_path: P) -> Result<Self> {
315 let conf_py_path = conf_py_path.as_ref();
316 let mut parser = PythonConfigParser::new()?;
317 let conf_py_config = parser.parse_conf_py(conf_py_path)?;
318 for warning in parser.warnings() {
321 log::warn!(
322 "{}:{}: {}",
323 conf_py_path.display(),
324 warning.line,
325 warning.message
326 );
327 }
328 Ok(conf_py_config.to_build_config())
329 }
330
331 pub fn auto_detect<P: AsRef<std::path::Path>>(source_dir: P) -> Result<Self> {
333 let source_dir = source_dir.as_ref();
334
335 let conf_py_path = source_dir.join("conf.py");
337 if conf_py_path.exists() {
338 return Self::from_conf_py(conf_py_path);
339 }
340
341 let yaml_path = source_dir.join("sphinx-ultra.yaml");
343 if yaml_path.exists() {
344 return Self::from_file(yaml_path);
345 }
346
347 let yml_path = source_dir.join("sphinx-ultra.yml");
349 if yml_path.exists() {
350 return Self::from_file(yml_path);
351 }
352
353 let json_path = source_dir.join("sphinx-ultra.json");
355 if json_path.exists() {
356 return Self::from_file(json_path);
357 }
358
359 Ok(Self::default())
361 }
362
363 pub fn apply_override(&mut self, key: &str, value: &str) -> Result<Option<String>> {
372 match key {
375 "html_theme" => {
376 self.apply_override("output.html_theme", value)?;
377 return self.apply_override("theme.name", value);
378 }
379 "templates_path" => {
380 self.apply_override("template_dirs", value)?;
381 }
383 "html_static_path" => {
384 self.apply_override("static_dirs", value)?;
385 }
387 _ => {}
388 }
389
390 let mut tree = serde_json::to_value(&*self)?;
391
392 let mut slot = &mut tree;
397 for part in key.split('.') {
398 slot = match slot {
399 serde_json::Value::Object(map) => map
400 .entry(part.to_string())
401 .or_insert(serde_json::Value::Null),
402 _ => {
403 return Ok(Some(format!(
404 "unknown config value '{}' in override, ignoring",
405 key
406 )))
407 }
408 };
409 }
410
411 if slot.is_object() {
414 return Ok(Some(format!(
415 "cannot override dictionary config setting '{}', ignoring (use -D {}.key=value)",
416 key, key
417 )));
418 }
419
420 let coerced = Self::coerce_override_value(slot, key, value)?;
421 let retry_as_string = matches!(coerced, serde_json::Value::Number(_))
422 && matches!(slot, serde_json::Value::Null);
423 *slot = coerced;
424
425 let applied: Self = match serde_json::from_value(tree.clone()) {
426 Ok(config) => config,
427 Err(first_err) => {
431 if retry_as_string {
432 let mut retry_tree = tree;
433 let mut retry_slot = &mut retry_tree;
434 for part in key.split('.') {
435 retry_slot = retry_slot.get_mut(part).expect("path resolved above");
436 }
437 *retry_slot = serde_json::Value::String(value.to_string());
438 serde_json::from_value(retry_tree).map_err(|e| {
439 anyhow::anyhow!("invalid value for -D {}={}: {}", key, value, e)
440 })?
441 } else {
442 return Err(anyhow::anyhow!(
443 "invalid value for -D {}={}: {}",
444 key,
445 value,
446 first_err
447 ));
448 }
449 }
450 };
451
452 let check = serde_json::to_value(&applied)?;
455 let mut probe = Some(&check);
456 for part in key.split('.') {
457 probe = probe.and_then(|v| v.get(part));
458 }
459 if probe.is_none() {
460 return Ok(Some(format!(
461 "unknown config value '{}' in override, ignoring",
462 key
463 )));
464 }
465
466 *self = applied;
467 Ok(None)
468 }
469
470 fn coerce_override_value(
472 current: &serde_json::Value,
473 key: &str,
474 value: &str,
475 ) -> Result<serde_json::Value> {
476 use serde_json::Value;
477 Ok(match current {
478 Value::Bool(_) => match value {
479 "1" | "true" | "True" => Value::Bool(true),
480 "0" | "false" | "False" => Value::Bool(false),
481 other => anyhow::bail!("invalid boolean for -D {}={}", key, other),
482 },
483 Value::Number(_) => value
484 .parse::<i64>()
485 .map(Value::from)
486 .or_else(|_| value.parse::<f64>().map(Value::from))
487 .map_err(|_| anyhow::anyhow!("invalid number for -D {}={}", key, value))?,
488 Value::Array(_) => Value::Array(
489 value
490 .split(',')
491 .filter(|s| !s.is_empty())
492 .map(|s| Value::String(s.trim().to_string()))
493 .collect(),
494 ),
495 Value::Null => value
498 .parse::<i64>()
499 .map(Value::from)
500 .unwrap_or_else(|_| Value::String(value.to_string())),
501 _ => Value::String(value.to_string()),
502 })
503 }
504
505 #[allow(dead_code)]
506 pub fn save_to_file<P: AsRef<std::path::Path>>(&self, path: P) -> Result<()> {
507 let content = if path.as_ref().extension().and_then(|s| s.to_str()) == Some("yaml")
508 || path.as_ref().extension().and_then(|s| s.to_str()) == Some("yml")
509 {
510 serde_yaml::to_string(self)?
511 } else {
512 serde_json::to_string_pretty(self)?
513 };
514 std::fs::write(path, content)?;
515 Ok(())
516 }
517}
518#[cfg(test)]
519mod tests {
520 use super::*;
521 use std::fs;
522 use std::path::Path;
523 use tempfile::TempDir;
524
525 #[test]
526 fn minimal_yaml_loads_with_defaults() {
527 let temp_dir = TempDir::new().unwrap();
528 let p = temp_dir.path().join("sphinx-ultra.yaml");
529 fs::write(&p, "project: 'Tiny'\n").unwrap();
530
531 let config = BuildConfig::from_file(&p).unwrap();
532 assert_eq!(config.project, "Tiny");
533 assert_eq!(config.max_cache_size_mb, 500); assert_eq!(config.include_patterns, vec!["**".to_string()]);
535 }
536
537 #[test]
538 fn from_file_routes_conf_py() {
539 let temp_dir = TempDir::new().unwrap();
540 let p = temp_dir.path().join("conf.py");
541 fs::write(&p, "project = 'PyProject'\n").unwrap();
542
543 let config = BuildConfig::from_file(&p).unwrap();
544 assert_eq!(config.project, "PyProject");
545 }
546
547 #[test]
548 fn shipped_yaml_examples_load() {
549 for rel in ["sphinx-ultra.yaml", "examples/basic/sphinx-ultra.yaml"] {
550 let p = Path::new(env!("CARGO_MANIFEST_DIR")).join(rel);
551 BuildConfig::from_file(&p).unwrap_or_else(|e| panic!("{rel} failed to load: {e}"));
552 }
553 }
554
555 #[test]
556 fn test_auto_detect_conf_py() {
557 let temp_dir = TempDir::new().unwrap();
558 let root = temp_dir.path();
559
560 fs::write(root.join("conf.py"), "project = 'Test Project'\n").unwrap();
561
562 let config = BuildConfig::auto_detect(root).unwrap();
563 assert_eq!(config.project, "Test Project");
564 }
565
566 #[test]
567 fn test_auto_detect_yaml() {
568 let temp_dir = TempDir::new().unwrap();
569 let root = temp_dir.path();
570
571 let yaml_content = r#"
572project: 'YAML Project'
573output:
574 html_theme: 'alabaster'
575"#;
576 fs::write(root.join("sphinx-ultra.yaml"), yaml_content).unwrap();
577
578 let config = BuildConfig::auto_detect(root).unwrap();
579 assert_eq!(config.project, "YAML Project");
580 }
581
582 #[test]
583 fn test_auto_detect_default() {
584 let temp_dir = TempDir::new().unwrap();
585 let root = temp_dir.path();
586
587 let config = BuildConfig::auto_detect(root).unwrap();
589 assert_eq!(config, BuildConfig::default());
590 }
591
592 #[test]
593 fn override_string_bool_number_and_list() {
594 let mut config = BuildConfig::default();
595 config.apply_override("project", "Custom").unwrap();
596 assert_eq!(config.project, "Custom");
597
598 config.apply_override("fail_on_warning", "1").unwrap();
599 assert!(config.fail_on_warning);
600 config.apply_override("fail_on_warning", "False").unwrap();
601 assert!(!config.fail_on_warning);
602
603 config.apply_override("max_cache_size_mb", "64").unwrap();
604 assert_eq!(config.max_cache_size_mb, 64);
605
606 config
607 .apply_override("exclude_patterns", "drafts/**,_scratch")
608 .unwrap();
609 assert_eq!(
610 config.exclude_patterns,
611 vec!["drafts/**".to_string(), "_scratch".to_string()]
612 );
613 }
614
615 #[test]
616 fn override_dotted_path_reaches_nested_sections() {
617 let mut config = BuildConfig::default();
618 config.apply_override("output.minify_html", "true").unwrap();
619 assert!(config.output.minify_html);
620 }
621
622 #[test]
623 fn override_html_theme_alias_syncs_both_copies() {
624 let mut config = BuildConfig::default();
625 config.apply_override("html_theme", "furo").unwrap();
626 assert_eq!(config.output.html_theme, "furo");
627 assert_eq!(config.theme.name, "furo");
628 }
629
630 #[test]
631 fn override_templates_path_syncs_template_dirs() {
632 let mut config = BuildConfig::default();
633 config
634 .apply_override("templates_path", "_mytemplates")
635 .unwrap();
636 assert_eq!(config.templates_path, vec![PathBuf::from("_mytemplates")]);
637 assert_eq!(config.template_dirs, vec![PathBuf::from("_mytemplates")]);
638 }
639
640 #[test]
641 fn override_unknown_key_is_ignored_not_error() {
642 let mut config = BuildConfig::default();
643 let before = config.clone();
644 let warning = config.apply_override("totally_unknown_key", "1").unwrap();
645 assert_eq!(config, before);
646 assert!(warning.unwrap().contains("unknown config value"));
647
648 let warning = config.apply_override("output.bogus_knob", "1").unwrap();
651 assert_eq!(config, before);
652 assert!(warning.unwrap().contains("unknown config value"));
653 }
654
655 #[test]
656 fn override_option_number_field() {
657 let mut config = BuildConfig::default();
658 assert!(config
659 .apply_override("parallel_jobs", "3")
660 .unwrap()
661 .is_none());
662 assert_eq!(config.parallel_jobs, Some(3));
663 }
664
665 #[test]
666 fn override_bad_bool_is_an_error() {
667 let mut config = BuildConfig::default();
668 assert!(config.apply_override("nitpicky", "maybe").is_err());
669 }
670
671 #[test]
672 fn override_numeric_value_for_unset_string_option_stays_a_string() {
673 let mut config = BuildConfig::default();
676 assert!(config
677 .apply_override("html_title", "2024")
678 .unwrap()
679 .is_none());
680 assert_eq!(config.html_title, Some("2024".to_string()));
681 }
682
683 #[test]
684 fn override_dict_member_and_whole_dict() {
685 let mut config = BuildConfig::default();
686
687 assert!(config
689 .apply_override("html_context.banner", "on")
690 .unwrap()
691 .is_none());
692 assert_eq!(
693 config.html_context.get("banner"),
694 Some(&serde_json::Value::String("on".to_string()))
695 );
696
697 let before = config.clone();
699 let warning = config.apply_override("html_context", "x").unwrap();
700 assert_eq!(config, before);
701 assert!(warning
702 .unwrap()
703 .contains("cannot override dictionary config setting"));
704 }
705}