1use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6use thiserror::Error;
7
8pub const DEFAULT_OUTPUT_FORMAT: &str = "markdown";
14
15pub const DEFAULT_OUTPUT_PATH: &str = "docs/api";
17
18pub const DEFAULT_DOCS_RS_URL: &str = "https://docs.rs";
20
21pub const DEFAULT_TEMPLATE: &str = "mkdocs-material";
23
24pub const VERSION_SOURCE_CARGO: &str = "cargo";
26
27pub const VERSION_SOURCE_PYPROJECT: &str = "pyproject";
29
30pub const CARGO_MANIFEST: &str = "Cargo.toml";
32
33pub const PYPROJECT_MANIFEST: &str = "pyproject.toml";
35
36pub const PLISSKEN_CONFIG: &str = "plissken.toml";
38
39pub const TEMPLATE_MKDOCS_MATERIAL: &str = "mkdocs-material";
41
42pub const TEMPLATE_MDBOOK: &str = "mdbook";
44
45pub const DEFAULT_CRATES: &str = ".";
47
48#[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#[derive(Debug, Clone, Serialize)]
69pub struct ConfigWarning {
70 pub field: String,
72 pub message: String,
74 #[serde(skip_serializing_if = "Option::is_none")]
76 pub hint: Option<String>,
77}
78
79impl ConfigWarning {
80 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 pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
91 self.hint = Some(hint.into());
92 self
93 }
94}
95
96#[derive(Debug)]
98pub struct ValidationResult {
99 pub valid: bool,
101 pub warnings: Vec<ConfigWarning>,
103}
104
105#[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#[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#[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#[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 #[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct PythonConfig {
176 pub package: String,
178 #[serde(default)]
180 pub source: Option<PathBuf>,
181 #[serde(default)]
183 pub auto_discover: bool,
184 #[serde(default)]
186 pub modules: HashMap<String, ModuleSourceType>,
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
191#[serde(rename_all = "lowercase")]
192pub enum ModuleSourceType {
193 Pyo3,
194 Python,
195}
196
197#[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#[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#[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 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 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 if self.project.name.is_empty()
288 && let Some(name) = inferred.project_name
289 {
290 self.project.name = name;
291 }
292
293 if let Some(ref mut rust) = self.rust {
295 if rust.crates.is_empty()
297 && let Some(crates) = inferred.rust_crates
298 {
299 rust.crates = crates;
300 }
301 if rust.entry_point.is_none() {
303 rust.entry_point = inferred.rust_entry_point;
304 }
305 }
306
307 if let Some(ref mut python) = self.python {
309 if python.package.is_empty()
311 && let Some(pkg) = inferred.python_package
312 {
313 python.package = pkg;
314 }
315 if python.source.is_none() {
317 python.source = inferred.python_source;
318 }
319 }
320
321 self
322 }
323
324 pub fn validate(&self, project_root: &Path) -> Result<ValidationResult, ConfigError> {
337 let mut warnings = Vec::new();
338
339 if self.rust.is_none() && self.python.is_none() {
341 return Err(ConfigError::NoLanguageConfigured);
342 }
343
344 self.validate_version_source(project_root)?;
346
347 if let Some(ref rust_config) = self.rust {
349 self.validate_rust_config(rust_config, project_root, &mut warnings)?;
350 }
351
352 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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(); 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 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![], 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(), 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 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 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 assert_eq!(config.output.prefix, Some("".to_string()));
919 }
920}