Skip to main content

sphinx_ultra/
config.rs

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    /// Number of parallel jobs to use (defaults to number of CPU cores)
11    pub parallel_jobs: Option<usize>,
12
13    /// Maximum cache size in MB
14    pub max_cache_size_mb: usize,
15
16    /// Cache expiration time in hours
17    pub cache_expiration_hours: u64,
18
19    /// Output format configuration
20    pub output: OutputConfig,
21
22    /// Theme configuration
23    pub theme: ThemeConfig,
24
25    /// Extension configuration
26    pub extensions: Vec<String>,
27
28    /// Custom template directories
29    pub template_dirs: Vec<PathBuf>,
30
31    /// Static file directories
32    pub static_dirs: Vec<PathBuf>,
33
34    /// Build optimization settings
35    pub optimization: OptimizationConfig,
36
37    // Sphinx-compatible fields
38    /// Project name
39    pub project: String,
40
41    /// Project version
42    pub version: Option<String>,
43
44    /// Project release
45    pub release: Option<String>,
46
47    /// Copyright notice
48    pub copyright: Option<String>,
49
50    /// Language code
51    pub language: Option<String>,
52
53    /// Root document
54    pub root_doc: Option<String>,
55
56    /// HTML theme style files
57    pub html_style: Vec<String>,
58
59    /// HTML CSS files
60    pub html_css_files: Vec<String>,
61
62    /// HTML JavaScript files
63    pub html_js_files: Vec<String>,
64
65    /// HTML static paths
66    pub html_static_path: Vec<PathBuf>,
67
68    /// HTML logo file
69    pub html_logo: Option<String>,
70
71    /// HTML favicon file
72    pub html_favicon: Option<String>,
73
74    /// HTML title
75    pub html_title: Option<String>,
76
77    /// HTML short title
78    pub html_short_title: Option<String>,
79
80    /// Show copyright in HTML
81    pub html_show_copyright: Option<bool>,
82
83    /// Show Sphinx attribution
84    pub html_show_sphinx: Option<bool>,
85
86    /// Copy source files
87    pub html_copy_source: Option<bool>,
88
89    /// Show source links
90    pub html_show_sourcelink: Option<bool>,
91
92    /// Source link suffix
93    pub html_sourcelink_suffix: Option<String>,
94
95    /// Use index
96    pub html_use_index: Option<bool>,
97
98    /// Use OpenSearch
99    pub html_use_opensearch: Option<bool>,
100
101    /// Last updated format
102    pub html_last_updated_fmt: Option<String>,
103
104    /// Templates path
105    pub templates_path: Vec<PathBuf>,
106
107    /// Turn warnings into errors
108    pub fail_on_warning: bool,
109
110    /// Glob-style patterns for file inclusion (Sphinx compatibility)
111    /// Default: ["**"] (include all files)
112    pub include_patterns: Vec<String>,
113
114    /// Glob-style patterns for file exclusion (Sphinx compatibility)
115    /// Default: [] (exclude nothing)
116    /// Exclusions have priority over inclusions
117    pub exclude_patterns: Vec<String>,
118
119    /// Warn about all missing cross-references (Sphinx `nitpicky` / `-n`)
120    pub nitpicky: bool,
121
122    /// `(reftype, target)` pairs whose missing-reference warnings `nitpicky`
123    /// must not raise (`nitpick_ignore`, `config.py`). Matched exactly, with
124    /// the reftype spelled either `domain:type` or — for the std domain —
125    /// bare `type` (`post_transforms/__init__.py:266-273`).
126    pub nitpick_ignore: Vec<(String, String)>,
127
128    /// The same, with both halves matched as regular expressions that must
129    /// match in full (`nitpick_ignore_regex`, `:274-282`).
130    pub nitpick_ignore_regex: Vec<(String, String)>,
131
132    /// Tags set via `-t` (consumed by `only`/`ifconfig` once M2 lands)
133    pub tags: Vec<String>,
134
135    /// Cache/doctree directory override (Sphinx `-d`); defaults to
136    /// `<output>/.sphinx-ultra-cache` when unset
137    pub doctree_dir: Option<std::path::PathBuf>,
138
139    /// Extra HTML template variables (conf.py `html_context`, CLI `-A`).
140    ///
141    /// Ordered, not hashed: this struct's serialization is the cache/
142    /// environment fingerprint (`builder::config_fingerprint`), and a
143    /// `HashMap` would emit its entries in `RandomState` order — a digest
144    /// that differs on every process, wiping the cache directory on every
145    /// build for any project that sets two or more `html_context` keys.
146    pub html_context: std::collections::BTreeMap<String, serde_json::Value>,
147
148    /// Run directive/role validation during the build
149    pub validate_directives: bool,
150
151    /// Number figures, tables and code blocks (`numfig`, `config.py:275`).
152    /// Off by default, exactly like Sphinx; when off,
153    /// `assign_figure_numbers` assigns nothing and `:numref:` degrades.
154    pub numfig: bool,
155
156    /// Per-figtype number format (`numfig_format`, `config.py:682-693`).
157    ///
158    /// Sphinx seeds this with `{section: 'Section %s', figure: 'Fig. %s',
159    /// table: 'Table %s', code-block: 'Listing %s'}` and **merges** the
160    /// user's dict over those defaults rather than replacing them, so a
161    /// `conf.py` that only overrides `figure` keeps the other three. That
162    /// merge lives in [`crate::python_config::PythonConfig::to_build_config`];
163    /// this field always holds the merged result, which is why
164    /// [`Default`] populates it with the four defaults.
165    pub numfig_format: std::collections::BTreeMap<String, String>,
166
167    /// How many leading section numbers a figure number is scoped by
168    /// (`numfig_secnum_depth`, `config.py:276`). 0 numbers figures
169    /// project-globally (1, 2, 3...); 1 (the default) numbers them per
170    /// top-level section (1.1, 1.2, 2.1...).
171    pub numfig_secnum_depth: u32,
172
173    /// `source_encoding` (`config.py:244`, default `'utf-8-sig'`, rebuild
174    /// class `'env'` — so it enters the cache fingerprint like every other
175    /// read-phase key). The encoding the file-inserting directives decode
176    /// their targets with when no `:encoding:` option is given: `include`
177    /// through `settings.input_encoding`, which the environment sets from
178    /// this key (`environment/__init__.py:375`), and `literalinclude`
179    /// through `config.source_encoding` directly (`code.py:210`). A value
180    /// other than UTF-8 earns sphinx's own deprecation warning at config
181    /// time ([`BuildConfig::validate`]). Documented limitation: this
182    /// crate still reads its OWN source documents as UTF-8.
183    pub source_encoding: String,
184
185    /// Type mismatches `check_confval_types` (`config.py:775-847`) will
186    /// report — `(key, python type name)` for the two `int | None` keys
187    /// whose value arrived as some other type: a `-D` override (always
188    /// `str`, because `convert_overrides` has no int branch for a key whose
189    /// default is `None` and returns the raw string, `config.py:397`) or a
190    /// mistyped `conf.py` assignment. Diagnostic state rather than
191    /// configuration: skipped by serde, so it neither enters the cache
192    /// fingerprint nor survives a save/load.
193    #[serde(skip)]
194    pub confval_type_mismatches: Vec<(String, String)>,
195
196    // --- Object-signature / py-domain family (research spec §1-5, §7) ---
197    //
198    // The first ten keys below are rebuild category `'env'` in sphinx, i.e.
199    // read-phase inputs: a change to any of them invalidates every parsed
200    // document. `modindex_common_prefix` alone is `'html'` (`config.py:264`),
201    // a write-phase key. All eleven still enter the build-cache fingerprint,
202    // which hashes this whole struct minus
203    // `builder::EXCLUDED_FROM_FINGERPRINT`: over-invalidating on the one
204    // write-only key costs a rebuild, while under-invalidating on any of the
205    // other ten would serve stale doctrees.
206    /// `maximum_signature_line_length`, default `None` (`config.py:279-281`):
207    /// the wrap threshold shared by the py/js/c/cpp object domains, behind
208    /// each domain's own override. See [`crate::py::PySigConfig::max_len`]
209    /// for how the two py keys combine.
210    pub maximum_signature_line_length: Option<i64>,
211
212    /// `python_maximum_signature_line_length`, default `None`
213    /// (`domains/python/__init__.py:1108-1113`). An explicit `0` is *not*
214    /// the same as unset: see [`crate::py::PySigConfig::max_len`].
215    pub python_maximum_signature_line_length: Option<i64>,
216
217    /// `python_trailing_comma_in_multi_line_signatures`, default `True`
218    /// (`domains/python/__init__.py:1114-1119`).
219    pub python_trailing_comma_in_multi_line_signatures: bool,
220
221    /// `python_display_short_literal_types`, default `False`
222    /// (`domains/python/__init__.py:1120-1122`).
223    pub python_display_short_literal_types: bool,
224
225    /// `python_use_unqualified_type_names`, default `False`
226    /// (`domains/python/__init__.py:1105-1107`).
227    pub python_use_unqualified_type_names: bool,
228
229    /// `toc_object_entries`, default `True` (`config.py:250`).
230    pub toc_object_entries: bool,
231
232    /// `toc_object_entries_show_parents`, default `'domain'`, an
233    /// `ENUM('domain', 'all', 'hide')` (`config.py:251-253`). Stored as the
234    /// raw string because sphinx only *warns* about a value outside the
235    /// enum and keeps it — see [`BuildConfig::validate`].
236    pub toc_object_entries_show_parents: String,
237
238    /// `add_function_parentheses`, default `True` (`config.py:248`) — the
239    /// `fix_parens` roles (`:py:func:`, `:py:meth:`) append `()` to an
240    /// implicit title, and object descriptions do the same for `_toc_name`.
241    pub add_function_parentheses: bool,
242
243    /// `add_module_names`, default `True` (`config.py:249`): whether a
244    /// signature renders its module prefix.
245    pub add_module_names: bool,
246
247    /// `strip_signature_backslash`, default `False`
248    /// (`directives/__init__.py:370-372`): strip backslashes out of a
249    /// signature before it is measured and parsed.
250    pub strip_signature_backslash: bool,
251
252    /// `modindex_common_prefix`, default `[]` (`config.py:264`): module-name
253    /// prefixes the python module index ignores when sorting. The one
254    /// `'html'`-rebuild key in this family.
255    pub modindex_common_prefix: Vec<String>,
256
257    /// `intersphinx_mapping`, already normalised and validated
258    /// (`ext/intersphinx/_load.py:38-136`): project name -> (target URI,
259    /// inventory locations). Loading a `conf.py` whose mapping fails
260    /// validation is an error, exactly as Sphinx's `ConfigError` aborts the
261    /// build — see [`crate::intersphinx::validate_mapping`].
262    pub intersphinx_mapping: crate::intersphinx::IntersphinxMapping,
263
264    /// `intersphinx_disabled_reftypes`, default `['std:doc']`
265    /// (`ext/intersphinx/__init__.py:79`). Entries are `domain:objtype`,
266    /// `domain:*` or `*`, and they only ever block a *bare* reference: the
267    /// `inv:target` and `:external:` forms bypass them.
268    pub intersphinx_disabled_reftypes: Vec<String>,
269
270    /// `intersphinx_resolve_self`, default `''` (`__init__.py:69`): the
271    /// inventory name that means "this project", so `name:target` resolves
272    /// locally instead of through an inventory.
273    pub intersphinx_resolve_self: String,
274
275    /// `intersphinx_cache_limit` in days, default 5 (`__init__.py:70`).
276    /// Negative means a cached inventory never expires.
277    pub intersphinx_cache_limit: i64,
278
279    /// `intersphinx_timeout` in seconds, default `None` — which Sphinx
280    /// passes to `requests` as no timeout at all (`__init__.py:71`).
281    pub intersphinx_timeout: Option<f64>,
282
283    /// `tls_verify`, default `True` (`config.py:286`).
284    pub tls_verify: bool,
285
286    /// `tls_cacerts`, default `None` (`config.py:287`): one CA bundle path,
287    /// or a per-host mapping of them.
288    pub tls_cacerts: Option<crate::intersphinx::TlsCacerts>,
289
290    /// `user_agent`, default `None` (`config.py:288`) — unset means
291    /// [`crate::intersphinx::DEFAULT_USER_AGENT`].
292    pub user_agent: Option<String>,
293}
294
295/// Sphinx's default `source_encoding` (`config.py:244`).
296pub const DEFAULT_SOURCE_ENCODING: &str = crate::rst::DEFAULT_SOURCE_ENCODING;
297
298/// The config keys registered with a `None` default and `int | NoneType`
299/// as their valid types (`config.py:279-281`,
300/// `domains/python/__init__.py:1108-1113`), in sphinx's registration
301/// order — the order `check_confval_types` reports them in.
302const NONE_DEFAULT_INT_KEYS: [&str; 2] = [
303    "maximum_signature_line_length",
304    "python_maximum_signature_line_length",
305];
306
307/// `deprecate_source_encoding` (`config.py:886-896`, a `config-inited`
308/// handler at priority 790): the encodings it does NOT warn about.
309const UTF8_SPELLINGS: [&str; 3] = ["utf-8", "utf-8-sig", "utf8"];
310
311/// The three values `toc_object_entries_show_parents` accepts —
312/// `ENUM('domain', 'all', 'hide')` (`config.py:251-253`), in sphinx's own
313/// registration order.
314pub const TOC_OBJECT_ENTRIES_SHOW_PARENTS: [&str; 3] = ["domain", "all", "hide"];
315
316/// Sphinx's `numfig_format` defaults (`config.py:682-693`), which user
317/// entries merge over.
318pub 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    /// Output HTML format
334    pub html_theme: String,
335
336    /// Enable syntax highlighting
337    pub syntax_highlighting: bool,
338
339    /// Syntax highlighting theme
340    pub highlight_theme: String,
341
342    /// Generate search index
343    pub search_index: bool,
344
345    /// Minify output HTML
346    pub minify_html: bool,
347
348    /// Compress output files
349    pub compress_output: bool,
350}
351
352#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
353#[serde(default)]
354pub struct ThemeConfig {
355    /// Theme name
356    pub name: String,
357
358    /// Theme-specific configuration
359    pub options: serde_json::Value,
360
361    /// Custom CSS files
362    pub custom_css: Vec<PathBuf>,
363
364    /// Custom JavaScript files
365    pub custom_js: Vec<PathBuf>,
366}
367
368#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
369#[serde(default)]
370pub struct OptimizationConfig {
371    /// Enable parallel processing
372    pub parallel_processing: bool,
373
374    /// Enable incremental builds
375    pub incremental_builds: bool,
376
377    /// Cache parsed documents
378    pub document_caching: bool,
379
380    /// Optimize images
381    pub image_optimization: bool,
382
383    /// Bundle assets
384    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            // Sphinx-compatible defaults
405            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            // Warning handling
430            fail_on_warning: false,
431
432            // File pattern matching (Sphinx compatibility)
433            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            // Object-signature / py-domain family, probe-verified against
451            // sphinx 9.1.0 (task-2 brief, "Probe outcomes").
452            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        // Sphinx projects configure via conf.py; route it to the Python
517        // config parser so `--config conf.py` behaves like auto-detection.
518        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    /// Load configuration from a Sphinx conf.py file
539    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        // Silent dropping is banned: surface every construct the parser
544        // could not handle.
545        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    /// Try to auto-detect and load configuration from various sources
557    pub fn auto_detect<P: AsRef<std::path::Path>>(source_dir: P) -> Result<Self> {
558        let source_dir = source_dir.as_ref();
559
560        // Try conf.py first (Sphinx standard)
561        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        // Try sphinx-ultra.yaml
567        let yaml_path = source_dir.join("sphinx-ultra.yaml");
568        if yaml_path.exists() {
569            return Self::from_file(yaml_path);
570        }
571
572        // Try sphinx-ultra.yml
573        let yml_path = source_dir.join("sphinx-ultra.yml");
574        if yml_path.exists() {
575            return Self::from_file(yml_path);
576        }
577
578        // Try sphinx-ultra.json
579        let json_path = source_dir.join("sphinx-ultra.json");
580        if json_path.exists() {
581            return Self::from_file(json_path);
582        }
583
584        // Return default configuration
585        Ok(Self::default())
586    }
587
588    /// Sphinx's `check_confval_types` pass, which runs once at
589    /// `config-inited` — after `conf.py` *and* after every `-D` override —
590    /// and reports values outside a setting's declared type or enum.
591    ///
592    /// It **warns**; it does not fail. A rejected value is left in place and
593    /// the build carries on with it (probe E of the task-2 brief:
594    /// `-D toc_object_entries_show_parents=bogus` builds successfully with
595    /// `config.toc_object_entries_show_parents == 'bogus'`). Returns the
596    /// warning texts so the caller can log them, write them to `-w`, and
597    /// count them toward `-W`, like every other config-time warning.
598    ///
599    /// Sphinx renders the candidate set as a python `frozenset` repr, whose
600    /// element order is hash-order and therefore varies between processes
601    /// (verified: three runs, three orders). The registration order is used
602    /// here instead, which is the only deterministic choice.
603    ///
604    /// The `config-inited` handlers run in priority order, which fixes the
605    /// order of the warnings: `deprecate_source_encoding` (790) before
606    /// `check_confval_types` (800), and inside the latter the options in
607    /// registration order — `toc_object_entries_show_parents`
608    /// (`config.py:251`) before `maximum_signature_line_length`
609    /// (`config.py:279`) before the py domain's
610    /// `python_maximum_signature_line_length`. Each message is logged
611    /// `once=True`, so a key is reported at most once.
612    pub fn validate(&self) -> Vec<String> {
613        let mut warnings = Vec::new();
614
615        // config-inited @790: `deprecate_source_encoding`. Byte-exact.
616        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        // This crate's own check (sphinx has none — it raises `LookupError`
625        // at the first file it opens): a codec outside the include
626        // directives' table cannot be decoded here, and the parser falls
627        // back to the default rather than mis-decoding silently.
628        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        // config-inited @800: `check_confval_types`, in registration order.
638        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        // The type-mismatch branch (`config.py:822-838`): `type_value` is not
652        // in `{int, NoneType}` and shares no non-trivial base with NoneType,
653        // so the warning names the permitted set, `sorted` by the
654        // backticked spelling — `NoneType' before `int' (N < i).
655        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    /// Record that `key` (one of [`NONE_DEFAULT_INT_KEYS`]) received a value
671    /// of python type `type_name`, for [`Self::validate`] to report. One
672    /// entry per key, like sphinx's `once=True`.
673    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    /// Apply a `-D key=value` override (sphinx-build semantics): the value is
685    /// coerced to the type the field already has, dotted keys reach the nested
686    /// sections (`output.*`, `theme.*`) and map-typed settings
687    /// (`html_context.name`), and an unknown key warns and is ignored rather
688    /// than failing the build.
689    ///
690    /// Returns the sphinx-style warning message when the override was ignored
691    /// — the caller decides how to report it (it must count toward `-W`).
692    pub fn apply_override(&mut self, key: &str, value: &str) -> Result<Option<String>> {
693        // `html_theme` is the Sphinx name; it lives in two places here.
694        // Fan aliases out first so both copies stay in sync.
695        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                // fall through to set templates_path itself below
703            }
704            "html_static_path" => {
705                self.apply_override("static_dirs", value)?;
706                // fall through to set html_static_path itself below
707            }
708            _ => {}
709        }
710
711        // `convert_overrides` (`config.py:354-399`) has no `int` branch for
712        // a key whose default is `None`: control reaches `isinstance(default,
713        // str) or default is None: return value`, so the value stays the
714        // raw STRING, and `check_confval_types` then warns that it has type
715        // `str'. Sphinx keeps that string — and the first py signature
716        // raises `TypeError: '>' not supported between instances of 'int'
717        // and 'str'` at `_object.py:304` (probe-pinned, panel fix round B)
718        // — so "unset" is the only value this crate can sanely carry. The
719        // warning itself is reported by [`Self::validate`], at
720        // `config-inited` like sphinx's.
721        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        // Resolve the dotted path. A key missing from its parent object is
733        // inserted as Null (map-typed settings like html_context accept new
734        // keys); whether it truly landed is checked after the round-trip —
735        // structs silently drop unknown fields, which we report as unknown.
736        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        // Whole-dict overrides are not expressible on the command line
752        // (sphinx-build warns and continues too).
753        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            // A Null slot gave no type information and the numeric guess was
768            // wrong (e.g. -D html_title=2024 targets an Option<String>):
769            // retry with the raw string before giving up.
770            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        // Did the key survive the round-trip? Structs drop unknown fields
793        // silently; a vanished key means the setting doesn't exist.
794        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        // Serde-skipped state does not survive the round trip; carry it.
807        applied.confval_type_mismatches = std::mem::take(&mut self.confval_type_mismatches);
808        *self = applied;
809        Ok(None)
810    }
811
812    /// Coerce a CLI string to the JSON type currently occupying the slot.
813    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            // Null slots are Option<...> fields: prefer a number if the value
838            // parses as one (parallel_jobs, intersphinx_timeout), otherwise
839            // store the string. A wrong numeric guess is retried as a string
840            // by the caller, so trying the fractional form costs nothing and
841            // is the only way to reach an `Option<f64>` setting.
842            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); // default filled in
880        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        // No config files
934        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        // Unknown nested keys are dropped by the struct round-trip and
995        // reported the same way.
996        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        // sphinx-build sets html_title="2024"; the numeric guess for the
1020        // Null slot must fall back to a string instead of failing the build.
1021        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        // -D html_context.banner=on inserts into the map (sphinx-build syntax)
1034        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        // A whole-dict override warns and is ignored, like sphinx-build
1044        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        // Sphinx raises ConfigError here, which aborts the build before it
1072        // starts; the CLI turns a config-loading error into exit code 2.
1073        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        // An unset `Option<f64>`: the slot carries no type information, so
1110        // the fractional form has to be guessed at.
1111        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    /// Probe D of the task-2 brief dumped `app.config` for all eleven keys
1135    /// under sphinx 9.1.0; these are those values.
1136    #[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    /// Sphinx's `convert_overrides` has no `int` branch for a key whose
1153    /// default is `None` (`config.py:354-399` ends in `isinstance(default,
1154    /// str) or default is None: return value`), so `-D` hands
1155    /// `check_confval_types` the raw STRING and it warns — for `20`, `0`,
1156    /// `abc` and `None` alike. Probed on the pinned toolchain (panel fix
1157    /// round B, [18]): the warning fires with no py directive in the
1158    /// project, `-W` exits 1, and with a `.. py:function::` present sphinx
1159    /// then crashes (`TypeError: '>' not supported between instances of
1160    /// 'int' and 'str'`, `_object.py:304`). This crate warns byte-exactly
1161    /// and leaves the key UNSET, the only value it can sanely carry.
1162    #[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        // `once=True`: a key overridden twice is reported once, and a
1197        // later ordinary override keeps the record through the round trip.
1198        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        // Registration order: the ENUM key (`config.py:251`) is reported
1209        // before `maximum_signature_line_length` (`config.py:279`).
1210        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    /// `source_encoding` (`config.py:244`): default `'utf-8-sig'`,
1223    /// overridable, a non-UTF-8 value earns sphinx's deprecation text
1224    /// (byte-exact, probed: `deprecate_source_encoding` at config-inited
1225    /// priority 790, i.e. BEFORE the type checks), and a codec this crate
1226    /// cannot decode earns this crate's own fallback notice on top.
1227    #[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    /// `toc_object_entries_show_parents` is `ENUM('domain', 'all', 'hide')`
1304    /// (`config.py:251-253`), and sphinx's `check_confval_types` only
1305    /// **warns** about a value outside it — the build continues with the
1306    /// offending value untouched (probe E, recorded in the task-2 brief).
1307    /// So `validate` returns warnings and never fails.
1308    #[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        // The comparison is case-sensitive, exactly like a python set test.
1340        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        // sphinx-build spells booleans as 0/1; `true`/`True` work too.
1352        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        // A dict setting is overridden key by key, which leaves the other
1367        // defaults in place.
1368        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}