Skip to main content

sphinx_ultra/
config.rs

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    /// 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    /// Every value `conf.py` assigned (plus `-D` overrides), as the literal
149    /// it was assigned: Sphinx's `Config` namespace before any defaults.
150    ///
151    /// The typed fields above can't tell "unset" from "set to the default"
152    /// (`html_static_path = ['_static']` is both sphinx-quickstart's
153    /// default and this crate's), and Sphinx's `.buildinfo` hash covers
154    /// every html-category key *as configured*; this map is how
155    /// [`crate::builder::sphinx_build_info_contents`] tells the two apart,
156    /// and hashes a key the typed fields don't model at all.
157    pub conf_values: std::collections::BTreeMap<String, serde_json::Value>,
158
159    /// Run directive/role validation during the build
160    pub validate_directives: bool,
161
162    /// Number figures, tables and code blocks (`numfig`, `config.py:275`).
163    /// Off by default, exactly like Sphinx; when off,
164    /// `assign_figure_numbers` assigns nothing and `:numref:` degrades.
165    pub numfig: bool,
166
167    /// Per-figtype number format (`numfig_format`, `config.py:682-693`).
168    ///
169    /// Sphinx seeds this with `{section: 'Section %s', figure: 'Fig. %s',
170    /// table: 'Table %s', code-block: 'Listing %s'}` and **merges** the
171    /// user's dict over those defaults rather than replacing them, so a
172    /// `conf.py` that only overrides `figure` keeps the other three. That
173    /// merge lives in [`crate::python_config::PythonConfig::to_build_config`];
174    /// this field always holds the merged result, which is why
175    /// [`Default`] populates it with the four defaults.
176    pub numfig_format: std::collections::BTreeMap<String, String>,
177
178    /// How many leading section numbers a figure number is scoped by
179    /// (`numfig_secnum_depth`, `config.py:276`). 0 numbers figures
180    /// project-globally (1, 2, 3...); 1 (the default) numbers them per
181    /// top-level section (1.1, 1.2, 2.1...).
182    pub numfig_secnum_depth: u32,
183
184    /// `source_encoding` (`config.py:244`, default `'utf-8-sig'`, rebuild
185    /// class `'env'` — so it enters the cache fingerprint like every other
186    /// read-phase key). The encoding the file-inserting directives decode
187    /// their targets with when no `:encoding:` option is given: `include`
188    /// through `settings.input_encoding`, which the environment sets from
189    /// this key (`environment/__init__.py:375`), and `literalinclude`
190    /// through `config.source_encoding` directly (`code.py:210`). A value
191    /// other than UTF-8 earns sphinx's own deprecation warning at config
192    /// time ([`BuildConfig::validate`]). Documented limitation: this
193    /// crate still reads its OWN source documents as UTF-8.
194    pub source_encoding: String,
195
196    /// Type mismatches `check_confval_types` (`config.py:775-847`) will
197    /// report — `(key, python type name)` for the two `int | None` keys
198    /// whose value arrived as some other type: a `-D` override (always
199    /// `str`, because `convert_overrides` has no int branch for a key whose
200    /// default is `None` and returns the raw string, `config.py:397`) or a
201    /// mistyped `conf.py` assignment. Diagnostic state rather than
202    /// configuration: skipped by serde, so it neither enters the cache
203    /// fingerprint nor survives a save/load.
204    #[serde(skip)]
205    pub confval_type_mismatches: Vec<(String, String)>,
206
207    // --- Object-signature / py-domain family (research spec §1-5, §7) ---
208    //
209    // The first ten keys below are rebuild category `'env'` in sphinx, i.e.
210    // read-phase inputs: a change to any of them invalidates every parsed
211    // document. `modindex_common_prefix` alone is `'html'` (`config.py:264`),
212    // a write-phase key. All eleven still enter the build-cache fingerprint,
213    // which hashes this whole struct minus
214    // `builder::EXCLUDED_FROM_FINGERPRINT`: over-invalidating on the one
215    // write-only key costs a rebuild, while under-invalidating on any of the
216    // other ten would serve stale doctrees.
217    /// `maximum_signature_line_length`, default `None` (`config.py:279-281`):
218    /// the wrap threshold shared by the py/js/c/cpp object domains, behind
219    /// each domain's own override. See [`crate::py::PySigConfig::max_len`]
220    /// for how the two py keys combine.
221    pub maximum_signature_line_length: Option<i64>,
222
223    /// `python_maximum_signature_line_length`, default `None`
224    /// (`domains/python/__init__.py:1108-1113`). An explicit `0` is *not*
225    /// the same as unset: see [`crate::py::PySigConfig::max_len`].
226    pub python_maximum_signature_line_length: Option<i64>,
227
228    /// `python_trailing_comma_in_multi_line_signatures`, default `True`
229    /// (`domains/python/__init__.py:1114-1119`).
230    pub python_trailing_comma_in_multi_line_signatures: bool,
231
232    /// `python_display_short_literal_types`, default `False`
233    /// (`domains/python/__init__.py:1120-1122`).
234    pub python_display_short_literal_types: bool,
235
236    /// `python_use_unqualified_type_names`, default `False`
237    /// (`domains/python/__init__.py:1105-1107`).
238    pub python_use_unqualified_type_names: bool,
239
240    /// `toc_object_entries`, default `True` (`config.py:250`).
241    pub toc_object_entries: bool,
242
243    /// `toc_object_entries_show_parents`, default `'domain'`, an
244    /// `ENUM('domain', 'all', 'hide')` (`config.py:251-253`). Stored as the
245    /// raw string because sphinx only *warns* about a value outside the
246    /// enum and keeps it — see [`BuildConfig::validate`].
247    pub toc_object_entries_show_parents: String,
248
249    /// `add_function_parentheses`, default `True` (`config.py:248`) — the
250    /// `fix_parens` roles (`:py:func:`, `:py:meth:`) append `()` to an
251    /// implicit title, and object descriptions do the same for `_toc_name`.
252    pub add_function_parentheses: bool,
253
254    /// `add_module_names`, default `True` (`config.py:249`): whether a
255    /// signature renders its module prefix.
256    pub add_module_names: bool,
257
258    /// `strip_signature_backslash`, default `False`
259    /// (`directives/__init__.py:370-372`): strip backslashes out of a
260    /// signature before it is measured and parsed.
261    pub strip_signature_backslash: bool,
262
263    /// `modindex_common_prefix`, default `[]` (`config.py:264`): module-name
264    /// prefixes the python module index ignores when sorting. The one
265    /// `'html'`-rebuild key in this family.
266    pub modindex_common_prefix: Vec<String>,
267
268    /// `intersphinx_mapping`, already normalised and validated
269    /// (`ext/intersphinx/_load.py:38-136`): project name -> (target URI,
270    /// inventory locations). Loading a `conf.py` whose mapping fails
271    /// validation is an error, exactly as Sphinx's `ConfigError` aborts the
272    /// build — see [`crate::intersphinx::validate_mapping`].
273    pub intersphinx_mapping: crate::intersphinx::IntersphinxMapping,
274
275    /// `intersphinx_disabled_reftypes`, default `['std:doc']`
276    /// (`ext/intersphinx/__init__.py:79`). Entries are `domain:objtype`,
277    /// `domain:*` or `*`, and they only ever block a *bare* reference: the
278    /// `inv:target` and `:external:` forms bypass them.
279    pub intersphinx_disabled_reftypes: Vec<String>,
280
281    /// `intersphinx_resolve_self`, default `''` (`__init__.py:69`): the
282    /// inventory name that means "this project", so `name:target` resolves
283    /// locally instead of through an inventory.
284    pub intersphinx_resolve_self: String,
285
286    /// `intersphinx_cache_limit` in days, default 5 (`__init__.py:70`).
287    /// Negative means a cached inventory never expires.
288    pub intersphinx_cache_limit: i64,
289
290    /// `intersphinx_timeout` in seconds, default `None` — which Sphinx
291    /// passes to `requests` as no timeout at all (`__init__.py:71`).
292    pub intersphinx_timeout: Option<f64>,
293
294    /// `tls_verify`, default `True` (`config.py:286`).
295    pub tls_verify: bool,
296
297    /// `tls_cacerts`, default `None` (`config.py:287`): one CA bundle path,
298    /// or a per-host mapping of them.
299    pub tls_cacerts: Option<crate::intersphinx::TlsCacerts>,
300
301    /// `user_agent`, default `None` (`config.py:288`) — unset means
302    /// [`crate::intersphinx::DEFAULT_USER_AGENT`].
303    pub user_agent: Option<String>,
304}
305
306/// Sphinx's default `source_encoding` (`config.py:244`).
307pub const DEFAULT_SOURCE_ENCODING: &str = crate::rst::DEFAULT_SOURCE_ENCODING;
308
309/// The config keys registered with a `None` default and `int | NoneType`
310/// as their valid types (`config.py:279-281`,
311/// `domains/python/__init__.py:1108-1113`), in sphinx's registration
312/// order — the order `check_confval_types` reports them in.
313const NONE_DEFAULT_INT_KEYS: [&str; 2] = [
314    "maximum_signature_line_length",
315    "python_maximum_signature_line_length",
316];
317
318/// `deprecate_source_encoding` (`config.py:886-896`, a `config-inited`
319/// handler at priority 790): the encodings it does NOT warn about.
320const UTF8_SPELLINGS: [&str; 3] = ["utf-8", "utf-8-sig", "utf8"];
321
322/// The three values `toc_object_entries_show_parents` accepts —
323/// `ENUM('domain', 'all', 'hide')` (`config.py:251-253`), in sphinx's own
324/// registration order.
325pub const TOC_OBJECT_ENTRIES_SHOW_PARENTS: [&str; 3] = ["domain", "all", "hide"];
326
327/// Sphinx's `numfig_format` defaults (`config.py:682-693`), which user
328/// entries merge over.
329pub 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    /// Output HTML format
345    pub html_theme: String,
346
347    /// Enable syntax highlighting
348    pub syntax_highlighting: bool,
349
350    /// Syntax highlighting theme
351    pub highlight_theme: String,
352
353    /// Generate search index
354    pub search_index: bool,
355
356    /// Minify output HTML
357    pub minify_html: bool,
358
359    /// Compress output files
360    pub compress_output: bool,
361}
362
363#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
364#[serde(default)]
365pub struct ThemeConfig {
366    /// Theme name
367    pub name: String,
368
369    /// Theme-specific configuration
370    pub options: serde_json::Value,
371
372    /// Custom CSS files
373    pub custom_css: Vec<PathBuf>,
374
375    /// Custom JavaScript files
376    pub custom_js: Vec<PathBuf>,
377}
378
379#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
380#[serde(default)]
381pub struct OptimizationConfig {
382    /// Enable parallel processing
383    pub parallel_processing: bool,
384
385    /// Enable incremental builds
386    pub incremental_builds: bool,
387
388    /// Cache parsed documents
389    pub document_caching: bool,
390
391    /// Optimize images
392    pub image_optimization: bool,
393
394    /// Bundle assets
395    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            // Sphinx-compatible defaults
416            project: "Sphinx Ultra Project".to_string(),
417            // Sphinx defaults both values to the empty string when conf.py
418            // does not define them. Keep None here so artifact headers use
419            // that same default while explicit conf.py values remain intact.
420            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            // Warning handling
444            fail_on_warning: false,
445
446            // File pattern matching (Sphinx compatibility)
447            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            // Object-signature / py-domain family, probe-verified against
466            // sphinx 9.1.0 (task-2 brief, "Probe outcomes").
467            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        // Sphinx projects configure via conf.py; route it to the Python
532        // config parser so `--config conf.py` behaves like auto-detection.
533        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    /// Load configuration from a Sphinx conf.py file
554    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        // Silent dropping is banned: surface every construct the parser
559        // could not handle.
560        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    /// Try to auto-detect and load configuration from various sources
572    pub fn auto_detect<P: AsRef<std::path::Path>>(source_dir: P) -> Result<Self> {
573        let source_dir = source_dir.as_ref();
574
575        // Try conf.py first (Sphinx standard)
576        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        // Try sphinx-ultra.yaml
582        let yaml_path = source_dir.join("sphinx-ultra.yaml");
583        if yaml_path.exists() {
584            return Self::from_file(yaml_path);
585        }
586
587        // Try sphinx-ultra.yml
588        let yml_path = source_dir.join("sphinx-ultra.yml");
589        if yml_path.exists() {
590            return Self::from_file(yml_path);
591        }
592
593        // Try sphinx-ultra.json
594        let json_path = source_dir.join("sphinx-ultra.json");
595        if json_path.exists() {
596            return Self::from_file(json_path);
597        }
598
599        // Return default configuration
600        Ok(Self::default())
601    }
602
603    /// Sphinx's `check_confval_types` pass, which runs once at
604    /// `config-inited` — after `conf.py` *and* after every `-D` override —
605    /// and reports values outside a setting's declared type or enum.
606    ///
607    /// It **warns**; it does not fail. A rejected value is left in place and
608    /// the build carries on with it (probe E of the task-2 brief:
609    /// `-D toc_object_entries_show_parents=bogus` builds successfully with
610    /// `config.toc_object_entries_show_parents == 'bogus'`). Returns the
611    /// warning texts so the caller can log them, write them to `-w`, and
612    /// count them toward `-W`, like every other config-time warning.
613    ///
614    /// Sphinx renders the candidate set as a python `frozenset` repr, whose
615    /// element order is hash-order and therefore varies between processes
616    /// (verified: three runs, three orders). The registration order is used
617    /// here instead, which is the only deterministic choice.
618    ///
619    /// The `config-inited` handlers run in priority order, which fixes the
620    /// order of the warnings: `deprecate_source_encoding` (790) before
621    /// `check_confval_types` (800), and inside the latter the options in
622    /// registration order — `toc_object_entries_show_parents`
623    /// (`config.py:251`) before `maximum_signature_line_length`
624    /// (`config.py:279`) before the py domain's
625    /// `python_maximum_signature_line_length`. Each message is logged
626    /// `once=True`, so a key is reported at most once.
627    pub fn validate(&self) -> Vec<String> {
628        let mut warnings = Vec::new();
629
630        // config-inited @790: `deprecate_source_encoding`. Byte-exact.
631        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        // This crate's own check (sphinx has none — it raises `LookupError`
640        // at the first file it opens): a codec outside the include
641        // directives' table cannot be decoded here, and the parser falls
642        // back to the default rather than mis-decoding silently.
643        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        // config-inited @800: `check_confval_types`, in registration order.
653        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        // The type-mismatch branch (`config.py:822-838`): `type_value` is not
667        // in `{int, NoneType}` and shares no non-trivial base with NoneType,
668        // so the warning names the permitted set, `sorted` by the
669        // backticked spelling — `NoneType' before `int' (N < i).
670        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    /// Record that `key` (one of [`NONE_DEFAULT_INT_KEYS`]) received a value
686    /// of python type `type_name`, for [`Self::validate`] to report. One
687    /// entry per key, like sphinx's `once=True`.
688    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    /// `validate_html_logo`/`validate_html_favicon`
700    /// (`builders/html/__init__.py:1404-1423`, run at `config-inited`): a
701    /// logo or favicon that is neither a file under the confdir nor a URL
702    /// is warned about and unset, so the build — and the `.buildinfo`
703    /// hash — see `None`. Returns the warnings, worded as Sphinx words them.
704    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            // `is_url`: `'://' in url`; an empty value is falsy and skipped.
711            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    /// Apply a `-D key=value` override (sphinx-build semantics): the value is
732    /// coerced to the type the field already has, dotted keys reach the nested
733    /// sections (`output.*`, `theme.*`) and map-typed settings
734    /// (`html_context.name`), and an unknown key warns and is ignored rather
735    /// than failing the build.
736    ///
737    /// Returns the sphinx-style warning message when the override was ignored
738    /// — the caller decides how to report it (it must count toward `-W`).
739    pub fn apply_override(&mut self, key: &str, value: &str) -> Result<Option<String>> {
740        // `html_theme` is the Sphinx name; it lives in two places here.
741        // Fan aliases out first so both copies stay in sync.
742        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                // fall through to set templates_path itself below
755            }
756            "html_static_path" => {
757                self.apply_override("static_dirs", value)?;
758                // fall through to set html_static_path itself below
759            }
760            _ => {}
761        }
762
763        // `convert_overrides` (`config.py:354-399`) has no `int` branch for
764        // a key whose default is `None`: control reaches `isinstance(default,
765        // str) or default is None: return value`, so the value stays the
766        // raw STRING, and `check_confval_types` then warns that it has type
767        // `str'. Sphinx keeps that string — and the first py signature
768        // raises `TypeError: '>' not supported between instances of 'int'
769        // and 'str'` at `_object.py:304` (probe-pinned, panel fix round B)
770        // — so "unset" is the only value this crate can sanely carry. The
771        // warning itself is reported by [`Self::validate`], at
772        // `config-inited` like sphinx's.
773        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        // Resolve the dotted path. A key missing from its parent object is
785        // inserted as Null (map-typed settings like html_context accept new
786        // keys); whether it truly landed is checked after the round-trip —
787        // structs silently drop unknown fields, which we report as unknown.
788        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        // Whole-dict overrides are not expressible on the command line
804        // (sphinx-build warns and continues too).
805        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            // A Null slot gave no type information and the numeric guess was
820            // wrong (e.g. -D html_title=2024 targets an Option<String>):
821            // retry with the raw string before giving up.
822            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        // Did the key survive the round-trip? Structs drop unknown fields
845        // silently; a vanished key means the setting doesn't exist.
846        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        // Serde-skipped state does not survive the round trip; carry it.
859        applied.confval_type_mismatches = std::mem::take(&mut self.confval_type_mismatches);
860        // A top-level key is a Sphinx setting name: the `.buildinfo` hash
861        // sees the override as conf.py would have. (A dotted key names
862        // this crate's own nesting, not a conf.py setting.)
863        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    /// Coerce a CLI string to the JSON type currently occupying the slot.
873    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            // Null slots are Option<...> fields: prefer a number if the value
898            // parses as one (parallel_jobs, intersphinx_timeout), otherwise
899            // store the string. A wrong numeric guess is retried as a string
900            // by the caller, so trying the fractional form costs nothing and
901            // is the only way to reach an `Option<f64>` setting.
902            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); // default filled in
940        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        // No config files
994        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        // Unknown nested keys are dropped by the struct round-trip and
1055        // reported the same way.
1056        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        // sphinx-build sets html_title="2024"; the numeric guess for the
1080        // Null slot must fall back to a string instead of failing the build.
1081        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        // -D html_context.banner=on inserts into the map (sphinx-build syntax)
1094        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        // A whole-dict override warns and is ignored, like sphinx-build
1104        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        // Sphinx raises ConfigError here, which aborts the build before it
1132        // starts; the CLI turns a config-loading error into exit code 2.
1133        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        // An unset `Option<f64>`: the slot carries no type information, so
1170        // the fractional form has to be guessed at.
1171        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    /// Probe D of the task-2 brief dumped `app.config` for all eleven keys
1195    /// under sphinx 9.1.0; these are those values.
1196    #[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    /// Sphinx's `convert_overrides` has no `int` branch for a key whose
1213    /// default is `None` (`config.py:354-399` ends in `isinstance(default,
1214    /// str) or default is None: return value`), so `-D` hands
1215    /// `check_confval_types` the raw STRING and it warns — for `20`, `0`,
1216    /// `abc` and `None` alike. Probed on the pinned toolchain (panel fix
1217    /// round B, [18]): the warning fires with no py directive in the
1218    /// project, `-W` exits 1, and with a `.. py:function::` present sphinx
1219    /// then crashes (`TypeError: '>' not supported between instances of
1220    /// 'int' and 'str'`, `_object.py:304`). This crate warns byte-exactly
1221    /// and leaves the key UNSET, the only value it can sanely carry.
1222    #[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        // `once=True`: a key overridden twice is reported once, and a
1257        // later ordinary override keeps the record through the round trip.
1258        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        // Registration order: the ENUM key (`config.py:251`) is reported
1269        // before `maximum_signature_line_length` (`config.py:279`).
1270        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    /// `source_encoding` (`config.py:244`): default `'utf-8-sig'`,
1283    /// overridable, a non-UTF-8 value earns sphinx's deprecation text
1284    /// (byte-exact, probed: `deprecate_source_encoding` at config-inited
1285    /// priority 790, i.e. BEFORE the type checks), and a codec this crate
1286    /// cannot decode earns this crate's own fallback notice on top.
1287    #[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    /// `toc_object_entries_show_parents` is `ENUM('domain', 'all', 'hide')`
1364    /// (`config.py:251-253`), and sphinx's `check_confval_types` only
1365    /// **warns** about a value outside it — the build continues with the
1366    /// offending value untouched (probe E, recorded in the task-2 brief).
1367    /// So `validate` returns warnings and never fails.
1368    #[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        // The comparison is case-sensitive, exactly like a python set test.
1400        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        // sphinx-build spells booleans as 0/1; `true`/`True` work too.
1412        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        // A dict setting is overridden key by key, which leaves the other
1427        // defaults in place.
1428        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}