Skip to main content

polyxml_cli/
config.rs

1use std::collections::HashMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use glob::glob;
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9#[derive(Debug, Error)]
10pub enum ConfigError {
11    #[error("I/O error reading configuration: {0}")]
12    Io(#[from] std::io::Error),
13
14    #[error("TOML syntax error: {0}")]
15    Toml(#[from] toml::de::Error),
16
17    #[error("Invalid glob pattern '{pattern}': {error}")]
18    GlobPattern {
19        pattern: String,
20        error: glob::PatternError,
21    },
22
23    #[error("Failed to read glob path: {0}")]
24    Glob(#[from] glob::GlobError),
25}
26
27/// The top-level `polyxml.toml` workspace manifest.
28#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
29pub struct WorkspaceManifest {
30    pub workspace: Option<WorkspaceSection>,
31    #[serde(default)]
32    pub generate: Vec<TargetConfig>,
33    pub codegen: Option<HashMap<String, CodegenTargetConfig>>,
34}
35
36#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
37pub struct WorkspaceSection {
38    pub name: Option<String>,
39    #[serde(default)]
40    pub schemas: Vec<String>,
41    pub include_dirs: Option<Vec<String>>,
42    pub output_base_dir: Option<String>,
43    pub custom_header: Option<String>,
44}
45
46/// Target configuration from either `[[generate]]` or `[codegen.<target>]`.
47/// Unknown keys are rejected so removed legacy fields (`zod`, `source_gen`,
48/// `record_kind`, `builder`, `codec`, `rkyv`) fail loudly instead of being
49/// silently ignored.
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(deny_unknown_fields)]
52pub struct TargetConfig {
53    pub target: String,
54    pub output: String,
55    pub enabled: Option<bool>,
56    pub backend: Option<String>,
57    #[serde(default)]
58    pub features: Vec<String>,
59    pub package: Option<String>,
60    pub namespace: Option<String>,
61    pub strict_facets: Option<bool>,
62    pub slots: Option<bool>,
63    pub kw_only: Option<bool>,
64    pub zero_copy: Option<bool>,
65    pub codecs: Option<bool>,
66    pub standard: Option<String>,
67    pub derive_traits: Option<Vec<String>>,
68    pub box_cycles: Option<bool>,
69    pub modules: Option<bool>,
70    pub mode: Option<String>,
71    pub serializer: Option<String>,
72    pub style: Option<String>,
73    pub custom_header: Option<String>,
74}
75
76#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(deny_unknown_fields)]
78pub struct CodegenTargetConfig {
79    pub enabled: Option<bool>,
80    pub output: Option<String>,
81    pub backend: Option<String>,
82    #[serde(default)]
83    pub features: Vec<String>,
84    pub package: Option<String>,
85    pub namespace: Option<String>,
86    pub strict_facets: Option<bool>,
87    pub slots: Option<bool>,
88    pub kw_only: Option<bool>,
89    pub zero_copy: Option<bool>,
90    pub codecs: Option<bool>,
91    pub standard: Option<String>,
92    pub derive_traits: Option<Vec<String>>,
93    pub box_cycles: Option<bool>,
94    pub modules: Option<bool>,
95    pub mode: Option<String>,
96    pub serializer: Option<String>,
97    pub style: Option<String>,
98    pub custom_header: Option<String>,
99}
100
101impl std::str::FromStr for WorkspaceManifest {
102    type Err = ConfigError;
103
104    fn from_str(toml_str: &str) -> Result<Self, Self::Err> {
105        let manifest: WorkspaceManifest = toml::from_str(toml_str)?;
106        Ok(manifest)
107    }
108}
109
110impl WorkspaceManifest {
111    pub fn from_file(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
112        let content = fs::read_to_string(path)?;
113        content.parse()
114    }
115
116    /// Retrieve all configured target configurations, combining `[[generate]]`
117    /// and `[codegen.<target>]` definitions.
118    pub fn resolved_targets(&self) -> Vec<TargetConfig> {
119        let mut targets = Vec::new();
120
121        let ws_header = self
122            .workspace
123            .as_ref()
124            .and_then(|w| w.custom_header.clone());
125
126        // 1. Array of tables [[generate]]
127        for gen in &self.generate {
128            if gen.enabled.unwrap_or(true) {
129                let mut target = gen.clone();
130                if target.custom_header.is_none() {
131                    target.custom_header = ws_header.clone();
132                }
133                targets.push(target);
134            }
135        }
136
137        // 2. Table-based [codegen.<lang>]
138        if let Some(ref codegen_map) = self.codegen {
139            for (lang, cfg) in codegen_map {
140                if cfg.enabled.unwrap_or(true) {
141                    let output = cfg
142                        .output
143                        .clone()
144                        .unwrap_or_else(|| format!("generated/{}", lang));
145
146                    targets.push(TargetConfig {
147                        target: lang.clone(),
148                        output,
149                        enabled: cfg.enabled,
150                        backend: cfg.backend.clone(),
151                        features: cfg.features.clone(),
152                        package: cfg.package.clone(),
153                        namespace: cfg.namespace.clone(),
154                        strict_facets: cfg.strict_facets,
155                        slots: cfg.slots,
156                        kw_only: cfg.kw_only,
157                        zero_copy: cfg.zero_copy,
158                        codecs: cfg.codecs,
159                        standard: cfg.standard.clone(),
160                        derive_traits: cfg.derive_traits.clone(),
161                        box_cycles: cfg.box_cycles,
162                        modules: cfg.modules,
163                        mode: cfg.mode.clone(),
164                        serializer: cfg.serializer.clone(),
165                        style: cfg.style.clone(),
166                        custom_header: cfg.custom_header.clone().or_else(|| ws_header.clone()),
167                    });
168                }
169            }
170        }
171
172        targets
173    }
174
175    /// Expand all schema glob patterns in `workspace.schemas` relative to base directory.
176    pub fn expand_schemas(&self, base_dir: &Path) -> Result<Vec<PathBuf>, ConfigError> {
177        let mut paths = Vec::new();
178
179        let Some(ref ws) = self.workspace else {
180            return Ok(paths);
181        };
182
183        for pattern in &ws.schemas {
184            let full_pattern = if Path::new(pattern).is_absolute() {
185                pattern.clone()
186            } else {
187                base_dir.join(pattern).to_string_lossy().to_string()
188            };
189
190            let entries = glob(&full_pattern).map_err(|e| ConfigError::GlobPattern {
191                pattern: full_pattern.clone(),
192                error: e,
193            })?;
194
195            for entry in entries {
196                let path = entry?;
197                if path.is_file() {
198                    paths.push(path);
199                }
200            }
201        }
202
203        paths.sort();
204        paths.dedup();
205        Ok(paths)
206    }
207}