Skip to main content

plissken_core/
config.rs

1//! Configuration for plissken projects
2
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6use thiserror::Error;
7
8// =============================================================================
9// Constants
10// =============================================================================
11
12/// Default output format for documentation
13pub const DEFAULT_OUTPUT_FORMAT: &str = "markdown";
14
15/// Default output path for generated documentation
16pub const DEFAULT_OUTPUT_PATH: &str = "docs/api";
17
18/// Default docs.rs base URL for external links
19pub const DEFAULT_DOCS_RS_URL: &str = "https://docs.rs";
20
21/// Default SSG template name
22pub const DEFAULT_TEMPLATE: &str = "mkdocs-material";
23
24/// Version source identifier for Cargo.toml
25pub const VERSION_SOURCE_CARGO: &str = "cargo";
26
27/// Version source identifier for pyproject.toml
28pub const VERSION_SOURCE_PYPROJECT: &str = "pyproject";
29
30/// Cargo manifest filename
31pub const CARGO_MANIFEST: &str = "Cargo.toml";
32
33/// Python project manifest filename
34pub const PYPROJECT_MANIFEST: &str = "pyproject.toml";
35
36/// Plissken configuration filename
37pub const PLISSKEN_CONFIG: &str = "plissken.toml";
38
39/// MkDocs Material template name
40pub const TEMPLATE_MKDOCS_MATERIAL: &str = "mkdocs-material";
41
42/// mdBook template name
43pub const TEMPLATE_MDBOOK: &str = "mdbook";
44
45/// Default crates configuration value
46pub const DEFAULT_CRATES: &str = ".";
47
48/// Configuration validation error
49#[derive(Debug, Error)]
50pub enum ConfigError {
51    #[error("no language configured: add [rust] or [python] section")]
52    NoLanguageConfigured,
53
54    #[error("version_from is '{0}' but {1} not found")]
55    VersionSourceNotFound(String, String),
56
57    #[error("rust crate path '{0}' does not exist")]
58    RustCrateNotFound(PathBuf),
59
60    #[error("python source path '{0}' does not exist")]
61    PythonSourceNotFound(PathBuf),
62
63    #[error("git repository not found (version_from = 'git')")]
64    GitRepoNotFound,
65}
66
67/// Configuration warning (non-fatal issue)
68#[derive(Debug, Clone, Serialize)]
69pub struct ConfigWarning {
70    /// The config field that triggered the warning
71    pub field: String,
72    /// Human-readable warning message
73    pub message: String,
74    /// Optional hint for resolution
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub hint: Option<String>,
77}
78
79impl ConfigWarning {
80    /// Create a new warning
81    pub fn new(field: impl Into<String>, message: impl Into<String>) -> Self {
82        Self {
83            field: field.into(),
84            message: message.into(),
85            hint: None,
86        }
87    }
88
89    /// Add a hint to the warning
90    pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
91        self.hint = Some(hint.into());
92        self
93    }
94}
95
96/// Result of configuration validation
97#[derive(Debug)]
98pub struct ValidationResult {
99    /// Whether validation passed (no errors)
100    pub valid: bool,
101    /// Validation warnings (non-fatal)
102    pub warnings: Vec<ConfigWarning>,
103}
104
105/// Root configuration from plissken.toml
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct Config {
108    pub project: ProjectConfig,
109    pub output: OutputConfig,
110    #[serde(default)]
111    pub rust: Option<RustConfig>,
112    #[serde(default)]
113    pub python: Option<PythonConfig>,
114    #[serde(default)]
115    pub links: LinksConfig,
116    #[serde(default)]
117    pub quality: QualityConfig,
118}
119
120/// Project metadata
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct ProjectConfig {
123    pub name: String,
124    #[serde(default = "default_version_from")]
125    pub version_from: VersionSource,
126}
127
128fn default_version_from() -> VersionSource {
129    VersionSource::Git
130}
131
132/// Where to get version information
133#[derive(Debug, Clone, Serialize, Deserialize, Default)]
134#[serde(rename_all = "lowercase")]
135pub enum VersionSource {
136    #[default]
137    Git,
138    Cargo,
139    Pyproject,
140}
141
142/// Output configuration
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct OutputConfig {
145    #[serde(default = "default_format")]
146    pub format: String,
147    #[serde(default = "default_output_path")]
148    pub path: PathBuf,
149    #[serde(default)]
150    pub template: Option<String>,
151    /// Path prefix for nav entries when rendering into a subfolder of an existing doc site.
152    /// E.g., `prefix = "api"` makes nav entries like `api/rust/mycrate.md` instead of `rust/mycrate.md`.
153    #[serde(default)]
154    pub prefix: Option<String>,
155}
156
157fn default_format() -> String {
158    DEFAULT_OUTPUT_FORMAT.to_string()
159}
160
161fn default_output_path() -> PathBuf {
162    PathBuf::from(DEFAULT_OUTPUT_PATH)
163}
164
165/// Rust source configuration
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct RustConfig {
168    pub crates: Vec<PathBuf>,
169    #[serde(default)]
170    pub entry_point: Option<String>,
171}
172
173/// Python source configuration
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct PythonConfig {
176    /// The Python package name
177    pub package: String,
178    /// Source directory containing Python files (defaults to package name)
179    #[serde(default)]
180    pub source: Option<PathBuf>,
181    /// Automatically discover Python modules by walking the filesystem
182    #[serde(default)]
183    pub auto_discover: bool,
184    /// Explicit module mappings (overrides auto-discovered modules)
185    #[serde(default)]
186    pub modules: HashMap<String, ModuleSourceType>,
187}
188
189/// Source type for a Python module
190#[derive(Debug, Clone, Serialize, Deserialize)]
191#[serde(rename_all = "lowercase")]
192pub enum ModuleSourceType {
193    Pyo3,
194    Python,
195}
196
197/// Linking configuration
198#[derive(Debug, Clone, Serialize, Deserialize, Default)]
199pub struct LinksConfig {
200    #[serde(default = "default_dependencies")]
201    pub dependencies: DependencySource,
202    #[serde(default = "default_docs_rs")]
203    pub docs_rs_base: String,
204}
205
206fn default_dependencies() -> DependencySource {
207    DependencySource::CargoLock
208}
209
210fn default_docs_rs() -> String {
211    DEFAULT_DOCS_RS_URL.to_string()
212}
213
214/// Where to get dependency versions
215#[derive(Debug, Clone, Serialize, Deserialize, Default)]
216#[serde(rename_all = "snake_case")]
217pub enum DependencySource {
218    #[default]
219    CargoLock,
220    CargoToml,
221    None,
222}
223
224/// Quality/linting configuration
225#[derive(Debug, Clone, Serialize, Deserialize, Default)]
226pub struct QualityConfig {
227    #[serde(default)]
228    pub require_docstrings: bool,
229    #[serde(default)]
230    pub min_coverage: Option<f64>,
231    #[serde(default)]
232    pub fail_on_broken_links: bool,
233}
234
235impl Config {
236    /// Load configuration from a plissken.toml file.
237    ///
238    /// # Errors
239    ///
240    /// Returns `PlisskenError::ConfigNotFound` if the file doesn't exist,
241    /// `PlisskenError::ConfigParse` if the TOML is invalid.
242    pub fn from_file(path: &std::path::Path) -> crate::error::Result<Self> {
243        use crate::error::PlisskenError;
244
245        let content = std::fs::read_to_string(path).map_err(|e| {
246            if e.kind() == std::io::ErrorKind::NotFound {
247                PlisskenError::ConfigNotFound {
248                    path: path.to_path_buf(),
249                }
250            } else {
251                PlisskenError::Io {
252                    context: format!("failed to read config file '{}'", path.display()),
253                    source: e,
254                }
255            }
256        })?;
257
258        let config: Config = toml::from_str(&content).map_err(|e| PlisskenError::ConfigParse {
259            message: e.to_string(),
260            source: Some(e),
261        })?;
262
263        Ok(config)
264    }
265
266    /// Apply inferred defaults from manifest files (Cargo.toml, pyproject.toml).
267    ///
268    /// Infers project metadata from existing manifest files and fills in missing
269    /// configuration values. Explicit configuration always takes precedence over
270    /// inferred values.
271    ///
272    /// # Arguments
273    /// * `project_root` - The directory containing manifest files
274    ///
275    /// # Inferred values
276    /// - `project.name` - From pyproject.toml [project].name or Cargo.toml [package].name
277    /// - `rust.crates` - From Cargo.toml [workspace].members or single crate root
278    /// - `rust.entry_point` - From Cargo.toml [package].name
279    /// - `python.package` - From pyproject.toml [project].name (with dash-to-underscore)
280    /// - `python.source` - From pyproject.toml [tool.maturin].python-source
281    pub fn with_inferred_defaults(mut self, project_root: &Path) -> Self {
282        use crate::manifest::InferredConfig;
283
284        let inferred = InferredConfig::from_directory(project_root);
285
286        // Fill in project name if empty
287        if self.project.name.is_empty()
288            && let Some(name) = inferred.project_name
289        {
290            self.project.name = name;
291        }
292
293        // Fill in Rust config if present but incomplete
294        if let Some(ref mut rust) = self.rust {
295            // Fill in crates if empty
296            if rust.crates.is_empty()
297                && let Some(crates) = inferred.rust_crates
298            {
299                rust.crates = crates;
300            }
301            // Fill in entry_point if not set
302            if rust.entry_point.is_none() {
303                rust.entry_point = inferred.rust_entry_point;
304            }
305        }
306
307        // Fill in Python config if present but incomplete
308        if let Some(ref mut python) = self.python {
309            // Fill in package name if empty
310            if python.package.is_empty()
311                && let Some(pkg) = inferred.python_package
312            {
313                python.package = pkg;
314            }
315            // Fill in source if not set
316            if python.source.is_none() {
317                python.source = inferred.python_source;
318            }
319        }
320
321        self
322    }
323
324    /// Validate configuration semantically.
325    ///
326    /// Performs validation beyond TOML parsing:
327    /// - At least one language section must be configured
328    /// - version_from source file must exist
329    /// - Configured paths must exist
330    ///
331    /// Returns `Ok(ValidationResult)` with any warnings if validation passes,
332    /// or `Err(ConfigError)` if validation fails.
333    ///
334    /// # Arguments
335    /// * `project_root` - The directory containing the plissken.toml file
336    pub fn validate(&self, project_root: &Path) -> Result<ValidationResult, ConfigError> {
337        let mut warnings = Vec::new();
338
339        // Must have at least one language configured
340        if self.rust.is_none() && self.python.is_none() {
341            return Err(ConfigError::NoLanguageConfigured);
342        }
343
344        // Validate version_from source exists
345        self.validate_version_source(project_root)?;
346
347        // Validate Rust configuration
348        if let Some(ref rust_config) = self.rust {
349            self.validate_rust_config(rust_config, project_root, &mut warnings)?;
350        }
351
352        // Validate Python configuration
353        if let Some(ref python_config) = self.python {
354            self.validate_python_config(python_config, project_root, &mut warnings)?;
355        }
356
357        Ok(ValidationResult {
358            valid: true,
359            warnings,
360        })
361    }
362
363    fn validate_version_source(&self, project_root: &Path) -> Result<(), ConfigError> {
364        match self.project.version_from {
365            VersionSource::Cargo => {
366                let cargo_toml = project_root.join(CARGO_MANIFEST);
367                if !cargo_toml.exists() {
368                    return Err(ConfigError::VersionSourceNotFound(
369                        VERSION_SOURCE_CARGO.to_string(),
370                        CARGO_MANIFEST.to_string(),
371                    ));
372                }
373            }
374            VersionSource::Pyproject => {
375                let pyproject = project_root.join(PYPROJECT_MANIFEST);
376                if !pyproject.exists() {
377                    return Err(ConfigError::VersionSourceNotFound(
378                        VERSION_SOURCE_PYPROJECT.to_string(),
379                        PYPROJECT_MANIFEST.to_string(),
380                    ));
381                }
382            }
383            VersionSource::Git => {
384                // Check if we're in a git repository
385                let git_check = std::process::Command::new("git")
386                    .args(["rev-parse", "--git-dir"])
387                    .current_dir(project_root)
388                    .output();
389
390                match git_check {
391                    Ok(output) if output.status.success() => {}
392                    _ => return Err(ConfigError::GitRepoNotFound),
393                }
394            }
395        }
396        Ok(())
397    }
398
399    fn validate_rust_config(
400        &self,
401        rust_config: &RustConfig,
402        project_root: &Path,
403        warnings: &mut Vec<ConfigWarning>,
404    ) -> Result<(), ConfigError> {
405        if rust_config.crates.is_empty() {
406            warnings.push(
407                ConfigWarning::new(
408                    "rust.crates",
409                    "no crates configured; no Rust docs will be generated",
410                )
411                .with_hint("add crate paths to the crates array"),
412            );
413            return Ok(());
414        }
415
416        for crate_path in &rust_config.crates {
417            let crate_dir = project_root.join(crate_path);
418
419            if !crate_dir.exists() {
420                return Err(ConfigError::RustCrateNotFound(crate_path.clone()));
421            }
422
423            // Check for Cargo.toml in crate directory (warning, not error)
424            let cargo_toml = crate_dir.join(CARGO_MANIFEST);
425            if !cargo_toml.exists() && crate_path.as_os_str() != DEFAULT_CRATES {
426                warnings.push(ConfigWarning::new(
427                    "rust.crates",
428                    format!("no Cargo.toml found in crate '{}'", crate_path.display()),
429                ));
430            }
431
432            // Check for src directory (warning, not error)
433            let src_dir = crate_dir.join("src");
434            if !src_dir.exists() {
435                warnings.push(
436                    ConfigWarning::new(
437                        "rust.crates",
438                        format!("no src/ directory in crate '{}'", crate_path.display()),
439                    )
440                    .with_hint("Rust source files are typically in a src/ directory"),
441                );
442            }
443        }
444
445        Ok(())
446    }
447
448    fn validate_python_config(
449        &self,
450        python_config: &PythonConfig,
451        project_root: &Path,
452        warnings: &mut Vec<ConfigWarning>,
453    ) -> Result<(), ConfigError> {
454        // Determine Python source directory
455        let python_dir = if let Some(ref source) = python_config.source {
456            project_root.join(source)
457        } else {
458            project_root.join(&python_config.package)
459        };
460
461        if !python_dir.exists() {
462            return Err(ConfigError::PythonSourceNotFound(python_dir));
463        }
464
465        // Check for __init__.py (warning, not error)
466        let init_py = python_dir.join("__init__.py");
467        if !init_py.exists() {
468            warnings.push(
469                ConfigWarning::new(
470                    "python.package",
471                    format!(
472                        "no __init__.py in '{}' - may not be a proper Python package",
473                        python_dir.display()
474                    ),
475                )
476                .with_hint("add __init__.py to make it a Python package"),
477            );
478        }
479
480        // Check for empty modules list (warning)
481        if python_config.modules.is_empty() {
482            warnings.push(
483                ConfigWarning::new(
484                    "python.modules",
485                    "no modules listed; consider using auto_discover or listing modules explicitly",
486                )
487                .with_hint("modules will be discovered from filesystem if not listed"),
488            );
489        }
490
491        Ok(())
492    }
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498    use std::collections::HashMap;
499    use tempfile::TempDir;
500
501    fn minimal_config() -> Config {
502        Config {
503            project: ProjectConfig {
504                name: "test".to_string(),
505                version_from: VersionSource::Git,
506            },
507            output: OutputConfig {
508                format: "markdown".to_string(),
509                path: PathBuf::from("docs/api"),
510                template: None,
511                prefix: None,
512            },
513            rust: None,
514            python: None,
515            links: LinksConfig::default(),
516            quality: QualityConfig::default(),
517        }
518    }
519
520    #[test]
521    fn test_validate_no_language_configured() {
522        let config = minimal_config();
523        let temp_dir = TempDir::new().unwrap();
524
525        let result = config.validate(temp_dir.path());
526        assert!(matches!(result, Err(ConfigError::NoLanguageConfigured)));
527    }
528
529    #[test]
530    fn test_validate_rust_crate_not_found() {
531        let mut config = minimal_config();
532        config.rust = Some(RustConfig {
533            crates: vec![PathBuf::from("nonexistent")],
534            entry_point: None,
535        });
536
537        let temp_dir = TempDir::new().unwrap();
538        // Create a git repo so version_from works
539        std::process::Command::new("git")
540            .args(["init"])
541            .current_dir(temp_dir.path())
542            .output()
543            .unwrap();
544
545        let result = config.validate(temp_dir.path());
546        assert!(matches!(result, Err(ConfigError::RustCrateNotFound(_))));
547    }
548
549    #[test]
550    fn test_validate_python_source_not_found() {
551        let mut config = minimal_config();
552        config.python = Some(PythonConfig {
553            package: "nonexistent".to_string(),
554            source: None,
555            auto_discover: false,
556            modules: HashMap::new(),
557        });
558
559        let temp_dir = TempDir::new().unwrap();
560        // Create a git repo so version_from works
561        std::process::Command::new("git")
562            .args(["init"])
563            .current_dir(temp_dir.path())
564            .output()
565            .unwrap();
566
567        let result = config.validate(temp_dir.path());
568        assert!(matches!(result, Err(ConfigError::PythonSourceNotFound(_))));
569    }
570
571    #[test]
572    fn test_validate_version_source_cargo_not_found() {
573        let mut config = minimal_config();
574        config.project.version_from = VersionSource::Cargo;
575        config.rust = Some(RustConfig {
576            crates: vec![PathBuf::from(".")],
577            entry_point: None,
578        });
579
580        let temp_dir = TempDir::new().unwrap();
581        // Create src directory but no Cargo.toml
582        std::fs::create_dir(temp_dir.path().join("src")).unwrap();
583
584        let result = config.validate(temp_dir.path());
585        assert!(matches!(
586            result,
587            Err(ConfigError::VersionSourceNotFound(_, _))
588        ));
589    }
590
591    #[test]
592    fn test_validate_version_source_pyproject_not_found() {
593        let mut config = minimal_config();
594        config.project.version_from = VersionSource::Pyproject;
595        config.python = Some(PythonConfig {
596            package: "mypackage".to_string(),
597            source: None,
598            auto_discover: false,
599            modules: HashMap::new(),
600        });
601
602        let temp_dir = TempDir::new().unwrap();
603        // Create package directory but no pyproject.toml
604        std::fs::create_dir(temp_dir.path().join("mypackage")).unwrap();
605
606        let result = config.validate(temp_dir.path());
607        assert!(matches!(
608            result,
609            Err(ConfigError::VersionSourceNotFound(_, _))
610        ));
611    }
612
613    #[test]
614    fn test_validate_valid_rust_config() {
615        let mut config = minimal_config();
616        config.project.version_from = VersionSource::Cargo;
617        config.rust = Some(RustConfig {
618            crates: vec![PathBuf::from(".")],
619            entry_point: None,
620        });
621
622        let temp_dir = TempDir::new().unwrap();
623        // Create Cargo.toml and src directory
624        std::fs::write(
625            temp_dir.path().join("Cargo.toml"),
626            "[package]\nname = \"test\"\nversion = \"0.1.0\"\n",
627        )
628        .unwrap();
629        std::fs::create_dir(temp_dir.path().join("src")).unwrap();
630
631        let result = config.validate(temp_dir.path());
632        assert!(result.is_ok());
633        let validation = result.unwrap();
634        assert!(validation.valid);
635    }
636
637    #[test]
638    fn test_validate_valid_python_config() {
639        let mut config = minimal_config();
640        config.project.version_from = VersionSource::Pyproject;
641        config.python = Some(PythonConfig {
642            package: "mypackage".to_string(),
643            source: None,
644            auto_discover: false,
645            modules: HashMap::new(),
646        });
647
648        let temp_dir = TempDir::new().unwrap();
649        // Create pyproject.toml and package directory with __init__.py
650        std::fs::write(
651            temp_dir.path().join("pyproject.toml"),
652            "[project]\nname = \"mypackage\"\nversion = \"1.0.0\"\n",
653        )
654        .unwrap();
655        let pkg_dir = temp_dir.path().join("mypackage");
656        std::fs::create_dir(&pkg_dir).unwrap();
657        std::fs::write(pkg_dir.join("__init__.py"), "").unwrap();
658
659        let result = config.validate(temp_dir.path());
660        assert!(result.is_ok());
661        let validation = result.unwrap();
662        assert!(validation.valid);
663        // Should have warning about empty modules
664        assert!(!validation.warnings.is_empty());
665    }
666
667    #[test]
668    fn test_validate_warnings_for_missing_init_py() {
669        let mut config = minimal_config();
670        config.project.version_from = VersionSource::Pyproject;
671        config.python = Some(PythonConfig {
672            package: "mypackage".to_string(),
673            source: None,
674            auto_discover: false,
675            modules: HashMap::new(),
676        });
677
678        let temp_dir = TempDir::new().unwrap();
679        std::fs::write(
680            temp_dir.path().join("pyproject.toml"),
681            "[project]\nname = \"mypackage\"\nversion = \"1.0.0\"\n",
682        )
683        .unwrap();
684        // Create package directory WITHOUT __init__.py
685        std::fs::create_dir(temp_dir.path().join("mypackage")).unwrap();
686
687        let result = config.validate(temp_dir.path());
688        assert!(result.is_ok());
689        let validation = result.unwrap();
690        // Should have warning about missing __init__.py
691        assert!(
692            validation
693                .warnings
694                .iter()
695                .any(|w| w.message.contains("__init__.py"))
696        );
697    }
698
699    #[test]
700    fn test_validate_warnings_for_missing_src_dir() {
701        let mut config = minimal_config();
702        config.project.version_from = VersionSource::Cargo;
703        config.rust = Some(RustConfig {
704            crates: vec![PathBuf::from(".")],
705            entry_point: None,
706        });
707
708        let temp_dir = TempDir::new().unwrap();
709        // Create Cargo.toml but no src directory
710        std::fs::write(
711            temp_dir.path().join("Cargo.toml"),
712            "[package]\nname = \"test\"\nversion = \"0.1.0\"\n",
713        )
714        .unwrap();
715
716        let result = config.validate(temp_dir.path());
717        assert!(result.is_ok());
718        let validation = result.unwrap();
719        // Should have warning about missing src/
720        assert!(
721            validation
722                .warnings
723                .iter()
724                .any(|w| w.message.contains("src/"))
725        );
726    }
727
728    #[test]
729    fn test_with_inferred_defaults_fills_project_name() {
730        let mut config = minimal_config();
731        config.project.name = "".to_string(); // Empty name to be filled
732
733        let temp_dir = TempDir::new().unwrap();
734        std::fs::write(
735            temp_dir.path().join("Cargo.toml"),
736            "[package]\nname = \"inferred-name\"\nversion = \"0.1.0\"\n",
737        )
738        .unwrap();
739
740        let config = config.with_inferred_defaults(temp_dir.path());
741        assert_eq!(config.project.name, "inferred-name");
742    }
743
744    #[test]
745    fn test_with_inferred_defaults_preserves_explicit_name() {
746        let mut config = minimal_config();
747        config.project.name = "explicit-name".to_string();
748
749        let temp_dir = TempDir::new().unwrap();
750        std::fs::write(
751            temp_dir.path().join("Cargo.toml"),
752            "[package]\nname = \"inferred-name\"\nversion = \"0.1.0\"\n",
753        )
754        .unwrap();
755
756        let config = config.with_inferred_defaults(temp_dir.path());
757        // Explicit name should be preserved
758        assert_eq!(config.project.name, "explicit-name");
759    }
760
761    #[test]
762    fn test_with_inferred_defaults_fills_rust_config() {
763        let mut config = minimal_config();
764        config.rust = Some(RustConfig {
765            crates: vec![], // Empty crates to be filled
766            entry_point: None,
767        });
768
769        let temp_dir = TempDir::new().unwrap();
770        std::fs::write(
771            temp_dir.path().join("Cargo.toml"),
772            r#"
773[workspace]
774members = ["crates/core", "crates/cli"]
775
776[package]
777name = "my-project"
778version = "0.1.0"
779"#,
780        )
781        .unwrap();
782
783        let config = config.with_inferred_defaults(temp_dir.path());
784        assert_eq!(
785            config.rust.as_ref().unwrap().crates,
786            vec![PathBuf::from("crates/core"), PathBuf::from("crates/cli")]
787        );
788        assert_eq!(
789            config.rust.as_ref().unwrap().entry_point,
790            Some("my-project".to_string())
791        );
792    }
793
794    #[test]
795    fn test_with_inferred_defaults_fills_python_config() {
796        let mut config = minimal_config();
797        config.python = Some(PythonConfig {
798            package: "".to_string(), // Empty package to be filled
799            source: None,
800            auto_discover: false,
801            modules: HashMap::new(),
802        });
803
804        let temp_dir = TempDir::new().unwrap();
805        std::fs::write(
806            temp_dir.path().join("pyproject.toml"),
807            r#"
808[project]
809name = "my-python-pkg"
810version = "1.0.0"
811
812[tool.maturin]
813python-source = "python"
814"#,
815        )
816        .unwrap();
817
818        let config = config.with_inferred_defaults(temp_dir.path());
819        assert_eq!(config.python.as_ref().unwrap().package, "my_python_pkg");
820        assert_eq!(
821            config.python.as_ref().unwrap().source,
822            Some(PathBuf::from("python"))
823        );
824    }
825
826    #[test]
827    fn test_with_inferred_defaults_preserves_explicit_python_config() {
828        let mut config = minimal_config();
829        config.python = Some(PythonConfig {
830            package: "explicit_pkg".to_string(),
831            source: Some(PathBuf::from("src")),
832            auto_discover: false,
833            modules: HashMap::new(),
834        });
835
836        let temp_dir = TempDir::new().unwrap();
837        std::fs::write(
838            temp_dir.path().join("pyproject.toml"),
839            r#"
840[project]
841name = "inferred-pkg"
842version = "1.0.0"
843
844[tool.maturin]
845python-source = "python"
846"#,
847        )
848        .unwrap();
849
850        let config = config.with_inferred_defaults(temp_dir.path());
851        // Explicit values should be preserved
852        assert_eq!(config.python.as_ref().unwrap().package, "explicit_pkg");
853        assert_eq!(
854            config.python.as_ref().unwrap().source,
855            Some(PathBuf::from("src"))
856        );
857    }
858
859    #[test]
860    fn test_with_inferred_defaults_pyproject_takes_precedence_for_name() {
861        let mut config = minimal_config();
862        config.project.name = "".to_string();
863
864        let temp_dir = TempDir::new().unwrap();
865        std::fs::write(
866            temp_dir.path().join("Cargo.toml"),
867            "[package]\nname = \"cargo-name\"\nversion = \"0.1.0\"\n",
868        )
869        .unwrap();
870        std::fs::write(
871            temp_dir.path().join("pyproject.toml"),
872            "[project]\nname = \"pyproject-name\"\nversion = \"1.0.0\"\n",
873        )
874        .unwrap();
875
876        let config = config.with_inferred_defaults(temp_dir.path());
877        // pyproject.toml name should take precedence
878        assert_eq!(config.project.name, "pyproject-name");
879    }
880
881    #[test]
882    fn test_output_config_prefix_deserialization() {
883        let toml_str = r#"
884            [project]
885            name = "test"
886
887            [output]
888            prefix = "api/reference"
889        "#;
890        let config: Config = toml::from_str(toml_str).unwrap();
891        assert_eq!(config.output.prefix, Some("api/reference".to_string()));
892    }
893
894    #[test]
895    fn test_output_config_prefix_default_none() {
896        let toml_str = r#"
897            [project]
898            name = "test"
899
900            [output]
901            format = "markdown"
902        "#;
903        let config: Config = toml::from_str(toml_str).unwrap();
904        assert_eq!(config.output.prefix, None);
905    }
906
907    #[test]
908    fn test_output_config_prefix_empty_string() {
909        let toml_str = r#"
910            [project]
911            name = "test"
912
913            [output]
914            prefix = ""
915        "#;
916        let config: Config = toml::from_str(toml_str).unwrap();
917        // Empty string deserializes as Some(""), but resolve_prefix in CLI normalizes to None
918        assert_eq!(config.output.prefix, Some("".to_string()));
919    }
920}