1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3use std::path::{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 nitpick_ignore: Vec<(String, String)>,
127
128 pub nitpick_ignore_regex: Vec<(String, String)>,
131
132 pub tags: Vec<String>,
134
135 pub doctree_dir: Option<std::path::PathBuf>,
138
139 pub html_context: std::collections::BTreeMap<String, serde_json::Value>,
147
148 pub conf_values: std::collections::BTreeMap<String, serde_json::Value>,
158
159 pub validate_directives: bool,
161
162 pub numfig: bool,
166
167 pub numfig_format: std::collections::BTreeMap<String, String>,
177
178 pub numfig_secnum_depth: u32,
183
184 pub source_encoding: String,
195
196 #[serde(skip)]
205 pub confval_type_mismatches: Vec<(String, String)>,
206
207 pub maximum_signature_line_length: Option<i64>,
222
223 pub python_maximum_signature_line_length: Option<i64>,
227
228 pub python_trailing_comma_in_multi_line_signatures: bool,
231
232 pub python_display_short_literal_types: bool,
235
236 pub python_use_unqualified_type_names: bool,
239
240 pub toc_object_entries: bool,
242
243 pub toc_object_entries_show_parents: String,
248
249 pub add_function_parentheses: bool,
253
254 pub add_module_names: bool,
257
258 pub strip_signature_backslash: bool,
262
263 pub modindex_common_prefix: Vec<String>,
267
268 pub intersphinx_mapping: crate::intersphinx::IntersphinxMapping,
274
275 pub intersphinx_disabled_reftypes: Vec<String>,
280
281 pub intersphinx_resolve_self: String,
285
286 pub intersphinx_cache_limit: i64,
289
290 pub intersphinx_timeout: Option<f64>,
293
294 pub tls_verify: bool,
296
297 pub tls_cacerts: Option<crate::intersphinx::TlsCacerts>,
300
301 pub user_agent: Option<String>,
304}
305
306pub const DEFAULT_SOURCE_ENCODING: &str = crate::rst::DEFAULT_SOURCE_ENCODING;
308
309const NONE_DEFAULT_INT_KEYS: [&str; 2] = [
314 "maximum_signature_line_length",
315 "python_maximum_signature_line_length",
316];
317
318const UTF8_SPELLINGS: [&str; 3] = ["utf-8", "utf-8-sig", "utf8"];
321
322pub const TOC_OBJECT_ENTRIES_SHOW_PARENTS: [&str; 3] = ["domain", "all", "hide"];
326
327pub fn default_numfig_format() -> std::collections::BTreeMap<String, String> {
330 [
331 ("section", "Section %s"),
332 ("figure", "Fig. %s"),
333 ("table", "Table %s"),
334 ("code-block", "Listing %s"),
335 ]
336 .into_iter()
337 .map(|(k, v)| (k.to_string(), v.to_string()))
338 .collect()
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
342#[serde(default)]
343pub struct OutputConfig {
344 pub html_theme: String,
346
347 pub syntax_highlighting: bool,
349
350 pub highlight_theme: String,
352
353 pub search_index: bool,
355
356 pub minify_html: bool,
358
359 pub compress_output: bool,
361}
362
363#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
364#[serde(default)]
365pub struct ThemeConfig {
366 pub name: String,
368
369 pub options: serde_json::Value,
371
372 pub custom_css: Vec<PathBuf>,
374
375 pub custom_js: Vec<PathBuf>,
377}
378
379#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
380#[serde(default)]
381pub struct OptimizationConfig {
382 pub parallel_processing: bool,
384
385 pub incremental_builds: bool,
387
388 pub document_caching: bool,
390
391 pub image_optimization: bool,
393
394 pub asset_bundling: bool,
396}
397
398impl Default for BuildConfig {
399 fn default() -> Self {
400 Self {
401 parallel_jobs: None,
402 max_cache_size_mb: 500,
403 cache_expiration_hours: 24,
404 output: OutputConfig::default(),
405 theme: ThemeConfig::default(),
406 extensions: vec![
407 "sphinx.ext.autodoc".to_string(),
408 "sphinx.ext.viewcode".to_string(),
409 "sphinx.ext.intersphinx".to_string(),
410 ],
411 template_dirs: vec![],
412 static_dirs: vec![],
413 optimization: OptimizationConfig::default(),
414
415 project: "Sphinx Ultra Project".to_string(),
417 version: None,
421 release: None,
422 copyright: Some("2024, Sphinx Ultra".to_string()),
423 language: Some("en".to_string()),
424 root_doc: Some("index".to_string()),
425 html_style: vec!["sphinx_rtd_theme.css".to_string()],
426 html_css_files: vec![],
427 html_js_files: vec![],
428 html_static_path: vec![PathBuf::from("_static")],
429 html_logo: None,
430 html_favicon: None,
431 html_title: None,
432 html_short_title: None,
433 html_show_copyright: Some(true),
434 html_show_sphinx: Some(true),
435 html_copy_source: Some(true),
436 html_show_sourcelink: Some(true),
437 html_sourcelink_suffix: Some(".txt".to_string()),
438 html_use_index: Some(true),
439 html_use_opensearch: Some(false),
440 html_last_updated_fmt: Some("%b %d, %Y".to_string()),
441 templates_path: vec![PathBuf::from("_templates")],
442
443 fail_on_warning: false,
445
446 include_patterns: vec!["**".to_string()],
448 exclude_patterns: vec![],
449
450 nitpicky: false,
451 nitpick_ignore: vec![],
452 nitpick_ignore_regex: vec![],
453 tags: vec![],
454 doctree_dir: None,
455 html_context: std::collections::BTreeMap::new(),
456 conf_values: std::collections::BTreeMap::new(),
457 validate_directives: true,
458
459 numfig: false,
460 numfig_format: default_numfig_format(),
461 numfig_secnum_depth: 1,
462 source_encoding: DEFAULT_SOURCE_ENCODING.to_string(),
463 confval_type_mismatches: Vec::new(),
464
465 maximum_signature_line_length: None,
468 python_maximum_signature_line_length: None,
469 python_trailing_comma_in_multi_line_signatures: true,
470 python_display_short_literal_types: false,
471 python_use_unqualified_type_names: false,
472 toc_object_entries: true,
473 toc_object_entries_show_parents: "domain".to_string(),
474 add_function_parentheses: true,
475 add_module_names: true,
476 strip_signature_backslash: false,
477 modindex_common_prefix: Vec::new(),
478
479 intersphinx_mapping: Default::default(),
480 intersphinx_disabled_reftypes: vec!["std:doc".to_string()],
481 intersphinx_resolve_self: String::new(),
482 intersphinx_cache_limit: 5,
483 intersphinx_timeout: None,
484 tls_verify: true,
485 tls_cacerts: None,
486 user_agent: None,
487 }
488 }
489}
490
491impl Default for OutputConfig {
492 fn default() -> Self {
493 Self {
494 html_theme: "sphinx_rtd_theme".to_string(),
495 syntax_highlighting: true,
496 highlight_theme: "github".to_string(),
497 search_index: true,
498 minify_html: false,
499 compress_output: false,
500 }
501 }
502}
503
504impl Default for ThemeConfig {
505 fn default() -> Self {
506 Self {
507 name: "sphinx_rtd_theme".to_string(),
508 options: serde_json::json!({}),
509 custom_css: vec![],
510 custom_js: vec![],
511 }
512 }
513}
514
515impl Default for OptimizationConfig {
516 fn default() -> Self {
517 Self {
518 parallel_processing: true,
519 incremental_builds: true,
520 document_caching: true,
521 image_optimization: false,
522 asset_bundling: false,
523 }
524 }
525}
526
527impl BuildConfig {
528 pub fn from_file<P: AsRef<std::path::Path>>(path: P) -> Result<Self> {
529 let path = path.as_ref();
530
531 let is_python = path.file_name().and_then(|s| s.to_str()) == Some("conf.py")
534 || path.extension().and_then(|s| s.to_str()) == Some("py");
535 if is_python {
536 return Self::from_conf_py(path);
537 }
538
539 let content = std::fs::read_to_string(path)
540 .map_err(|e| anyhow::anyhow!("cannot read config file {}: {e}", path.display()))?;
541 let config = if path.extension().and_then(|s| s.to_str()) == Some("yaml")
542 || path.extension().and_then(|s| s.to_str()) == Some("yml")
543 {
544 serde_yaml::from_str(&content)
545 .map_err(|e| anyhow::anyhow!("invalid config file {}: {e}", path.display()))?
546 } else {
547 serde_json::from_str(&content)
548 .map_err(|e| anyhow::anyhow!("invalid config file {}: {e}", path.display()))?
549 };
550 Ok(config)
551 }
552
553 pub fn from_conf_py<P: AsRef<std::path::Path>>(conf_py_path: P) -> Result<Self> {
555 let conf_py_path = conf_py_path.as_ref();
556 let mut parser = PythonConfigParser::new()?;
557 let conf_py_config = parser.parse_conf_py(conf_py_path)?;
558 for warning in parser.warnings() {
561 log::warn!(
562 "{}:{}: {}",
563 conf_py_path.display(),
564 warning.line,
565 warning.message
566 );
567 }
568 conf_py_config.to_build_config()
569 }
570
571 pub fn auto_detect<P: AsRef<std::path::Path>>(source_dir: P) -> Result<Self> {
573 let source_dir = source_dir.as_ref();
574
575 let conf_py_path = source_dir.join("conf.py");
577 if conf_py_path.exists() {
578 return Self::from_conf_py(conf_py_path);
579 }
580
581 let yaml_path = source_dir.join("sphinx-ultra.yaml");
583 if yaml_path.exists() {
584 return Self::from_file(yaml_path);
585 }
586
587 let yml_path = source_dir.join("sphinx-ultra.yml");
589 if yml_path.exists() {
590 return Self::from_file(yml_path);
591 }
592
593 let json_path = source_dir.join("sphinx-ultra.json");
595 if json_path.exists() {
596 return Self::from_file(json_path);
597 }
598
599 Ok(Self::default())
601 }
602
603 pub fn validate(&self) -> Vec<String> {
628 let mut warnings = Vec::new();
629
630 if !UTF8_SPELLINGS.contains(&self.source_encoding.to_lowercase().as_str()) {
632 warnings.push(
633 "Support for source encodings other than UTF-8 is deprecated and will be \
634 removed in Sphinx 10. Please comment at \
635 https://github.com/sphinx-doc/sphinx/issues/13665 if this causes a problem."
636 .to_string(),
637 );
638 }
639 if !crate::rst::block::is_supported_encoding(&self.source_encoding) {
644 warnings.push(format!(
645 "source_encoding '{}' is not an encoding sphinx-ultra can decode \
646 (utf-8, utf-8-sig, ascii, latin-1); included files will be read as \
647 '{DEFAULT_SOURCE_ENCODING}'",
648 self.source_encoding
649 ));
650 }
651
652 if !TOC_OBJECT_ENTRIES_SHOW_PARENTS.contains(&self.toc_object_entries_show_parents.as_str())
654 {
655 let candidates = TOC_OBJECT_ENTRIES_SHOW_PARENTS
656 .iter()
657 .map(|value| format!("'{value}'"))
658 .collect::<Vec<_>>()
659 .join(", ");
660 warnings.push(format!(
661 "The config value `toc_object_entries_show_parents` has to be a one of \
662 frozenset({{{candidates}}}), but `{}` is given.",
663 self.toc_object_entries_show_parents
664 ));
665 }
666 for key in NONE_DEFAULT_INT_KEYS {
671 if let Some((_, type_name)) = self
672 .confval_type_mismatches
673 .iter()
674 .find(|(mismatched, _)| mismatched == key)
675 {
676 warnings.push(format!(
677 "The config value `{key}' has type `{type_name}'; expected `NoneType' or \
678 `int'."
679 ));
680 }
681 }
682 warnings
683 }
684
685 pub fn note_confval_type_mismatch(&mut self, key: &str, type_name: &str) {
689 if !self
690 .confval_type_mismatches
691 .iter()
692 .any(|(mismatched, _)| mismatched == key)
693 {
694 self.confval_type_mismatches
695 .push((key.to_string(), type_name.to_string()));
696 }
697 }
698
699 pub fn validate_html_logo_and_favicon(&mut self, confdir: &Path) -> Vec<String> {
705 let mut messages = Vec::new();
706 let mut check = |key: &str, label: &str, field: &mut Option<String>| {
707 let Some(value) = field.as_deref() else {
708 return;
709 };
710 if value.is_empty() || value.contains("://") || confdir.join(value).is_file() {
712 return;
713 }
714 messages.push(format!(
715 "{label} file {} does not exist",
716 crate::utils::py_repr_str(value)
717 ));
718 *field = None;
719 self.conf_values
720 .insert(key.to_string(), serde_json::Value::Null);
721 };
722 let mut html_logo = self.html_logo.take();
723 check("html_logo", "logo", &mut html_logo);
724 let mut html_favicon = self.html_favicon.take();
725 check("html_favicon", "favicon", &mut html_favicon);
726 self.html_logo = html_logo;
727 self.html_favicon = html_favicon;
728 messages
729 }
730
731 pub fn apply_override(&mut self, key: &str, value: &str) -> Result<Option<String>> {
740 match key {
743 "html_theme" => {
744 self.apply_override("output.html_theme", value)?;
745 let result = self.apply_override("theme.name", value)?;
746 self.conf_values.insert(
747 key.to_string(),
748 serde_json::Value::String(value.to_string()),
749 );
750 return Ok(result);
751 }
752 "templates_path" => {
753 self.apply_override("template_dirs", value)?;
754 }
756 "html_static_path" => {
757 self.apply_override("static_dirs", value)?;
758 }
760 _ => {}
761 }
762
763 if NONE_DEFAULT_INT_KEYS.contains(&key) {
774 match key {
775 "maximum_signature_line_length" => self.maximum_signature_line_length = None,
776 _ => self.python_maximum_signature_line_length = None,
777 }
778 self.note_confval_type_mismatch(key, "str");
779 return Ok(None);
780 }
781
782 let mut tree = serde_json::to_value(&*self)?;
783
784 let mut slot = &mut tree;
789 for part in key.split('.') {
790 slot = match slot {
791 serde_json::Value::Object(map) => map
792 .entry(part.to_string())
793 .or_insert(serde_json::Value::Null),
794 _ => {
795 return Ok(Some(format!(
796 "unknown config value '{}' in override, ignoring",
797 key
798 )))
799 }
800 };
801 }
802
803 if slot.is_object() {
806 return Ok(Some(format!(
807 "cannot override dictionary config setting '{}', ignoring (use -D {}.key=value)",
808 key, key
809 )));
810 }
811
812 let coerced = Self::coerce_override_value(slot, key, value)?;
813 let retry_as_string = matches!(coerced, serde_json::Value::Number(_))
814 && matches!(slot, serde_json::Value::Null);
815 *slot = coerced;
816
817 let mut applied: Self = match serde_json::from_value(tree.clone()) {
818 Ok(config) => config,
819 Err(first_err) => {
823 if retry_as_string {
824 let mut retry_tree = tree;
825 let mut retry_slot = &mut retry_tree;
826 for part in key.split('.') {
827 retry_slot = retry_slot.get_mut(part).expect("path resolved above");
828 }
829 *retry_slot = serde_json::Value::String(value.to_string());
830 serde_json::from_value(retry_tree).map_err(|e| {
831 anyhow::anyhow!("invalid value for -D {}={}: {}", key, value, e)
832 })?
833 } else {
834 return Err(anyhow::anyhow!(
835 "invalid value for -D {}={}: {}",
836 key,
837 value,
838 first_err
839 ));
840 }
841 }
842 };
843
844 let check = serde_json::to_value(&applied)?;
847 let mut probe = Some(&check);
848 for part in key.split('.') {
849 probe = probe.and_then(|v| v.get(part));
850 }
851 if probe.is_none() {
852 return Ok(Some(format!(
853 "unknown config value '{}' in override, ignoring",
854 key
855 )));
856 }
857
858 applied.confval_type_mismatches = std::mem::take(&mut self.confval_type_mismatches);
860 if !key.contains('.') && key != "conf_values" {
864 if let Some(value) = check.get(key) {
865 applied.conf_values.insert(key.to_string(), value.clone());
866 }
867 }
868 *self = applied;
869 Ok(None)
870 }
871
872 fn coerce_override_value(
874 current: &serde_json::Value,
875 key: &str,
876 value: &str,
877 ) -> Result<serde_json::Value> {
878 use serde_json::Value;
879 Ok(match current {
880 Value::Bool(_) => match value {
881 "1" | "true" | "True" => Value::Bool(true),
882 "0" | "false" | "False" => Value::Bool(false),
883 other => anyhow::bail!("invalid boolean for -D {}={}", key, other),
884 },
885 Value::Number(_) => value
886 .parse::<i64>()
887 .map(Value::from)
888 .or_else(|_| value.parse::<f64>().map(Value::from))
889 .map_err(|_| anyhow::anyhow!("invalid number for -D {}={}", key, value))?,
890 Value::Array(_) => Value::Array(
891 value
892 .split(',')
893 .filter(|s| !s.is_empty())
894 .map(|s| Value::String(s.trim().to_string()))
895 .collect(),
896 ),
897 Value::Null => value
903 .parse::<i64>()
904 .map(Value::from)
905 .or_else(|_| value.parse::<f64>().map(Value::from))
906 .unwrap_or_else(|_| Value::String(value.to_string())),
907 _ => Value::String(value.to_string()),
908 })
909 }
910
911 #[allow(dead_code)]
912 pub fn save_to_file<P: AsRef<std::path::Path>>(&self, path: P) -> Result<()> {
913 let content = if path.as_ref().extension().and_then(|s| s.to_str()) == Some("yaml")
914 || path.as_ref().extension().and_then(|s| s.to_str()) == Some("yml")
915 {
916 serde_yaml::to_string(self)?
917 } else {
918 serde_json::to_string_pretty(self)?
919 };
920 std::fs::write(path, content)?;
921 Ok(())
922 }
923}
924#[cfg(test)]
925mod tests {
926 use super::*;
927 use std::fs;
928 use std::path::Path;
929 use tempfile::TempDir;
930
931 #[test]
932 fn minimal_yaml_loads_with_defaults() {
933 let temp_dir = TempDir::new().unwrap();
934 let p = temp_dir.path().join("sphinx-ultra.yaml");
935 fs::write(&p, "project: 'Tiny'\n").unwrap();
936
937 let config = BuildConfig::from_file(&p).unwrap();
938 assert_eq!(config.project, "Tiny");
939 assert_eq!(config.max_cache_size_mb, 500); assert_eq!(config.include_patterns, vec!["**".to_string()]);
941 }
942
943 #[test]
944 fn from_file_routes_conf_py() {
945 let temp_dir = TempDir::new().unwrap();
946 let p = temp_dir.path().join("conf.py");
947 fs::write(&p, "project = 'PyProject'\n").unwrap();
948
949 let config = BuildConfig::from_file(&p).unwrap();
950 assert_eq!(config.project, "PyProject");
951 }
952
953 #[test]
954 fn shipped_yaml_examples_load() {
955 for rel in ["sphinx-ultra.yaml", "examples/basic/sphinx-ultra.yaml"] {
956 let p = Path::new(env!("CARGO_MANIFEST_DIR")).join(rel);
957 BuildConfig::from_file(&p).unwrap_or_else(|e| panic!("{rel} failed to load: {e}"));
958 }
959 }
960
961 #[test]
962 fn test_auto_detect_conf_py() {
963 let temp_dir = TempDir::new().unwrap();
964 let root = temp_dir.path();
965
966 fs::write(root.join("conf.py"), "project = 'Test Project'\n").unwrap();
967
968 let config = BuildConfig::auto_detect(root).unwrap();
969 assert_eq!(config.project, "Test Project");
970 }
971
972 #[test]
973 fn test_auto_detect_yaml() {
974 let temp_dir = TempDir::new().unwrap();
975 let root = temp_dir.path();
976
977 let yaml_content = r#"
978project: 'YAML Project'
979output:
980 html_theme: 'alabaster'
981"#;
982 fs::write(root.join("sphinx-ultra.yaml"), yaml_content).unwrap();
983
984 let config = BuildConfig::auto_detect(root).unwrap();
985 assert_eq!(config.project, "YAML Project");
986 }
987
988 #[test]
989 fn test_auto_detect_default() {
990 let temp_dir = TempDir::new().unwrap();
991 let root = temp_dir.path();
992
993 let config = BuildConfig::auto_detect(root).unwrap();
995 assert_eq!(config, BuildConfig::default());
996 }
997
998 #[test]
999 fn override_string_bool_number_and_list() {
1000 let mut config = BuildConfig::default();
1001 config.apply_override("project", "Custom").unwrap();
1002 assert_eq!(config.project, "Custom");
1003
1004 config.apply_override("fail_on_warning", "1").unwrap();
1005 assert!(config.fail_on_warning);
1006 config.apply_override("fail_on_warning", "False").unwrap();
1007 assert!(!config.fail_on_warning);
1008
1009 config.apply_override("max_cache_size_mb", "64").unwrap();
1010 assert_eq!(config.max_cache_size_mb, 64);
1011
1012 config
1013 .apply_override("exclude_patterns", "drafts/**,_scratch")
1014 .unwrap();
1015 assert_eq!(
1016 config.exclude_patterns,
1017 vec!["drafts/**".to_string(), "_scratch".to_string()]
1018 );
1019 }
1020
1021 #[test]
1022 fn override_dotted_path_reaches_nested_sections() {
1023 let mut config = BuildConfig::default();
1024 config.apply_override("output.minify_html", "true").unwrap();
1025 assert!(config.output.minify_html);
1026 }
1027
1028 #[test]
1029 fn override_html_theme_alias_syncs_both_copies() {
1030 let mut config = BuildConfig::default();
1031 config.apply_override("html_theme", "furo").unwrap();
1032 assert_eq!(config.output.html_theme, "furo");
1033 assert_eq!(config.theme.name, "furo");
1034 }
1035
1036 #[test]
1037 fn override_templates_path_syncs_template_dirs() {
1038 let mut config = BuildConfig::default();
1039 config
1040 .apply_override("templates_path", "_mytemplates")
1041 .unwrap();
1042 assert_eq!(config.templates_path, vec![PathBuf::from("_mytemplates")]);
1043 assert_eq!(config.template_dirs, vec![PathBuf::from("_mytemplates")]);
1044 }
1045
1046 #[test]
1047 fn override_unknown_key_is_ignored_not_error() {
1048 let mut config = BuildConfig::default();
1049 let before = config.clone();
1050 let warning = config.apply_override("totally_unknown_key", "1").unwrap();
1051 assert_eq!(config, before);
1052 assert!(warning.unwrap().contains("unknown config value"));
1053
1054 let warning = config.apply_override("output.bogus_knob", "1").unwrap();
1057 assert_eq!(config, before);
1058 assert!(warning.unwrap().contains("unknown config value"));
1059 }
1060
1061 #[test]
1062 fn override_option_number_field() {
1063 let mut config = BuildConfig::default();
1064 assert!(config
1065 .apply_override("parallel_jobs", "3")
1066 .unwrap()
1067 .is_none());
1068 assert_eq!(config.parallel_jobs, Some(3));
1069 }
1070
1071 #[test]
1072 fn override_bad_bool_is_an_error() {
1073 let mut config = BuildConfig::default();
1074 assert!(config.apply_override("nitpicky", "maybe").is_err());
1075 }
1076
1077 #[test]
1078 fn override_numeric_value_for_unset_string_option_stays_a_string() {
1079 let mut config = BuildConfig::default();
1082 assert!(config
1083 .apply_override("html_title", "2024")
1084 .unwrap()
1085 .is_none());
1086 assert_eq!(config.html_title, Some("2024".to_string()));
1087 }
1088
1089 #[test]
1090 fn override_dict_member_and_whole_dict() {
1091 let mut config = BuildConfig::default();
1092
1093 assert!(config
1095 .apply_override("html_context.banner", "on")
1096 .unwrap()
1097 .is_none());
1098 assert_eq!(
1099 config.html_context.get("banner"),
1100 Some(&serde_json::Value::String("on".to_string()))
1101 );
1102
1103 let before = config.clone();
1105 let warning = config.apply_override("html_context", "x").unwrap();
1106 assert_eq!(config, before);
1107 assert!(warning
1108 .unwrap()
1109 .contains("cannot override dictionary config setting"));
1110 }
1111
1112 #[test]
1113 fn intersphinx_and_http_defaults_match_sphinx() {
1114 let config = BuildConfig::default();
1115 assert!(config.intersphinx_mapping.is_empty());
1116 assert_eq!(
1117 config.intersphinx_disabled_reftypes,
1118 vec!["std:doc".to_string()],
1119 "the one default entry is what stops a bare `:doc:` resolving externally"
1120 );
1121 assert_eq!(config.intersphinx_resolve_self, "");
1122 assert_eq!(config.intersphinx_cache_limit, 5);
1123 assert_eq!(config.intersphinx_timeout, None);
1124 assert!(config.tls_verify);
1125 assert_eq!(config.tls_cacerts, None);
1126 assert_eq!(config.user_agent, None);
1127 }
1128
1129 #[test]
1130 fn an_invalid_intersphinx_mapping_fails_configuration_loading() {
1131 let temp_dir = TempDir::new().unwrap();
1134 let p = temp_dir.path().join("conf.py");
1135 fs::write(
1136 &p,
1137 "intersphinx_mapping = {'a': ('https://x/', None), 'b': ('https://x/', None)}\n",
1138 )
1139 .unwrap();
1140
1141 let err = BuildConfig::from_file(&p).expect_err("a duplicate target URI must abort");
1142 assert_eq!(
1143 err.to_string(),
1144 "Invalid `intersphinx_mapping` configuration (1 error)."
1145 );
1146 }
1147
1148 #[test]
1149 fn intersphinx_scalars_are_overridable_from_the_command_line() {
1150 let mut config = BuildConfig::default();
1151 assert!(config
1152 .apply_override("intersphinx_cache_limit", "-1")
1153 .unwrap()
1154 .is_none());
1155 assert_eq!(config.intersphinx_cache_limit, -1);
1156
1157 assert!(config
1158 .apply_override("intersphinx_disabled_reftypes", "std:doc,std:label")
1159 .unwrap()
1160 .is_none());
1161 assert_eq!(
1162 config.intersphinx_disabled_reftypes,
1163 vec!["std:doc".to_string(), "std:label".to_string()]
1164 );
1165
1166 assert!(config.apply_override("tls_verify", "0").unwrap().is_none());
1167 assert!(!config.tls_verify);
1168
1169 assert!(config
1172 .apply_override("intersphinx_timeout", "2.5")
1173 .unwrap()
1174 .is_none());
1175 assert_eq!(config.intersphinx_timeout, Some(2.5));
1176 assert!(config
1177 .apply_override("intersphinx_timeout", "5")
1178 .unwrap()
1179 .is_none());
1180 assert_eq!(config.intersphinx_timeout, Some(5.0));
1181 }
1182
1183 #[test]
1184 fn numfig_defaults_match_sphinx() {
1185 let config = BuildConfig::default();
1186 assert!(!config.numfig);
1187 assert_eq!(config.numfig_secnum_depth, 1);
1188 assert_eq!(config.numfig_format["section"], "Section %s");
1189 assert_eq!(config.numfig_format["figure"], "Fig. %s");
1190 assert_eq!(config.numfig_format["table"], "Table %s");
1191 assert_eq!(config.numfig_format["code-block"], "Listing %s");
1192 }
1193
1194 #[test]
1197 fn object_signature_and_py_domain_defaults_match_sphinx() {
1198 let config = BuildConfig::default();
1199 assert_eq!(config.maximum_signature_line_length, None);
1200 assert_eq!(config.python_maximum_signature_line_length, None);
1201 assert!(config.python_trailing_comma_in_multi_line_signatures);
1202 assert!(!config.python_display_short_literal_types);
1203 assert!(!config.python_use_unqualified_type_names);
1204 assert!(config.toc_object_entries);
1205 assert_eq!(config.toc_object_entries_show_parents, "domain");
1206 assert!(config.add_function_parentheses);
1207 assert!(config.add_module_names);
1208 assert!(!config.strip_signature_backslash);
1209 assert!(config.modindex_common_prefix.is_empty());
1210 }
1211
1212 #[test]
1223 fn a_none_default_int_key_overridden_from_the_command_line_warns_like_sphinx() {
1224 for value in ["20", "0", "abc", "None"] {
1225 let mut config = BuildConfig {
1226 maximum_signature_line_length: Some(60),
1227 ..Default::default()
1228 };
1229 assert!(config
1230 .apply_override("maximum_signature_line_length", value)
1231 .unwrap()
1232 .is_none());
1233 assert_eq!(
1234 config.maximum_signature_line_length, None,
1235 "{value}: never coerced, never kept as the old number"
1236 );
1237 assert!(config
1238 .apply_override("python_maximum_signature_line_length", value)
1239 .unwrap()
1240 .is_none());
1241 assert_eq!(config.python_maximum_signature_line_length, None);
1242 assert_eq!(
1243 config.validate(),
1244 vec![
1245 "The config value `maximum_signature_line_length' has type `str'; \
1246 expected `NoneType' or `int'."
1247 .to_string(),
1248 "The config value `python_maximum_signature_line_length' has type `str'; \
1249 expected `NoneType' or `int'."
1250 .to_string(),
1251 ],
1252 "{value}"
1253 );
1254 }
1255
1256 let mut config = BuildConfig::default();
1259 config
1260 .apply_override("maximum_signature_line_length", "1")
1261 .unwrap();
1262 config
1263 .apply_override("maximum_signature_line_length", "2")
1264 .unwrap();
1265 config.apply_override("nitpicky", "1").unwrap();
1266 assert_eq!(config.validate().len(), 1);
1267
1268 let mut config = BuildConfig::default();
1271 config
1272 .apply_override("maximum_signature_line_length", "20")
1273 .unwrap();
1274 config
1275 .apply_override("toc_object_entries_show_parents", "bogus")
1276 .unwrap();
1277 let warnings = config.validate();
1278 assert!(warnings[0].contains("toc_object_entries_show_parents"));
1279 assert!(warnings[1].contains("maximum_signature_line_length"));
1280 }
1281
1282 #[test]
1288 fn source_encoding_is_a_real_key_with_sphinxs_deprecation_warning() {
1289 let config = BuildConfig::default();
1290 assert_eq!(config.source_encoding, "utf-8-sig");
1291 assert!(config.validate().is_empty());
1292
1293 let deprecation = "Support for source encodings other than UTF-8 is deprecated and \
1294 will be removed in Sphinx 10. Please comment at \
1295 https://github.com/sphinx-doc/sphinx/issues/13665 if this causes \
1296 a problem.";
1297 for quiet in ["utf-8", "UTF-8", "utf8", "utf-8-sig", "UTF-8-SIG"] {
1298 let mut config = BuildConfig::default();
1299 assert!(config
1300 .apply_override("source_encoding", quiet)
1301 .unwrap()
1302 .is_none());
1303 assert_eq!(config.source_encoding, quiet);
1304 assert!(config.validate().is_empty(), "{quiet}");
1305 }
1306
1307 let mut config = BuildConfig::default();
1308 config.apply_override("source_encoding", "latin-1").unwrap();
1309 assert_eq!(config.validate(), vec![deprecation.to_string()]);
1310
1311 let mut config = BuildConfig::default();
1312 config.apply_override("source_encoding", "cp1252").unwrap();
1313 config
1314 .apply_override("maximum_signature_line_length", "20")
1315 .unwrap();
1316 let warnings = config.validate();
1317 assert_eq!(warnings.len(), 3, "{warnings:#?}");
1318 assert_eq!(warnings[0], deprecation);
1319 assert_eq!(
1320 warnings[1],
1321 "source_encoding 'cp1252' is not an encoding sphinx-ultra can decode (utf-8, \
1322 utf-8-sig, ascii, latin-1); included files will be read as 'utf-8-sig'"
1323 );
1324 assert!(warnings[2].starts_with("The config value `maximum_signature_line_length'"));
1325 }
1326
1327 #[test]
1328 fn object_signature_family_is_overridable_from_the_command_line() {
1329 let mut config = BuildConfig::default();
1330
1331 for key in [
1332 "python_trailing_comma_in_multi_line_signatures",
1333 "python_display_short_literal_types",
1334 "python_use_unqualified_type_names",
1335 "toc_object_entries",
1336 "add_function_parentheses",
1337 "add_module_names",
1338 "strip_signature_backslash",
1339 ] {
1340 assert!(config.apply_override(key, "0").unwrap().is_none(), "{key}");
1341 assert!(config.apply_override(key, "1").unwrap().is_none(), "{key}");
1342 }
1343 assert!(config.python_trailing_comma_in_multi_line_signatures);
1344 assert!(config.add_function_parentheses);
1345 assert!(config.strip_signature_backslash);
1346
1347 assert!(config
1348 .apply_override("toc_object_entries_show_parents", "hide")
1349 .unwrap()
1350 .is_none());
1351 assert_eq!(config.toc_object_entries_show_parents, "hide");
1352
1353 assert!(config
1354 .apply_override("modindex_common_prefix", "mypkg.,other.")
1355 .unwrap()
1356 .is_none());
1357 assert_eq!(
1358 config.modindex_common_prefix,
1359 vec!["mypkg.".to_string(), "other.".to_string()]
1360 );
1361 }
1362
1363 #[test]
1369 fn an_out_of_enum_toc_show_parents_warns_and_is_kept() {
1370 for accepted in ["domain", "all", "hide"] {
1371 let mut config = BuildConfig::default();
1372 config
1373 .apply_override("toc_object_entries_show_parents", accepted)
1374 .unwrap();
1375 assert!(
1376 config.validate().is_empty(),
1377 "{accepted} is one of the three ENUM values"
1378 );
1379 }
1380
1381 let mut config = BuildConfig::default();
1382 config
1383 .apply_override("toc_object_entries_show_parents", "bogus")
1384 .unwrap();
1385 let warnings = config.validate();
1386 assert_eq!(
1387 warnings,
1388 vec![
1389 "The config value `toc_object_entries_show_parents` has to be a one of \
1390 frozenset({'domain', 'all', 'hide'}), but `bogus` is given."
1391 .to_string()
1392 ]
1393 );
1394 assert_eq!(
1395 config.toc_object_entries_show_parents, "bogus",
1396 "sphinx keeps the rejected value rather than resetting it"
1397 );
1398
1399 let mut config = BuildConfig::default();
1401 config
1402 .apply_override("toc_object_entries_show_parents", "Domain")
1403 .unwrap();
1404 assert_eq!(config.validate().len(), 1);
1405 }
1406
1407 #[test]
1408 fn numfig_family_is_overridable_from_the_command_line() {
1409 let mut config = BuildConfig::default();
1410
1411 assert!(config.apply_override("numfig", "1").unwrap().is_none());
1413 assert!(config.numfig);
1414 assert!(config.apply_override("numfig", "0").unwrap().is_none());
1415 assert!(!config.numfig);
1416 assert!(config.apply_override("numfig", "true").unwrap().is_none());
1417 assert!(config.numfig);
1418 assert!(config.apply_override("numfig", "yes").is_err());
1419
1420 assert!(config
1421 .apply_override("numfig_secnum_depth", "2")
1422 .unwrap()
1423 .is_none());
1424 assert_eq!(config.numfig_secnum_depth, 2);
1425
1426 assert!(config
1429 .apply_override("numfig_format.figure", "Figure %s")
1430 .unwrap()
1431 .is_none());
1432 assert_eq!(config.numfig_format["figure"], "Figure %s");
1433 assert_eq!(config.numfig_format["table"], "Table %s");
1434 }
1435}