Skip to main content

alef_core/config/
workspace.rs

1//! Workspace-level shared defaults for multi-crate alef workspaces.
2//!
3//! A `[workspace]` section in `alef.toml` collects defaults that apply to every
4//! `[[crates]]` entry unless that crate overrides the field. The fields here
5//! are the cross-crate concerns (tooling, DTO style, default pipelines, output
6//! templates) — anything that is fundamentally per-crate (sources, language
7//! module names, publish settings) lives on [`crate::config::raw_crate::RawCrateConfig`]
8//! instead.
9//!
10//! See `crates/alef-core/src/config/resolved.rs` for how workspace defaults
11//! merge into a per-crate [`crate::config::resolved::ResolvedCrateConfig`].
12
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15
16use super::dto::DtoConfig;
17use super::extras::Language;
18use super::output::{
19    BuildCommandConfig, CitationConfig, CleanConfig, GeneratedHeaderConfig, LintConfig, OutputTemplate,
20    PrecommitConfig, ScaffoldConfig, SetupConfig, SyncConfig, TestConfig, UpdateConfig,
21};
22use super::tools::ToolsConfig;
23use super::{FormatConfig, GenerateConfig};
24
25/// One parameter in a [`ClientConstructorConfig`].
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct ConstructorParam {
28    /// Parameter name as it appears in the generated function signature.
29    pub name: String,
30    /// Rust type of the parameter (e.g. `"*const c_char"` for FFI, `"&str"` for Rust-embedded).
31    #[serde(rename = "type")]
32    pub ty: String,
33}
34
35/// Custom constructor configuration for an opaque handle type.
36///
37/// When present under `[workspace.client_constructors.<TypeName>]`, every
38/// backend that wraps the type in an opaque handle emits a constructor whose
39/// body is the `body` template string with `{type_name}` and `{source_path}`
40/// substituted.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct ClientConstructorConfig {
43    /// Ordered list of constructor parameters.
44    #[serde(default)]
45    pub params: Vec<ConstructorParam>,
46    /// Body template.  Use `{type_name}` for the bare type name and
47    /// `{source_path}` for the fully-qualified core path.
48    pub body: String,
49    /// Error type returned by the constructor (`Result<Self, ErrType>`).
50    /// Defaults to `String` when absent.
51    #[serde(default)]
52    pub error_type: Option<String>,
53}
54
55/// Workspace-level configuration shared across all `[[crates]]` entries.
56///
57/// Every field is optional; an empty `[workspace]` section is valid and means
58/// every crate uses Alef's built-in defaults (or its own per-crate values).
59///
60/// Resolution rule (highest priority first):
61/// 1. Per-crate value on `[[crates]]`.
62/// 2. Workspace default on `[workspace]`.
63/// 3. Built-in default (compiled into Alef).
64#[derive(Debug, Clone, Default, Serialize, Deserialize)]
65#[serde(deny_unknown_fields)]
66pub struct WorkspaceConfig {
67    /// Pinned alef CLI version (e.g. `"0.13.0"`). Used by the `install-alef`
68    /// helper to install the exact version this workspace expects.
69    ///
70    /// In the legacy single-crate schema this lived at `version` at the top
71    /// level. The new schema renames it to `[workspace] alef_version` so it
72    /// can never collide with any per-crate version field.
73    #[serde(default)]
74    pub alef_version: Option<String>,
75
76    /// Default list of target languages for crates that do not specify their
77    /// own. A per-crate `languages` array overrides this entirely.
78    #[serde(default)]
79    pub languages: Vec<Language>,
80
81    /// Global package-manager and dev-tool preferences. Inherited by every
82    /// crate; cannot be overridden per-crate today.
83    #[serde(default)]
84    pub tools: ToolsConfig,
85
86    /// Default DTO/type generation styles per language. A per-crate `[crates.dto]`
87    /// table replaces this wholesale (no field-level merge).
88    #[serde(default)]
89    pub dto: DtoConfig,
90
91    /// Default post-generation formatting flags. A per-crate value replaces
92    /// this wholesale.
93    #[serde(default)]
94    pub format: FormatConfig,
95
96    /// Default per-language formatting overrides (e.g., disable `mix format`
97    /// for elixir). Merged with per-crate `format_overrides` by language key:
98    /// per-crate keys win wholesale; missing keys fall through to this map.
99    /// Note: there is no field-level merge inside a single `FormatConfig`.
100    #[serde(default)]
101    pub format_overrides: HashMap<String, FormatConfig>,
102
103    /// Default generation-pass flags (which passes alef runs).
104    #[serde(default)]
105    pub generate: GenerateConfig,
106
107    /// Default per-language generation flag overrides. Merged with per-crate
108    /// `generate_overrides` by language key: per-crate keys win wholesale;
109    /// missing keys fall through to this map.
110    #[serde(default)]
111    pub generate_overrides: HashMap<String, GenerateConfig>,
112
113    /// Per-language output path templates with `{crate}` and `{lang}` placeholders.
114    /// A per-crate explicit `[crates.output]` path always wins over the template.
115    #[serde(default)]
116    pub output_template: OutputTemplate,
117
118    /// Default package metadata for generated manifests and README context.
119    /// Per-crate `[scaffold]` values override this field-by-field.
120    #[serde(default)]
121    pub scaffold: Option<ScaffoldConfig>,
122
123    /// Default generated-file header metadata.
124    /// Per-crate `[scaffold.generated_header]` values override this field-by-field.
125    #[serde(default)]
126    pub generated_header: Option<GeneratedHeaderConfig>,
127
128    /// Default pre-commit scaffold metadata.
129    /// Per-crate `[scaffold.precommit]` values override this field-by-field.
130    #[serde(default)]
131    pub precommit: Option<PrecommitConfig>,
132
133    /// Default lint pipeline keyed by language code (`"python"`, `"node"`, …).
134    /// Merged field-wise with per-crate `[crates.lint.<lang>]`.
135    #[serde(default)]
136    pub lint: HashMap<String, LintConfig>,
137
138    /// Default test pipeline keyed by language code.
139    #[serde(default)]
140    pub test: HashMap<String, TestConfig>,
141
142    /// Default setup pipeline keyed by language code.
143    #[serde(default)]
144    pub setup: HashMap<String, SetupConfig>,
145
146    /// Default update pipeline keyed by language code.
147    #[serde(default)]
148    pub update: HashMap<String, UpdateConfig>,
149
150    /// Default clean pipeline keyed by language code.
151    #[serde(default)]
152    pub clean: HashMap<String, CleanConfig>,
153
154    /// Default build pipeline keyed by language code.
155    #[serde(default)]
156    pub build_commands: HashMap<String, BuildCommandConfig>,
157
158    /// Workspace-wide opaque types — types from external crates that alef can't
159    /// extract. Map of type name → fully-qualified Rust path. These get opaque
160    /// wrapper structs across all language backends, in every crate that
161    /// references them.
162    #[serde(default)]
163    pub opaque_types: HashMap<String, String>,
164
165    /// Per-type custom constructors emitted by every backend that supports
166    /// opaque handles.  Key: type name (e.g. `"DefaultClient"`).
167    /// Value: [`ClientConstructorConfig`] describing params and a body template.
168    #[serde(default)]
169    pub client_constructors: HashMap<String, ClientConstructorConfig>,
170
171    /// Workspace-wide version sync rules. A per-crate publish step still runs
172    /// independently per crate; sync rules in this section apply globally.
173    #[serde(default)]
174    pub sync: Option<SyncConfig>,
175
176    /// Optional CITATION.cff metadata. When present, `alef sync-versions` writes
177    /// a fully rendered `CITATION.cff` at the repo root using these fields plus
178    /// the canonical workspace version. When absent, a hand-authored
179    /// CITATION.cff (if any) only has its `version:` line updated.
180    #[serde(default)]
181    pub citation: Option<CitationConfig>,
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    #[test]
189    fn workspace_config_deserializes_empty() {
190        let cfg: WorkspaceConfig = toml::from_str("").unwrap();
191        assert!(cfg.alef_version.is_none());
192        assert!(cfg.languages.is_empty());
193        assert!(cfg.lint.is_empty());
194        assert!(cfg.opaque_types.is_empty());
195        assert!(cfg.sync.is_none());
196    }
197
198    #[test]
199    fn workspace_config_deserializes_full() {
200        let toml_str = r#"
201alef_version = "0.13.0"
202languages = ["python", "node"]
203
204[output_template]
205python = "packages/python/{crate}/"
206node   = "packages/node/{crate}/"
207
208[lint.python]
209precondition = "command -v ruff >/dev/null 2>&1"
210check        = "ruff check ."
211
212[test.python]
213command = "uv run pytest"
214
215[opaque_types]
216Tree = "tree_sitter::Tree"
217"#;
218        let cfg: WorkspaceConfig = toml::from_str(toml_str).unwrap();
219        assert_eq!(cfg.alef_version.as_deref(), Some("0.13.0"));
220        assert_eq!(cfg.languages.len(), 2);
221        assert_eq!(cfg.output_template.python.as_deref(), Some("packages/python/{crate}/"));
222        assert!(cfg.lint.contains_key("python"));
223        assert!(cfg.test.contains_key("python"));
224        assert_eq!(
225            cfg.opaque_types.get("Tree").map(String::as_str),
226            Some("tree_sitter::Tree")
227        );
228    }
229
230    #[test]
231    fn workspace_config_deserializes_client_constructors() {
232        let toml_str = r#"
233[client_constructors.DefaultClient]
234body = "{source_path}::new().map_err(|e| e.to_string())"
235
236[[client_constructors.DefaultClient.params]]
237name = "api_key"
238type = "*const std::ffi::c_char"
239"#;
240        let cfg: WorkspaceConfig = toml::from_str(toml_str).unwrap();
241        let ctor = cfg.client_constructors.get("DefaultClient").unwrap();
242        assert_eq!(ctor.params.len(), 1);
243        assert_eq!(ctor.params[0].name, "api_key");
244        assert_eq!(ctor.params[0].ty, "*const std::ffi::c_char");
245        assert!(ctor.body.contains("{source_path}"));
246    }
247}