Skip to main content

polyxml_cli/
config.rs

1use std::collections::{BTreeMap, HashMap, HashSet};
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    #[error("Module '{module}' depends on unknown module '{dependency}'")]
27    UnknownModuleDependency { module: String, dependency: String },
28
29    #[error("Cycle in workspace module dependencies involving '{0}'")]
30    ModuleCycle(String),
31
32    #[error("Invalid module name '{0}': use ASCII letters, digits, and underscores, starting with a letter or underscore")]
33    InvalidModuleName(String),
34}
35
36/// The top-level `polyxml.toml` workspace manifest.
37#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
38pub struct WorkspaceManifest {
39    pub workspace: Option<WorkspaceSection>,
40    #[serde(default)]
41    pub generate: Vec<TargetConfig>,
42    pub codegen: Option<HashMap<String, CodegenTargetConfig>>,
43    #[serde(default)]
44    pub modules: BTreeMap<String, ModuleConfig>,
45}
46
47/// A schema package compiled once and imported by dependent packages.
48#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(deny_unknown_fields)]
50pub struct ModuleConfig {
51    #[serde(default)]
52    pub schemas: Vec<String>,
53    #[serde(default)]
54    pub root_elements: Vec<String>,
55    #[serde(default)]
56    pub depends_on: Vec<String>,
57}
58
59#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
60pub struct WorkspaceSection {
61    pub name: Option<String>,
62    #[serde(default)]
63    pub schemas: Vec<String>,
64    #[serde(default)]
65    pub root_elements: Vec<String>,
66    pub include_dirs: Option<Vec<String>>,
67    pub output_base_dir: Option<String>,
68    pub custom_header: Option<String>,
69    pub go_module: Option<String>,
70}
71
72/// Target configuration from either `[[generate]]` or `[codegen.<target>]`.
73/// Unknown keys are rejected so misspelled or unsupported options fail
74/// instead of being silently ignored.
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(deny_unknown_fields)]
77pub struct TargetConfig {
78    pub target: String,
79    pub output: String,
80    pub enabled: Option<bool>,
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    pub split_units: Option<bool>,
100    pub split_crates: Option<bool>,
101    pub chunk_size: Option<usize>,
102}
103
104#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(deny_unknown_fields)]
106pub struct CodegenTargetConfig {
107    pub enabled: Option<bool>,
108    pub output: Option<String>,
109    pub backend: Option<String>,
110    #[serde(default)]
111    pub features: Vec<String>,
112    pub package: Option<String>,
113    pub namespace: Option<String>,
114    pub strict_facets: Option<bool>,
115    pub slots: Option<bool>,
116    pub kw_only: Option<bool>,
117    pub zero_copy: Option<bool>,
118    pub codecs: Option<bool>,
119    pub standard: Option<String>,
120    pub derive_traits: Option<Vec<String>>,
121    pub box_cycles: Option<bool>,
122    pub modules: Option<bool>,
123    pub mode: Option<String>,
124    pub serializer: Option<String>,
125    pub style: Option<String>,
126    pub custom_header: Option<String>,
127    pub split_units: Option<bool>,
128    pub split_crates: Option<bool>,
129    pub chunk_size: Option<usize>,
130}
131
132impl std::str::FromStr for WorkspaceManifest {
133    type Err = ConfigError;
134
135    fn from_str(toml_str: &str) -> Result<Self, Self::Err> {
136        let manifest: WorkspaceManifest = toml::from_str(toml_str)?;
137        Ok(manifest)
138    }
139}
140
141impl WorkspaceManifest {
142    pub fn from_file(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
143        let content = fs::read_to_string(path)?;
144        content.parse()
145    }
146
147    /// Retrieve all configured target configurations, combining `[[generate]]`
148    /// and `[codegen.<target>]` definitions.
149    pub fn resolved_targets(&self) -> Vec<TargetConfig> {
150        let mut targets = Vec::new();
151
152        let ws_header = self
153            .workspace
154            .as_ref()
155            .and_then(|w| w.custom_header.clone());
156
157        // 1. Array of tables [[generate]]
158        for gen in &self.generate {
159            if gen.enabled.unwrap_or(true) {
160                let mut target = gen.clone();
161                if target.custom_header.is_none() {
162                    target.custom_header = ws_header.clone();
163                }
164                targets.push(target);
165            }
166        }
167
168        // 2. Table-based [codegen.<lang>]
169        if let Some(ref codegen_map) = self.codegen {
170            for (lang, cfg) in codegen_map {
171                if cfg.enabled.unwrap_or(true) {
172                    let output = cfg
173                        .output
174                        .clone()
175                        .unwrap_or_else(|| format!("generated/{}", lang));
176
177                    targets.push(TargetConfig {
178                        target: lang.clone(),
179                        output,
180                        enabled: cfg.enabled,
181                        backend: cfg.backend.clone(),
182                        features: cfg.features.clone(),
183                        package: cfg.package.clone(),
184                        namespace: cfg.namespace.clone(),
185                        strict_facets: cfg.strict_facets,
186                        slots: cfg.slots,
187                        kw_only: cfg.kw_only,
188                        zero_copy: cfg.zero_copy,
189                        codecs: cfg.codecs,
190                        standard: cfg.standard.clone(),
191                        derive_traits: cfg.derive_traits.clone(),
192                        box_cycles: cfg.box_cycles,
193                        modules: cfg.modules,
194                        mode: cfg.mode.clone(),
195                        serializer: cfg.serializer.clone(),
196                        style: cfg.style.clone(),
197                        custom_header: cfg.custom_header.clone().or_else(|| ws_header.clone()),
198                        split_units: cfg.split_units,
199                        split_crates: cfg.split_crates,
200                        chunk_size: cfg.chunk_size,
201                    });
202                }
203            }
204        }
205
206        targets
207    }
208
209    /// Expand all schema glob patterns in `workspace.schemas` relative to base directory.
210    pub fn expand_schemas(&self, base_dir: &Path) -> Result<Vec<PathBuf>, ConfigError> {
211        let Some(ref ws) = self.workspace else {
212            return Ok(Vec::new());
213        };
214
215        expand_patterns(&ws.schemas, base_dir)
216    }
217
218    pub fn expand_module_schemas(
219        &self,
220        name: &str,
221        base_dir: &Path,
222    ) -> Result<Vec<PathBuf>, ConfigError> {
223        let Some(module) = self.modules.get(name) else {
224            return Ok(Vec::new());
225        };
226        expand_patterns(&module.schemas, base_dir)
227    }
228
229    pub fn module_order(&self) -> Result<Vec<String>, ConfigError> {
230        for name in self.modules.keys() {
231            let mut chars = name.chars();
232            if !chars
233                .next()
234                .is_some_and(|ch| ch.is_ascii_alphabetic() || ch == '_')
235                || !chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
236            {
237                return Err(ConfigError::InvalidModuleName(name.clone()));
238            }
239        }
240        let mut order = Vec::new();
241        let mut visiting = HashSet::new();
242        let mut visited = HashSet::new();
243        fn visit(
244            name: &str,
245            manifest: &WorkspaceManifest,
246            visiting: &mut HashSet<String>,
247            visited: &mut HashSet<String>,
248            order: &mut Vec<String>,
249        ) -> Result<(), ConfigError> {
250            if visited.contains(name) {
251                return Ok(());
252            }
253            if !visiting.insert(name.to_string()) {
254                return Err(ConfigError::ModuleCycle(name.to_string()));
255            }
256            let module = &manifest.modules[name];
257            for dependency in &module.depends_on {
258                if !manifest.modules.contains_key(dependency) {
259                    return Err(ConfigError::UnknownModuleDependency {
260                        module: name.to_string(),
261                        dependency: dependency.clone(),
262                    });
263                }
264                visit(dependency, manifest, visiting, visited, order)?;
265            }
266            visiting.remove(name);
267            visited.insert(name.to_string());
268            order.push(name.to_string());
269            Ok(())
270        }
271        for name in self.modules.keys() {
272            visit(name, self, &mut visiting, &mut visited, &mut order)?;
273        }
274        Ok(order)
275    }
276}
277
278fn expand_patterns(patterns: &[String], base_dir: &Path) -> Result<Vec<PathBuf>, ConfigError> {
279    let mut paths = Vec::new();
280    for pattern in patterns {
281        let full_pattern = if Path::new(pattern).is_absolute() {
282            pattern.clone()
283        } else {
284            base_dir.join(pattern).to_string_lossy().to_string()
285        };
286
287        let entries = glob(&full_pattern).map_err(|e| ConfigError::GlobPattern {
288            pattern: full_pattern.clone(),
289            error: e,
290        })?;
291
292        for entry in entries {
293            let path = entry?;
294            if path.is_file() {
295                paths.push(path);
296            }
297        }
298    }
299
300    paths.sort();
301    paths.dedup();
302    Ok(paths)
303}