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 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 validate_directives: bool,
150
151 pub numfig: bool,
155
156 pub numfig_format: std::collections::BTreeMap<String, String>,
166
167 pub numfig_secnum_depth: u32,
172
173 pub source_encoding: String,
184
185 #[serde(skip)]
194 pub confval_type_mismatches: Vec<(String, String)>,
195
196 pub maximum_signature_line_length: Option<i64>,
211
212 pub python_maximum_signature_line_length: Option<i64>,
216
217 pub python_trailing_comma_in_multi_line_signatures: bool,
220
221 pub python_display_short_literal_types: bool,
224
225 pub python_use_unqualified_type_names: bool,
228
229 pub toc_object_entries: bool,
231
232 pub toc_object_entries_show_parents: String,
237
238 pub add_function_parentheses: bool,
242
243 pub add_module_names: bool,
246
247 pub strip_signature_backslash: bool,
251
252 pub modindex_common_prefix: Vec<String>,
256
257 pub intersphinx_mapping: crate::intersphinx::IntersphinxMapping,
263
264 pub intersphinx_disabled_reftypes: Vec<String>,
269
270 pub intersphinx_resolve_self: String,
274
275 pub intersphinx_cache_limit: i64,
278
279 pub intersphinx_timeout: Option<f64>,
282
283 pub tls_verify: bool,
285
286 pub tls_cacerts: Option<crate::intersphinx::TlsCacerts>,
289
290 pub user_agent: Option<String>,
293}
294
295pub const DEFAULT_SOURCE_ENCODING: &str = crate::rst::DEFAULT_SOURCE_ENCODING;
297
298const NONE_DEFAULT_INT_KEYS: [&str; 2] = [
303 "maximum_signature_line_length",
304 "python_maximum_signature_line_length",
305];
306
307const UTF8_SPELLINGS: [&str; 3] = ["utf-8", "utf-8-sig", "utf8"];
310
311pub const TOC_OBJECT_ENTRIES_SHOW_PARENTS: [&str; 3] = ["domain", "all", "hide"];
315
316pub fn default_numfig_format() -> std::collections::BTreeMap<String, String> {
319 [
320 ("section", "Section %s"),
321 ("figure", "Fig. %s"),
322 ("table", "Table %s"),
323 ("code-block", "Listing %s"),
324 ]
325 .into_iter()
326 .map(|(k, v)| (k.to_string(), v.to_string()))
327 .collect()
328}
329
330#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
331#[serde(default)]
332pub struct OutputConfig {
333 pub html_theme: String,
335
336 pub syntax_highlighting: bool,
338
339 pub highlight_theme: String,
341
342 pub search_index: bool,
344
345 pub minify_html: bool,
347
348 pub compress_output: bool,
350}
351
352#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
353#[serde(default)]
354pub struct ThemeConfig {
355 pub name: String,
357
358 pub options: serde_json::Value,
360
361 pub custom_css: Vec<PathBuf>,
363
364 pub custom_js: Vec<PathBuf>,
366}
367
368#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
369#[serde(default)]
370pub struct OptimizationConfig {
371 pub parallel_processing: bool,
373
374 pub incremental_builds: bool,
376
377 pub document_caching: bool,
379
380 pub image_optimization: bool,
382
383 pub asset_bundling: bool,
385}
386
387impl Default for BuildConfig {
388 fn default() -> Self {
389 Self {
390 parallel_jobs: None,
391 max_cache_size_mb: 500,
392 cache_expiration_hours: 24,
393 output: OutputConfig::default(),
394 theme: ThemeConfig::default(),
395 extensions: vec![
396 "sphinx.ext.autodoc".to_string(),
397 "sphinx.ext.viewcode".to_string(),
398 "sphinx.ext.intersphinx".to_string(),
399 ],
400 template_dirs: vec![],
401 static_dirs: vec![],
402 optimization: OptimizationConfig::default(),
403
404 project: "Sphinx Ultra Project".to_string(),
406 version: Some("1.0.0".to_string()),
407 release: Some("1.0.0".to_string()),
408 copyright: Some("2024, Sphinx Ultra".to_string()),
409 language: Some("en".to_string()),
410 root_doc: Some("index".to_string()),
411 html_style: vec!["sphinx_rtd_theme.css".to_string()],
412 html_css_files: vec![],
413 html_js_files: vec![],
414 html_static_path: vec![PathBuf::from("_static")],
415 html_logo: None,
416 html_favicon: None,
417 html_title: None,
418 html_short_title: None,
419 html_show_copyright: Some(true),
420 html_show_sphinx: Some(true),
421 html_copy_source: Some(true),
422 html_show_sourcelink: Some(true),
423 html_sourcelink_suffix: Some(".txt".to_string()),
424 html_use_index: Some(true),
425 html_use_opensearch: Some(false),
426 html_last_updated_fmt: Some("%b %d, %Y".to_string()),
427 templates_path: vec![PathBuf::from("_templates")],
428
429 fail_on_warning: false,
431
432 include_patterns: vec!["**".to_string()],
434 exclude_patterns: vec![],
435
436 nitpicky: false,
437 nitpick_ignore: vec![],
438 nitpick_ignore_regex: vec![],
439 tags: vec![],
440 doctree_dir: None,
441 html_context: std::collections::BTreeMap::new(),
442 validate_directives: true,
443
444 numfig: false,
445 numfig_format: default_numfig_format(),
446 numfig_secnum_depth: 1,
447 source_encoding: DEFAULT_SOURCE_ENCODING.to_string(),
448 confval_type_mismatches: Vec::new(),
449
450 maximum_signature_line_length: None,
453 python_maximum_signature_line_length: None,
454 python_trailing_comma_in_multi_line_signatures: true,
455 python_display_short_literal_types: false,
456 python_use_unqualified_type_names: false,
457 toc_object_entries: true,
458 toc_object_entries_show_parents: "domain".to_string(),
459 add_function_parentheses: true,
460 add_module_names: true,
461 strip_signature_backslash: false,
462 modindex_common_prefix: Vec::new(),
463
464 intersphinx_mapping: Default::default(),
465 intersphinx_disabled_reftypes: vec!["std:doc".to_string()],
466 intersphinx_resolve_self: String::new(),
467 intersphinx_cache_limit: 5,
468 intersphinx_timeout: None,
469 tls_verify: true,
470 tls_cacerts: None,
471 user_agent: None,
472 }
473 }
474}
475
476impl Default for OutputConfig {
477 fn default() -> Self {
478 Self {
479 html_theme: "sphinx_rtd_theme".to_string(),
480 syntax_highlighting: true,
481 highlight_theme: "github".to_string(),
482 search_index: true,
483 minify_html: false,
484 compress_output: false,
485 }
486 }
487}
488
489impl Default for ThemeConfig {
490 fn default() -> Self {
491 Self {
492 name: "sphinx_rtd_theme".to_string(),
493 options: serde_json::json!({}),
494 custom_css: vec![],
495 custom_js: vec![],
496 }
497 }
498}
499
500impl Default for OptimizationConfig {
501 fn default() -> Self {
502 Self {
503 parallel_processing: true,
504 incremental_builds: true,
505 document_caching: true,
506 image_optimization: false,
507 asset_bundling: false,
508 }
509 }
510}
511
512impl BuildConfig {
513 pub fn from_file<P: AsRef<std::path::Path>>(path: P) -> Result<Self> {
514 let path = path.as_ref();
515
516 let is_python = path.file_name().and_then(|s| s.to_str()) == Some("conf.py")
519 || path.extension().and_then(|s| s.to_str()) == Some("py");
520 if is_python {
521 return Self::from_conf_py(path);
522 }
523
524 let content = std::fs::read_to_string(path)
525 .map_err(|e| anyhow::anyhow!("cannot read config file {}: {e}", path.display()))?;
526 let config = if path.extension().and_then(|s| s.to_str()) == Some("yaml")
527 || path.extension().and_then(|s| s.to_str()) == Some("yml")
528 {
529 serde_yaml::from_str(&content)
530 .map_err(|e| anyhow::anyhow!("invalid config file {}: {e}", path.display()))?
531 } else {
532 serde_json::from_str(&content)
533 .map_err(|e| anyhow::anyhow!("invalid config file {}: {e}", path.display()))?
534 };
535 Ok(config)
536 }
537
538 pub fn from_conf_py<P: AsRef<std::path::Path>>(conf_py_path: P) -> Result<Self> {
540 let conf_py_path = conf_py_path.as_ref();
541 let mut parser = PythonConfigParser::new()?;
542 let conf_py_config = parser.parse_conf_py(conf_py_path)?;
543 for warning in parser.warnings() {
546 log::warn!(
547 "{}:{}: {}",
548 conf_py_path.display(),
549 warning.line,
550 warning.message
551 );
552 }
553 conf_py_config.to_build_config()
554 }
555
556 pub fn auto_detect<P: AsRef<std::path::Path>>(source_dir: P) -> Result<Self> {
558 let source_dir = source_dir.as_ref();
559
560 let conf_py_path = source_dir.join("conf.py");
562 if conf_py_path.exists() {
563 return Self::from_conf_py(conf_py_path);
564 }
565
566 let yaml_path = source_dir.join("sphinx-ultra.yaml");
568 if yaml_path.exists() {
569 return Self::from_file(yaml_path);
570 }
571
572 let yml_path = source_dir.join("sphinx-ultra.yml");
574 if yml_path.exists() {
575 return Self::from_file(yml_path);
576 }
577
578 let json_path = source_dir.join("sphinx-ultra.json");
580 if json_path.exists() {
581 return Self::from_file(json_path);
582 }
583
584 Ok(Self::default())
586 }
587
588 pub fn validate(&self) -> Vec<String> {
613 let mut warnings = Vec::new();
614
615 if !UTF8_SPELLINGS.contains(&self.source_encoding.to_lowercase().as_str()) {
617 warnings.push(
618 "Support for source encodings other than UTF-8 is deprecated and will be \
619 removed in Sphinx 10. Please comment at \
620 https://github.com/sphinx-doc/sphinx/issues/13665 if this causes a problem."
621 .to_string(),
622 );
623 }
624 if !crate::rst::block::is_supported_encoding(&self.source_encoding) {
629 warnings.push(format!(
630 "source_encoding '{}' is not an encoding sphinx-ultra can decode \
631 (utf-8, utf-8-sig, ascii, latin-1); included files will be read as \
632 '{DEFAULT_SOURCE_ENCODING}'",
633 self.source_encoding
634 ));
635 }
636
637 if !TOC_OBJECT_ENTRIES_SHOW_PARENTS.contains(&self.toc_object_entries_show_parents.as_str())
639 {
640 let candidates = TOC_OBJECT_ENTRIES_SHOW_PARENTS
641 .iter()
642 .map(|value| format!("'{value}'"))
643 .collect::<Vec<_>>()
644 .join(", ");
645 warnings.push(format!(
646 "The config value `toc_object_entries_show_parents` has to be a one of \
647 frozenset({{{candidates}}}), but `{}` is given.",
648 self.toc_object_entries_show_parents
649 ));
650 }
651 for key in NONE_DEFAULT_INT_KEYS {
656 if let Some((_, type_name)) = self
657 .confval_type_mismatches
658 .iter()
659 .find(|(mismatched, _)| mismatched == key)
660 {
661 warnings.push(format!(
662 "The config value `{key}' has type `{type_name}'; expected `NoneType' or \
663 `int'."
664 ));
665 }
666 }
667 warnings
668 }
669
670 pub fn note_confval_type_mismatch(&mut self, key: &str, type_name: &str) {
674 if !self
675 .confval_type_mismatches
676 .iter()
677 .any(|(mismatched, _)| mismatched == key)
678 {
679 self.confval_type_mismatches
680 .push((key.to_string(), type_name.to_string()));
681 }
682 }
683
684 pub fn apply_override(&mut self, key: &str, value: &str) -> Result<Option<String>> {
693 match key {
696 "html_theme" => {
697 self.apply_override("output.html_theme", value)?;
698 return self.apply_override("theme.name", value);
699 }
700 "templates_path" => {
701 self.apply_override("template_dirs", value)?;
702 }
704 "html_static_path" => {
705 self.apply_override("static_dirs", value)?;
706 }
708 _ => {}
709 }
710
711 if NONE_DEFAULT_INT_KEYS.contains(&key) {
722 match key {
723 "maximum_signature_line_length" => self.maximum_signature_line_length = None,
724 _ => self.python_maximum_signature_line_length = None,
725 }
726 self.note_confval_type_mismatch(key, "str");
727 return Ok(None);
728 }
729
730 let mut tree = serde_json::to_value(&*self)?;
731
732 let mut slot = &mut tree;
737 for part in key.split('.') {
738 slot = match slot {
739 serde_json::Value::Object(map) => map
740 .entry(part.to_string())
741 .or_insert(serde_json::Value::Null),
742 _ => {
743 return Ok(Some(format!(
744 "unknown config value '{}' in override, ignoring",
745 key
746 )))
747 }
748 };
749 }
750
751 if slot.is_object() {
754 return Ok(Some(format!(
755 "cannot override dictionary config setting '{}', ignoring (use -D {}.key=value)",
756 key, key
757 )));
758 }
759
760 let coerced = Self::coerce_override_value(slot, key, value)?;
761 let retry_as_string = matches!(coerced, serde_json::Value::Number(_))
762 && matches!(slot, serde_json::Value::Null);
763 *slot = coerced;
764
765 let mut applied: Self = match serde_json::from_value(tree.clone()) {
766 Ok(config) => config,
767 Err(first_err) => {
771 if retry_as_string {
772 let mut retry_tree = tree;
773 let mut retry_slot = &mut retry_tree;
774 for part in key.split('.') {
775 retry_slot = retry_slot.get_mut(part).expect("path resolved above");
776 }
777 *retry_slot = serde_json::Value::String(value.to_string());
778 serde_json::from_value(retry_tree).map_err(|e| {
779 anyhow::anyhow!("invalid value for -D {}={}: {}", key, value, e)
780 })?
781 } else {
782 return Err(anyhow::anyhow!(
783 "invalid value for -D {}={}: {}",
784 key,
785 value,
786 first_err
787 ));
788 }
789 }
790 };
791
792 let check = serde_json::to_value(&applied)?;
795 let mut probe = Some(&check);
796 for part in key.split('.') {
797 probe = probe.and_then(|v| v.get(part));
798 }
799 if probe.is_none() {
800 return Ok(Some(format!(
801 "unknown config value '{}' in override, ignoring",
802 key
803 )));
804 }
805
806 applied.confval_type_mismatches = std::mem::take(&mut self.confval_type_mismatches);
808 *self = applied;
809 Ok(None)
810 }
811
812 fn coerce_override_value(
814 current: &serde_json::Value,
815 key: &str,
816 value: &str,
817 ) -> Result<serde_json::Value> {
818 use serde_json::Value;
819 Ok(match current {
820 Value::Bool(_) => match value {
821 "1" | "true" | "True" => Value::Bool(true),
822 "0" | "false" | "False" => Value::Bool(false),
823 other => anyhow::bail!("invalid boolean for -D {}={}", key, other),
824 },
825 Value::Number(_) => value
826 .parse::<i64>()
827 .map(Value::from)
828 .or_else(|_| value.parse::<f64>().map(Value::from))
829 .map_err(|_| anyhow::anyhow!("invalid number for -D {}={}", key, value))?,
830 Value::Array(_) => Value::Array(
831 value
832 .split(',')
833 .filter(|s| !s.is_empty())
834 .map(|s| Value::String(s.trim().to_string()))
835 .collect(),
836 ),
837 Value::Null => value
843 .parse::<i64>()
844 .map(Value::from)
845 .or_else(|_| value.parse::<f64>().map(Value::from))
846 .unwrap_or_else(|_| Value::String(value.to_string())),
847 _ => Value::String(value.to_string()),
848 })
849 }
850
851 #[allow(dead_code)]
852 pub fn save_to_file<P: AsRef<std::path::Path>>(&self, path: P) -> Result<()> {
853 let content = if path.as_ref().extension().and_then(|s| s.to_str()) == Some("yaml")
854 || path.as_ref().extension().and_then(|s| s.to_str()) == Some("yml")
855 {
856 serde_yaml::to_string(self)?
857 } else {
858 serde_json::to_string_pretty(self)?
859 };
860 std::fs::write(path, content)?;
861 Ok(())
862 }
863}
864#[cfg(test)]
865mod tests {
866 use super::*;
867 use std::fs;
868 use std::path::Path;
869 use tempfile::TempDir;
870
871 #[test]
872 fn minimal_yaml_loads_with_defaults() {
873 let temp_dir = TempDir::new().unwrap();
874 let p = temp_dir.path().join("sphinx-ultra.yaml");
875 fs::write(&p, "project: 'Tiny'\n").unwrap();
876
877 let config = BuildConfig::from_file(&p).unwrap();
878 assert_eq!(config.project, "Tiny");
879 assert_eq!(config.max_cache_size_mb, 500); assert_eq!(config.include_patterns, vec!["**".to_string()]);
881 }
882
883 #[test]
884 fn from_file_routes_conf_py() {
885 let temp_dir = TempDir::new().unwrap();
886 let p = temp_dir.path().join("conf.py");
887 fs::write(&p, "project = 'PyProject'\n").unwrap();
888
889 let config = BuildConfig::from_file(&p).unwrap();
890 assert_eq!(config.project, "PyProject");
891 }
892
893 #[test]
894 fn shipped_yaml_examples_load() {
895 for rel in ["sphinx-ultra.yaml", "examples/basic/sphinx-ultra.yaml"] {
896 let p = Path::new(env!("CARGO_MANIFEST_DIR")).join(rel);
897 BuildConfig::from_file(&p).unwrap_or_else(|e| panic!("{rel} failed to load: {e}"));
898 }
899 }
900
901 #[test]
902 fn test_auto_detect_conf_py() {
903 let temp_dir = TempDir::new().unwrap();
904 let root = temp_dir.path();
905
906 fs::write(root.join("conf.py"), "project = 'Test Project'\n").unwrap();
907
908 let config = BuildConfig::auto_detect(root).unwrap();
909 assert_eq!(config.project, "Test Project");
910 }
911
912 #[test]
913 fn test_auto_detect_yaml() {
914 let temp_dir = TempDir::new().unwrap();
915 let root = temp_dir.path();
916
917 let yaml_content = r#"
918project: 'YAML Project'
919output:
920 html_theme: 'alabaster'
921"#;
922 fs::write(root.join("sphinx-ultra.yaml"), yaml_content).unwrap();
923
924 let config = BuildConfig::auto_detect(root).unwrap();
925 assert_eq!(config.project, "YAML Project");
926 }
927
928 #[test]
929 fn test_auto_detect_default() {
930 let temp_dir = TempDir::new().unwrap();
931 let root = temp_dir.path();
932
933 let config = BuildConfig::auto_detect(root).unwrap();
935 assert_eq!(config, BuildConfig::default());
936 }
937
938 #[test]
939 fn override_string_bool_number_and_list() {
940 let mut config = BuildConfig::default();
941 config.apply_override("project", "Custom").unwrap();
942 assert_eq!(config.project, "Custom");
943
944 config.apply_override("fail_on_warning", "1").unwrap();
945 assert!(config.fail_on_warning);
946 config.apply_override("fail_on_warning", "False").unwrap();
947 assert!(!config.fail_on_warning);
948
949 config.apply_override("max_cache_size_mb", "64").unwrap();
950 assert_eq!(config.max_cache_size_mb, 64);
951
952 config
953 .apply_override("exclude_patterns", "drafts/**,_scratch")
954 .unwrap();
955 assert_eq!(
956 config.exclude_patterns,
957 vec!["drafts/**".to_string(), "_scratch".to_string()]
958 );
959 }
960
961 #[test]
962 fn override_dotted_path_reaches_nested_sections() {
963 let mut config = BuildConfig::default();
964 config.apply_override("output.minify_html", "true").unwrap();
965 assert!(config.output.minify_html);
966 }
967
968 #[test]
969 fn override_html_theme_alias_syncs_both_copies() {
970 let mut config = BuildConfig::default();
971 config.apply_override("html_theme", "furo").unwrap();
972 assert_eq!(config.output.html_theme, "furo");
973 assert_eq!(config.theme.name, "furo");
974 }
975
976 #[test]
977 fn override_templates_path_syncs_template_dirs() {
978 let mut config = BuildConfig::default();
979 config
980 .apply_override("templates_path", "_mytemplates")
981 .unwrap();
982 assert_eq!(config.templates_path, vec![PathBuf::from("_mytemplates")]);
983 assert_eq!(config.template_dirs, vec![PathBuf::from("_mytemplates")]);
984 }
985
986 #[test]
987 fn override_unknown_key_is_ignored_not_error() {
988 let mut config = BuildConfig::default();
989 let before = config.clone();
990 let warning = config.apply_override("totally_unknown_key", "1").unwrap();
991 assert_eq!(config, before);
992 assert!(warning.unwrap().contains("unknown config value"));
993
994 let warning = config.apply_override("output.bogus_knob", "1").unwrap();
997 assert_eq!(config, before);
998 assert!(warning.unwrap().contains("unknown config value"));
999 }
1000
1001 #[test]
1002 fn override_option_number_field() {
1003 let mut config = BuildConfig::default();
1004 assert!(config
1005 .apply_override("parallel_jobs", "3")
1006 .unwrap()
1007 .is_none());
1008 assert_eq!(config.parallel_jobs, Some(3));
1009 }
1010
1011 #[test]
1012 fn override_bad_bool_is_an_error() {
1013 let mut config = BuildConfig::default();
1014 assert!(config.apply_override("nitpicky", "maybe").is_err());
1015 }
1016
1017 #[test]
1018 fn override_numeric_value_for_unset_string_option_stays_a_string() {
1019 let mut config = BuildConfig::default();
1022 assert!(config
1023 .apply_override("html_title", "2024")
1024 .unwrap()
1025 .is_none());
1026 assert_eq!(config.html_title, Some("2024".to_string()));
1027 }
1028
1029 #[test]
1030 fn override_dict_member_and_whole_dict() {
1031 let mut config = BuildConfig::default();
1032
1033 assert!(config
1035 .apply_override("html_context.banner", "on")
1036 .unwrap()
1037 .is_none());
1038 assert_eq!(
1039 config.html_context.get("banner"),
1040 Some(&serde_json::Value::String("on".to_string()))
1041 );
1042
1043 let before = config.clone();
1045 let warning = config.apply_override("html_context", "x").unwrap();
1046 assert_eq!(config, before);
1047 assert!(warning
1048 .unwrap()
1049 .contains("cannot override dictionary config setting"));
1050 }
1051
1052 #[test]
1053 fn intersphinx_and_http_defaults_match_sphinx() {
1054 let config = BuildConfig::default();
1055 assert!(config.intersphinx_mapping.is_empty());
1056 assert_eq!(
1057 config.intersphinx_disabled_reftypes,
1058 vec!["std:doc".to_string()],
1059 "the one default entry is what stops a bare `:doc:` resolving externally"
1060 );
1061 assert_eq!(config.intersphinx_resolve_self, "");
1062 assert_eq!(config.intersphinx_cache_limit, 5);
1063 assert_eq!(config.intersphinx_timeout, None);
1064 assert!(config.tls_verify);
1065 assert_eq!(config.tls_cacerts, None);
1066 assert_eq!(config.user_agent, None);
1067 }
1068
1069 #[test]
1070 fn an_invalid_intersphinx_mapping_fails_configuration_loading() {
1071 let temp_dir = TempDir::new().unwrap();
1074 let p = temp_dir.path().join("conf.py");
1075 fs::write(
1076 &p,
1077 "intersphinx_mapping = {'a': ('https://x/', None), 'b': ('https://x/', None)}\n",
1078 )
1079 .unwrap();
1080
1081 let err = BuildConfig::from_file(&p).expect_err("a duplicate target URI must abort");
1082 assert_eq!(
1083 err.to_string(),
1084 "Invalid `intersphinx_mapping` configuration (1 error)."
1085 );
1086 }
1087
1088 #[test]
1089 fn intersphinx_scalars_are_overridable_from_the_command_line() {
1090 let mut config = BuildConfig::default();
1091 assert!(config
1092 .apply_override("intersphinx_cache_limit", "-1")
1093 .unwrap()
1094 .is_none());
1095 assert_eq!(config.intersphinx_cache_limit, -1);
1096
1097 assert!(config
1098 .apply_override("intersphinx_disabled_reftypes", "std:doc,std:label")
1099 .unwrap()
1100 .is_none());
1101 assert_eq!(
1102 config.intersphinx_disabled_reftypes,
1103 vec!["std:doc".to_string(), "std:label".to_string()]
1104 );
1105
1106 assert!(config.apply_override("tls_verify", "0").unwrap().is_none());
1107 assert!(!config.tls_verify);
1108
1109 assert!(config
1112 .apply_override("intersphinx_timeout", "2.5")
1113 .unwrap()
1114 .is_none());
1115 assert_eq!(config.intersphinx_timeout, Some(2.5));
1116 assert!(config
1117 .apply_override("intersphinx_timeout", "5")
1118 .unwrap()
1119 .is_none());
1120 assert_eq!(config.intersphinx_timeout, Some(5.0));
1121 }
1122
1123 #[test]
1124 fn numfig_defaults_match_sphinx() {
1125 let config = BuildConfig::default();
1126 assert!(!config.numfig);
1127 assert_eq!(config.numfig_secnum_depth, 1);
1128 assert_eq!(config.numfig_format["section"], "Section %s");
1129 assert_eq!(config.numfig_format["figure"], "Fig. %s");
1130 assert_eq!(config.numfig_format["table"], "Table %s");
1131 assert_eq!(config.numfig_format["code-block"], "Listing %s");
1132 }
1133
1134 #[test]
1137 fn object_signature_and_py_domain_defaults_match_sphinx() {
1138 let config = BuildConfig::default();
1139 assert_eq!(config.maximum_signature_line_length, None);
1140 assert_eq!(config.python_maximum_signature_line_length, None);
1141 assert!(config.python_trailing_comma_in_multi_line_signatures);
1142 assert!(!config.python_display_short_literal_types);
1143 assert!(!config.python_use_unqualified_type_names);
1144 assert!(config.toc_object_entries);
1145 assert_eq!(config.toc_object_entries_show_parents, "domain");
1146 assert!(config.add_function_parentheses);
1147 assert!(config.add_module_names);
1148 assert!(!config.strip_signature_backslash);
1149 assert!(config.modindex_common_prefix.is_empty());
1150 }
1151
1152 #[test]
1163 fn a_none_default_int_key_overridden_from_the_command_line_warns_like_sphinx() {
1164 for value in ["20", "0", "abc", "None"] {
1165 let mut config = BuildConfig {
1166 maximum_signature_line_length: Some(60),
1167 ..Default::default()
1168 };
1169 assert!(config
1170 .apply_override("maximum_signature_line_length", value)
1171 .unwrap()
1172 .is_none());
1173 assert_eq!(
1174 config.maximum_signature_line_length, None,
1175 "{value}: never coerced, never kept as the old number"
1176 );
1177 assert!(config
1178 .apply_override("python_maximum_signature_line_length", value)
1179 .unwrap()
1180 .is_none());
1181 assert_eq!(config.python_maximum_signature_line_length, None);
1182 assert_eq!(
1183 config.validate(),
1184 vec![
1185 "The config value `maximum_signature_line_length' has type `str'; \
1186 expected `NoneType' or `int'."
1187 .to_string(),
1188 "The config value `python_maximum_signature_line_length' has type `str'; \
1189 expected `NoneType' or `int'."
1190 .to_string(),
1191 ],
1192 "{value}"
1193 );
1194 }
1195
1196 let mut config = BuildConfig::default();
1199 config
1200 .apply_override("maximum_signature_line_length", "1")
1201 .unwrap();
1202 config
1203 .apply_override("maximum_signature_line_length", "2")
1204 .unwrap();
1205 config.apply_override("nitpicky", "1").unwrap();
1206 assert_eq!(config.validate().len(), 1);
1207
1208 let mut config = BuildConfig::default();
1211 config
1212 .apply_override("maximum_signature_line_length", "20")
1213 .unwrap();
1214 config
1215 .apply_override("toc_object_entries_show_parents", "bogus")
1216 .unwrap();
1217 let warnings = config.validate();
1218 assert!(warnings[0].contains("toc_object_entries_show_parents"));
1219 assert!(warnings[1].contains("maximum_signature_line_length"));
1220 }
1221
1222 #[test]
1228 fn source_encoding_is_a_real_key_with_sphinxs_deprecation_warning() {
1229 let config = BuildConfig::default();
1230 assert_eq!(config.source_encoding, "utf-8-sig");
1231 assert!(config.validate().is_empty());
1232
1233 let deprecation = "Support for source encodings other than UTF-8 is deprecated and \
1234 will be removed in Sphinx 10. Please comment at \
1235 https://github.com/sphinx-doc/sphinx/issues/13665 if this causes \
1236 a problem.";
1237 for quiet in ["utf-8", "UTF-8", "utf8", "utf-8-sig", "UTF-8-SIG"] {
1238 let mut config = BuildConfig::default();
1239 assert!(config
1240 .apply_override("source_encoding", quiet)
1241 .unwrap()
1242 .is_none());
1243 assert_eq!(config.source_encoding, quiet);
1244 assert!(config.validate().is_empty(), "{quiet}");
1245 }
1246
1247 let mut config = BuildConfig::default();
1248 config.apply_override("source_encoding", "latin-1").unwrap();
1249 assert_eq!(config.validate(), vec![deprecation.to_string()]);
1250
1251 let mut config = BuildConfig::default();
1252 config.apply_override("source_encoding", "cp1252").unwrap();
1253 config
1254 .apply_override("maximum_signature_line_length", "20")
1255 .unwrap();
1256 let warnings = config.validate();
1257 assert_eq!(warnings.len(), 3, "{warnings:#?}");
1258 assert_eq!(warnings[0], deprecation);
1259 assert_eq!(
1260 warnings[1],
1261 "source_encoding 'cp1252' is not an encoding sphinx-ultra can decode (utf-8, \
1262 utf-8-sig, ascii, latin-1); included files will be read as 'utf-8-sig'"
1263 );
1264 assert!(warnings[2].starts_with("The config value `maximum_signature_line_length'"));
1265 }
1266
1267 #[test]
1268 fn object_signature_family_is_overridable_from_the_command_line() {
1269 let mut config = BuildConfig::default();
1270
1271 for key in [
1272 "python_trailing_comma_in_multi_line_signatures",
1273 "python_display_short_literal_types",
1274 "python_use_unqualified_type_names",
1275 "toc_object_entries",
1276 "add_function_parentheses",
1277 "add_module_names",
1278 "strip_signature_backslash",
1279 ] {
1280 assert!(config.apply_override(key, "0").unwrap().is_none(), "{key}");
1281 assert!(config.apply_override(key, "1").unwrap().is_none(), "{key}");
1282 }
1283 assert!(config.python_trailing_comma_in_multi_line_signatures);
1284 assert!(config.add_function_parentheses);
1285 assert!(config.strip_signature_backslash);
1286
1287 assert!(config
1288 .apply_override("toc_object_entries_show_parents", "hide")
1289 .unwrap()
1290 .is_none());
1291 assert_eq!(config.toc_object_entries_show_parents, "hide");
1292
1293 assert!(config
1294 .apply_override("modindex_common_prefix", "mypkg.,other.")
1295 .unwrap()
1296 .is_none());
1297 assert_eq!(
1298 config.modindex_common_prefix,
1299 vec!["mypkg.".to_string(), "other.".to_string()]
1300 );
1301 }
1302
1303 #[test]
1309 fn an_out_of_enum_toc_show_parents_warns_and_is_kept() {
1310 for accepted in ["domain", "all", "hide"] {
1311 let mut config = BuildConfig::default();
1312 config
1313 .apply_override("toc_object_entries_show_parents", accepted)
1314 .unwrap();
1315 assert!(
1316 config.validate().is_empty(),
1317 "{accepted} is one of the three ENUM values"
1318 );
1319 }
1320
1321 let mut config = BuildConfig::default();
1322 config
1323 .apply_override("toc_object_entries_show_parents", "bogus")
1324 .unwrap();
1325 let warnings = config.validate();
1326 assert_eq!(
1327 warnings,
1328 vec![
1329 "The config value `toc_object_entries_show_parents` has to be a one of \
1330 frozenset({'domain', 'all', 'hide'}), but `bogus` is given."
1331 .to_string()
1332 ]
1333 );
1334 assert_eq!(
1335 config.toc_object_entries_show_parents, "bogus",
1336 "sphinx keeps the rejected value rather than resetting it"
1337 );
1338
1339 let mut config = BuildConfig::default();
1341 config
1342 .apply_override("toc_object_entries_show_parents", "Domain")
1343 .unwrap();
1344 assert_eq!(config.validate().len(), 1);
1345 }
1346
1347 #[test]
1348 fn numfig_family_is_overridable_from_the_command_line() {
1349 let mut config = BuildConfig::default();
1350
1351 assert!(config.apply_override("numfig", "1").unwrap().is_none());
1353 assert!(config.numfig);
1354 assert!(config.apply_override("numfig", "0").unwrap().is_none());
1355 assert!(!config.numfig);
1356 assert!(config.apply_override("numfig", "true").unwrap().is_none());
1357 assert!(config.numfig);
1358 assert!(config.apply_override("numfig", "yes").is_err());
1359
1360 assert!(config
1361 .apply_override("numfig_secnum_depth", "2")
1362 .unwrap()
1363 .is_none());
1364 assert_eq!(config.numfig_secnum_depth, 2);
1365
1366 assert!(config
1369 .apply_override("numfig_format.figure", "Figure %s")
1370 .unwrap()
1371 .is_none());
1372 assert_eq!(config.numfig_format["figure"], "Figure %s");
1373 assert_eq!(config.numfig_format["table"], "Table %s");
1374 }
1375}