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    /// Tags set via `-t` (consumed by `only`/`ifconfig` once M2 lands)
123    pub tags: Vec<String>,
124
125    /// Cache/doctree directory override (Sphinx `-d`); defaults to
126    /// `<output>/.sphinx-ultra-cache` when unset
127    pub doctree_dir: Option<std::path::PathBuf>,
128
129    /// Extra HTML template variables (conf.py `html_context`, CLI `-A`)
130    pub html_context: std::collections::HashMap<String, serde_json::Value>,
131
132    /// Run directive/role validation during the build
133    pub validate_directives: bool,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
137#[serde(default)]
138pub struct OutputConfig {
139    /// Output HTML format
140    pub html_theme: String,
141
142    /// Enable syntax highlighting
143    pub syntax_highlighting: bool,
144
145    /// Syntax highlighting theme
146    pub highlight_theme: String,
147
148    /// Generate search index
149    pub search_index: bool,
150
151    /// Minify output HTML
152    pub minify_html: bool,
153
154    /// Compress output files
155    pub compress_output: bool,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
159#[serde(default)]
160pub struct ThemeConfig {
161    /// Theme name
162    pub name: String,
163
164    /// Theme-specific configuration
165    pub options: serde_json::Value,
166
167    /// Custom CSS files
168    pub custom_css: Vec<PathBuf>,
169
170    /// Custom JavaScript files
171    pub custom_js: Vec<PathBuf>,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
175#[serde(default)]
176pub struct OptimizationConfig {
177    /// Enable parallel processing
178    pub parallel_processing: bool,
179
180    /// Enable incremental builds
181    pub incremental_builds: bool,
182
183    /// Cache parsed documents
184    pub document_caching: bool,
185
186    /// Optimize images
187    pub image_optimization: bool,
188
189    /// Bundle assets
190    pub asset_bundling: bool,
191}
192
193impl Default for BuildConfig {
194    fn default() -> Self {
195        Self {
196            parallel_jobs: None,
197            max_cache_size_mb: 500,
198            cache_expiration_hours: 24,
199            output: OutputConfig::default(),
200            theme: ThemeConfig::default(),
201            extensions: vec![
202                "sphinx.ext.autodoc".to_string(),
203                "sphinx.ext.viewcode".to_string(),
204                "sphinx.ext.intersphinx".to_string(),
205            ],
206            template_dirs: vec![],
207            static_dirs: vec![],
208            optimization: OptimizationConfig::default(),
209
210            // Sphinx-compatible defaults
211            project: "Sphinx Ultra Project".to_string(),
212            version: Some("1.0.0".to_string()),
213            release: Some("1.0.0".to_string()),
214            copyright: Some("2024, Sphinx Ultra".to_string()),
215            language: Some("en".to_string()),
216            root_doc: Some("index".to_string()),
217            html_style: vec!["sphinx_rtd_theme.css".to_string()],
218            html_css_files: vec![],
219            html_js_files: vec![],
220            html_static_path: vec![PathBuf::from("_static")],
221            html_logo: None,
222            html_favicon: None,
223            html_title: None,
224            html_short_title: None,
225            html_show_copyright: Some(true),
226            html_show_sphinx: Some(true),
227            html_copy_source: Some(true),
228            html_show_sourcelink: Some(true),
229            html_sourcelink_suffix: Some(".txt".to_string()),
230            html_use_index: Some(true),
231            html_use_opensearch: Some(false),
232            html_last_updated_fmt: Some("%b %d, %Y".to_string()),
233            templates_path: vec![PathBuf::from("_templates")],
234
235            // Warning handling
236            fail_on_warning: false,
237
238            // File pattern matching (Sphinx compatibility)
239            include_patterns: vec!["**".to_string()],
240            exclude_patterns: vec![],
241
242            nitpicky: false,
243            tags: vec![],
244            doctree_dir: None,
245            html_context: std::collections::HashMap::new(),
246            validate_directives: true,
247        }
248    }
249}
250
251impl Default for OutputConfig {
252    fn default() -> Self {
253        Self {
254            html_theme: "sphinx_rtd_theme".to_string(),
255            syntax_highlighting: true,
256            highlight_theme: "github".to_string(),
257            search_index: true,
258            minify_html: false,
259            compress_output: false,
260        }
261    }
262}
263
264impl Default for ThemeConfig {
265    fn default() -> Self {
266        Self {
267            name: "sphinx_rtd_theme".to_string(),
268            options: serde_json::json!({}),
269            custom_css: vec![],
270            custom_js: vec![],
271        }
272    }
273}
274
275impl Default for OptimizationConfig {
276    fn default() -> Self {
277        Self {
278            parallel_processing: true,
279            incremental_builds: true,
280            document_caching: true,
281            image_optimization: false,
282            asset_bundling: false,
283        }
284    }
285}
286
287impl BuildConfig {
288    pub fn from_file<P: AsRef<std::path::Path>>(path: P) -> Result<Self> {
289        let path = path.as_ref();
290
291        // Sphinx projects configure via conf.py; route it to the Python
292        // config parser so `--config conf.py` behaves like auto-detection.
293        let is_python = path.file_name().and_then(|s| s.to_str()) == Some("conf.py")
294            || path.extension().and_then(|s| s.to_str()) == Some("py");
295        if is_python {
296            return Self::from_conf_py(path);
297        }
298
299        let content = std::fs::read_to_string(path)
300            .map_err(|e| anyhow::anyhow!("cannot read config file {}: {e}", path.display()))?;
301        let config = if path.extension().and_then(|s| s.to_str()) == Some("yaml")
302            || path.extension().and_then(|s| s.to_str()) == Some("yml")
303        {
304            serde_yaml::from_str(&content)
305                .map_err(|e| anyhow::anyhow!("invalid config file {}: {e}", path.display()))?
306        } else {
307            serde_json::from_str(&content)
308                .map_err(|e| anyhow::anyhow!("invalid config file {}: {e}", path.display()))?
309        };
310        Ok(config)
311    }
312
313    /// Load configuration from a Sphinx conf.py file
314    pub fn from_conf_py<P: AsRef<std::path::Path>>(conf_py_path: P) -> Result<Self> {
315        let conf_py_path = conf_py_path.as_ref();
316        let mut parser = PythonConfigParser::new()?;
317        let conf_py_config = parser.parse_conf_py(conf_py_path)?;
318        // Silent dropping is banned: surface every construct the parser
319        // could not handle.
320        for warning in parser.warnings() {
321            log::warn!(
322                "{}:{}: {}",
323                conf_py_path.display(),
324                warning.line,
325                warning.message
326            );
327        }
328        Ok(conf_py_config.to_build_config())
329    }
330
331    /// Try to auto-detect and load configuration from various sources
332    pub fn auto_detect<P: AsRef<std::path::Path>>(source_dir: P) -> Result<Self> {
333        let source_dir = source_dir.as_ref();
334
335        // Try conf.py first (Sphinx standard)
336        let conf_py_path = source_dir.join("conf.py");
337        if conf_py_path.exists() {
338            return Self::from_conf_py(conf_py_path);
339        }
340
341        // Try sphinx-ultra.yaml
342        let yaml_path = source_dir.join("sphinx-ultra.yaml");
343        if yaml_path.exists() {
344            return Self::from_file(yaml_path);
345        }
346
347        // Try sphinx-ultra.yml
348        let yml_path = source_dir.join("sphinx-ultra.yml");
349        if yml_path.exists() {
350            return Self::from_file(yml_path);
351        }
352
353        // Try sphinx-ultra.json
354        let json_path = source_dir.join("sphinx-ultra.json");
355        if json_path.exists() {
356            return Self::from_file(json_path);
357        }
358
359        // Return default configuration
360        Ok(Self::default())
361    }
362
363    /// Apply a `-D key=value` override (sphinx-build semantics): the value is
364    /// coerced to the type the field already has, dotted keys reach the nested
365    /// sections (`output.*`, `theme.*`) and map-typed settings
366    /// (`html_context.name`), and an unknown key warns and is ignored rather
367    /// than failing the build.
368    ///
369    /// Returns the sphinx-style warning message when the override was ignored
370    /// — the caller decides how to report it (it must count toward `-W`).
371    pub fn apply_override(&mut self, key: &str, value: &str) -> Result<Option<String>> {
372        // `html_theme` is the Sphinx name; it lives in two places here.
373        // Fan aliases out first so both copies stay in sync.
374        match key {
375            "html_theme" => {
376                self.apply_override("output.html_theme", value)?;
377                return self.apply_override("theme.name", value);
378            }
379            "templates_path" => {
380                self.apply_override("template_dirs", value)?;
381                // fall through to set templates_path itself below
382            }
383            "html_static_path" => {
384                self.apply_override("static_dirs", value)?;
385                // fall through to set html_static_path itself below
386            }
387            _ => {}
388        }
389
390        let mut tree = serde_json::to_value(&*self)?;
391
392        // Resolve the dotted path. A key missing from its parent object is
393        // inserted as Null (map-typed settings like html_context accept new
394        // keys); whether it truly landed is checked after the round-trip —
395        // structs silently drop unknown fields, which we report as unknown.
396        let mut slot = &mut tree;
397        for part in key.split('.') {
398            slot = match slot {
399                serde_json::Value::Object(map) => map
400                    .entry(part.to_string())
401                    .or_insert(serde_json::Value::Null),
402                _ => {
403                    return Ok(Some(format!(
404                        "unknown config value '{}' in override, ignoring",
405                        key
406                    )))
407                }
408            };
409        }
410
411        // Whole-dict overrides are not expressible on the command line
412        // (sphinx-build warns and continues too).
413        if slot.is_object() {
414            return Ok(Some(format!(
415                "cannot override dictionary config setting '{}', ignoring (use -D {}.key=value)",
416                key, key
417            )));
418        }
419
420        let coerced = Self::coerce_override_value(slot, key, value)?;
421        let retry_as_string = matches!(coerced, serde_json::Value::Number(_))
422            && matches!(slot, serde_json::Value::Null);
423        *slot = coerced;
424
425        let applied: Self = match serde_json::from_value(tree.clone()) {
426            Ok(config) => config,
427            // A Null slot gave no type information and the numeric guess was
428            // wrong (e.g. -D html_title=2024 targets an Option<String>):
429            // retry with the raw string before giving up.
430            Err(first_err) => {
431                if retry_as_string {
432                    let mut retry_tree = tree;
433                    let mut retry_slot = &mut retry_tree;
434                    for part in key.split('.') {
435                        retry_slot = retry_slot.get_mut(part).expect("path resolved above");
436                    }
437                    *retry_slot = serde_json::Value::String(value.to_string());
438                    serde_json::from_value(retry_tree).map_err(|e| {
439                        anyhow::anyhow!("invalid value for -D {}={}: {}", key, value, e)
440                    })?
441                } else {
442                    return Err(anyhow::anyhow!(
443                        "invalid value for -D {}={}: {}",
444                        key,
445                        value,
446                        first_err
447                    ));
448                }
449            }
450        };
451
452        // Did the key survive the round-trip? Structs drop unknown fields
453        // silently; a vanished key means the setting doesn't exist.
454        let check = serde_json::to_value(&applied)?;
455        let mut probe = Some(&check);
456        for part in key.split('.') {
457            probe = probe.and_then(|v| v.get(part));
458        }
459        if probe.is_none() {
460            return Ok(Some(format!(
461                "unknown config value '{}' in override, ignoring",
462                key
463            )));
464        }
465
466        *self = applied;
467        Ok(None)
468    }
469
470    /// Coerce a CLI string to the JSON type currently occupying the slot.
471    fn coerce_override_value(
472        current: &serde_json::Value,
473        key: &str,
474        value: &str,
475    ) -> Result<serde_json::Value> {
476        use serde_json::Value;
477        Ok(match current {
478            Value::Bool(_) => match value {
479                "1" | "true" | "True" => Value::Bool(true),
480                "0" | "false" | "False" => Value::Bool(false),
481                other => anyhow::bail!("invalid boolean for -D {}={}", key, other),
482            },
483            Value::Number(_) => value
484                .parse::<i64>()
485                .map(Value::from)
486                .or_else(|_| value.parse::<f64>().map(Value::from))
487                .map_err(|_| anyhow::anyhow!("invalid number for -D {}={}", key, value))?,
488            Value::Array(_) => Value::Array(
489                value
490                    .split(',')
491                    .filter(|s| !s.is_empty())
492                    .map(|s| Value::String(s.trim().to_string()))
493                    .collect(),
494            ),
495            // Null slots are Option<...> fields: prefer a number if the value
496            // parses as one (parallel_jobs), otherwise store the string.
497            Value::Null => value
498                .parse::<i64>()
499                .map(Value::from)
500                .unwrap_or_else(|_| Value::String(value.to_string())),
501            _ => Value::String(value.to_string()),
502        })
503    }
504
505    #[allow(dead_code)]
506    pub fn save_to_file<P: AsRef<std::path::Path>>(&self, path: P) -> Result<()> {
507        let content = if path.as_ref().extension().and_then(|s| s.to_str()) == Some("yaml")
508            || path.as_ref().extension().and_then(|s| s.to_str()) == Some("yml")
509        {
510            serde_yaml::to_string(self)?
511        } else {
512            serde_json::to_string_pretty(self)?
513        };
514        std::fs::write(path, content)?;
515        Ok(())
516    }
517}
518#[cfg(test)]
519mod tests {
520    use super::*;
521    use std::fs;
522    use std::path::Path;
523    use tempfile::TempDir;
524
525    #[test]
526    fn minimal_yaml_loads_with_defaults() {
527        let temp_dir = TempDir::new().unwrap();
528        let p = temp_dir.path().join("sphinx-ultra.yaml");
529        fs::write(&p, "project: 'Tiny'\n").unwrap();
530
531        let config = BuildConfig::from_file(&p).unwrap();
532        assert_eq!(config.project, "Tiny");
533        assert_eq!(config.max_cache_size_mb, 500); // default filled in
534        assert_eq!(config.include_patterns, vec!["**".to_string()]);
535    }
536
537    #[test]
538    fn from_file_routes_conf_py() {
539        let temp_dir = TempDir::new().unwrap();
540        let p = temp_dir.path().join("conf.py");
541        fs::write(&p, "project = 'PyProject'\n").unwrap();
542
543        let config = BuildConfig::from_file(&p).unwrap();
544        assert_eq!(config.project, "PyProject");
545    }
546
547    #[test]
548    fn shipped_yaml_examples_load() {
549        for rel in ["sphinx-ultra.yaml", "examples/basic/sphinx-ultra.yaml"] {
550            let p = Path::new(env!("CARGO_MANIFEST_DIR")).join(rel);
551            BuildConfig::from_file(&p).unwrap_or_else(|e| panic!("{rel} failed to load: {e}"));
552        }
553    }
554
555    #[test]
556    fn test_auto_detect_conf_py() {
557        let temp_dir = TempDir::new().unwrap();
558        let root = temp_dir.path();
559
560        fs::write(root.join("conf.py"), "project = 'Test Project'\n").unwrap();
561
562        let config = BuildConfig::auto_detect(root).unwrap();
563        assert_eq!(config.project, "Test Project");
564    }
565
566    #[test]
567    fn test_auto_detect_yaml() {
568        let temp_dir = TempDir::new().unwrap();
569        let root = temp_dir.path();
570
571        let yaml_content = r#"
572project: 'YAML Project'
573output:
574  html_theme: 'alabaster'
575"#;
576        fs::write(root.join("sphinx-ultra.yaml"), yaml_content).unwrap();
577
578        let config = BuildConfig::auto_detect(root).unwrap();
579        assert_eq!(config.project, "YAML Project");
580    }
581
582    #[test]
583    fn test_auto_detect_default() {
584        let temp_dir = TempDir::new().unwrap();
585        let root = temp_dir.path();
586
587        // No config files
588        let config = BuildConfig::auto_detect(root).unwrap();
589        assert_eq!(config, BuildConfig::default());
590    }
591
592    #[test]
593    fn override_string_bool_number_and_list() {
594        let mut config = BuildConfig::default();
595        config.apply_override("project", "Custom").unwrap();
596        assert_eq!(config.project, "Custom");
597
598        config.apply_override("fail_on_warning", "1").unwrap();
599        assert!(config.fail_on_warning);
600        config.apply_override("fail_on_warning", "False").unwrap();
601        assert!(!config.fail_on_warning);
602
603        config.apply_override("max_cache_size_mb", "64").unwrap();
604        assert_eq!(config.max_cache_size_mb, 64);
605
606        config
607            .apply_override("exclude_patterns", "drafts/**,_scratch")
608            .unwrap();
609        assert_eq!(
610            config.exclude_patterns,
611            vec!["drafts/**".to_string(), "_scratch".to_string()]
612        );
613    }
614
615    #[test]
616    fn override_dotted_path_reaches_nested_sections() {
617        let mut config = BuildConfig::default();
618        config.apply_override("output.minify_html", "true").unwrap();
619        assert!(config.output.minify_html);
620    }
621
622    #[test]
623    fn override_html_theme_alias_syncs_both_copies() {
624        let mut config = BuildConfig::default();
625        config.apply_override("html_theme", "furo").unwrap();
626        assert_eq!(config.output.html_theme, "furo");
627        assert_eq!(config.theme.name, "furo");
628    }
629
630    #[test]
631    fn override_templates_path_syncs_template_dirs() {
632        let mut config = BuildConfig::default();
633        config
634            .apply_override("templates_path", "_mytemplates")
635            .unwrap();
636        assert_eq!(config.templates_path, vec![PathBuf::from("_mytemplates")]);
637        assert_eq!(config.template_dirs, vec![PathBuf::from("_mytemplates")]);
638    }
639
640    #[test]
641    fn override_unknown_key_is_ignored_not_error() {
642        let mut config = BuildConfig::default();
643        let before = config.clone();
644        let warning = config.apply_override("totally_unknown_key", "1").unwrap();
645        assert_eq!(config, before);
646        assert!(warning.unwrap().contains("unknown config value"));
647
648        // Unknown nested keys are dropped by the struct round-trip and
649        // reported the same way.
650        let warning = config.apply_override("output.bogus_knob", "1").unwrap();
651        assert_eq!(config, before);
652        assert!(warning.unwrap().contains("unknown config value"));
653    }
654
655    #[test]
656    fn override_option_number_field() {
657        let mut config = BuildConfig::default();
658        assert!(config
659            .apply_override("parallel_jobs", "3")
660            .unwrap()
661            .is_none());
662        assert_eq!(config.parallel_jobs, Some(3));
663    }
664
665    #[test]
666    fn override_bad_bool_is_an_error() {
667        let mut config = BuildConfig::default();
668        assert!(config.apply_override("nitpicky", "maybe").is_err());
669    }
670
671    #[test]
672    fn override_numeric_value_for_unset_string_option_stays_a_string() {
673        // sphinx-build sets html_title="2024"; the numeric guess for the
674        // Null slot must fall back to a string instead of failing the build.
675        let mut config = BuildConfig::default();
676        assert!(config
677            .apply_override("html_title", "2024")
678            .unwrap()
679            .is_none());
680        assert_eq!(config.html_title, Some("2024".to_string()));
681    }
682
683    #[test]
684    fn override_dict_member_and_whole_dict() {
685        let mut config = BuildConfig::default();
686
687        // -D html_context.banner=on inserts into the map (sphinx-build syntax)
688        assert!(config
689            .apply_override("html_context.banner", "on")
690            .unwrap()
691            .is_none());
692        assert_eq!(
693            config.html_context.get("banner"),
694            Some(&serde_json::Value::String("on".to_string()))
695        );
696
697        // A whole-dict override warns and is ignored, like sphinx-build
698        let before = config.clone();
699        let warning = config.apply_override("html_context", "x").unwrap();
700        assert_eq!(config, before);
701        assert!(warning
702            .unwrap()
703            .contains("cannot override dictionary config setting"));
704    }
705}