Skip to main content

alef/core/config/
output.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use serde_json::Value as JsonValue;
4use std::collections::HashMap;
5use std::path::PathBuf;
6
7#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
8pub struct ExcludeConfig {
9    #[serde(default)]
10    pub types: Vec<String>,
11    #[serde(default)]
12    pub functions: Vec<String>,
13    /// Exclude specific methods: "TypeName.method_name"
14    #[serde(default)]
15    pub methods: Vec<String>,
16}
17
18#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
19pub struct IncludeConfig {
20    #[serde(default)]
21    pub types: Vec<String>,
22    #[serde(default)]
23    pub functions: Vec<String>,
24}
25
26#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
27pub struct OutputConfig {
28    pub python: Option<PathBuf>,
29    pub node: Option<PathBuf>,
30    pub ruby: Option<PathBuf>,
31    pub php: Option<PathBuf>,
32    pub elixir: Option<PathBuf>,
33    pub wasm: Option<PathBuf>,
34    pub ffi: Option<PathBuf>,
35    pub go: Option<PathBuf>,
36    pub java: Option<PathBuf>,
37    pub kotlin: Option<PathBuf>,
38    pub kotlin_android: Option<PathBuf>,
39    pub dart: Option<PathBuf>,
40    pub swift: Option<PathBuf>,
41    pub gleam: Option<PathBuf>,
42    pub csharp: Option<PathBuf>,
43    pub r: Option<PathBuf>,
44    pub zig: Option<PathBuf>,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
48pub struct ScaffoldConfig {
49    pub description: Option<String>,
50    pub license: Option<String>,
51    pub repository: Option<String>,
52    pub homepage: Option<String>,
53    #[serde(default)]
54    pub authors: Vec<String>,
55    #[serde(default)]
56    pub keywords: Vec<String>,
57    /// Generated-file header text overrides.
58    #[serde(default)]
59    pub generated_header: Option<GeneratedHeaderConfig>,
60    /// Pre-commit scaffold overrides.
61    #[serde(default)]
62    pub precommit: Option<PrecommitConfig>,
63    /// Opt-in workspace `.cargo/config.toml` management. When present, alef writes
64    /// the full file with hash-based drift detection. Absent = legacy behavior
65    /// (wasm32 block only, create-if-missing, unmanaged).
66    pub cargo: Option<ScaffoldCargo>,
67}
68
69#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
70pub struct GeneratedHeaderConfig {
71    /// URL shown in generated-file headers for issue reporting and docs.
72    #[serde(default)]
73    pub issues_url: Option<String>,
74    /// Regeneration command shown in generated-file headers.
75    #[serde(default)]
76    pub regenerate_command: Option<String>,
77    /// Freshness verification command shown in generated-file headers.
78    #[serde(default)]
79    pub verify_command: Option<String>,
80}
81
82#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
83pub struct PrecommitConfig {
84    /// Whether to include the shared shell/Docker/docs hooks block.
85    #[serde(default)]
86    pub include_shared_hooks: Option<bool>,
87    /// Repository URL for the shared hooks block.
88    #[serde(default)]
89    pub shared_hooks_repo: Option<String>,
90    /// Revision for the shared hooks block.
91    #[serde(default)]
92    pub shared_hooks_rev: Option<String>,
93    /// Whether to include the alef hook block.
94    #[serde(default)]
95    pub include_alef_hooks: Option<bool>,
96    /// Repository URL for the alef hook block.
97    #[serde(default)]
98    pub alef_hooks_repo: Option<String>,
99    /// Revision for the alef hook block.
100    #[serde(default)]
101    pub alef_hooks_rev: Option<String>,
102}
103
104/// Opt-in management of workspace-level `.cargo/config.toml`.
105///
106/// All fields default to canonical values that produce the same `.cargo/config.toml`
107/// across polyglot repos. Override individual targets via `targets`, or inject
108/// repo-specific `[env]` entries via `env`.
109#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
110pub struct ScaffoldCargo {
111    /// Per-target cross-compile / rustflags overrides. Defaults emit the canonical
112    /// 6-target template (macOS dynamic_lookup, Windows MSVC rust-lld x64+i686,
113    /// aarch64-linux-gnu cross-gcc, x86_64-linux-musl, wasm32 bulk-memory).
114    #[serde(default)]
115    pub targets: ScaffoldCargoTargets,
116    /// Limit concurrent rustc jobs to prevent OOM during large builds.
117    /// Defaults to 4 (safe for 16 GB dev machines). Set to 0 to disable.
118    #[serde(default = "default_build_jobs")]
119    pub build_jobs: u32,
120    /// Free-form `[env]` entries copied verbatim into the generated file.
121    /// Values can be a plain string or `{ value, relative }`. Empty by default.
122    #[serde(default)]
123    pub env: HashMap<String, ScaffoldCargoEnvValue>,
124}
125
126impl Default for ScaffoldCargo {
127    fn default() -> Self {
128        Self {
129            targets: ScaffoldCargoTargets::default(),
130            build_jobs: default_build_jobs(),
131            env: HashMap::new(),
132        }
133    }
134}
135
136/// Per-target opt-out flags. All default to `true`.
137#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
138pub struct ScaffoldCargoTargets {
139    #[serde(default = "default_true")]
140    pub macos_dynamic_lookup: bool,
141    #[serde(default = "default_true")]
142    pub x86_64_pc_windows_msvc: bool,
143    #[serde(default = "default_true")]
144    pub i686_pc_windows_msvc: bool,
145    #[serde(default = "default_true")]
146    pub aarch64_unknown_linux_gnu: bool,
147    #[serde(default = "default_true")]
148    pub x86_64_unknown_linux_musl: bool,
149    #[serde(default = "default_true")]
150    pub wasm32_unknown_unknown: bool,
151}
152
153impl Default for ScaffoldCargoTargets {
154    fn default() -> Self {
155        Self {
156            macos_dynamic_lookup: true,
157            x86_64_pc_windows_msvc: true,
158            i686_pc_windows_msvc: true,
159            aarch64_unknown_linux_gnu: true,
160            x86_64_unknown_linux_musl: true,
161            wasm32_unknown_unknown: true,
162        }
163    }
164}
165
166fn default_true() -> bool {
167    true
168}
169
170fn default_build_jobs() -> u32 {
171    4
172}
173
174/// Value for a `[scaffold.cargo.env]` entry. Either a bare string (renders as
175/// `KEY = "value"`) or a structured form with `value` + optional `relative`.
176#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
177#[serde(untagged)]
178pub enum ScaffoldCargoEnvValue {
179    Plain(String),
180    Structured {
181        value: String,
182        #[serde(default)]
183        relative: bool,
184    },
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
188pub struct ReadmeConfig {
189    pub template_dir: Option<PathBuf>,
190    pub snippets_dir: Option<PathBuf>,
191    /// Deprecated: path to an external YAML config file. Prefer inline fields below.
192    pub config: Option<PathBuf>,
193    pub output_pattern: Option<String>,
194    /// Discord invite URL used in README templates.
195    pub discord_url: Option<String>,
196    /// Banner image URL used in README templates.
197    pub banner_url: Option<String>,
198    /// Per-language README configuration, keyed by language code
199    /// (e.g. "python", "typescript", "ruby"). Values are flexible JSON objects
200    /// that map directly to minijinja template context variables.
201    #[serde(default)]
202    pub languages: HashMap<String, JsonValue>,
203    /// Non-language README targets, keyed by target name
204    /// (e.g. "root", "cli"). Targets must declare `output_path` or `output`.
205    #[serde(default)]
206    pub targets: HashMap<String, JsonValue>,
207}
208
209/// A value that can be either a single string or a list of strings.
210///
211/// Deserializes from both `"cmd"` and `["cmd1", "cmd2"]` in TOML/JSON.
212#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
213#[serde(untagged)]
214pub enum StringOrVec {
215    Single(String),
216    Multiple(Vec<String>),
217}
218
219impl StringOrVec {
220    /// Return all commands as a slice-like iterator.
221    pub fn commands(&self) -> Vec<&str> {
222        match self {
223            StringOrVec::Single(s) => vec![s.as_str()],
224            StringOrVec::Multiple(v) => v.iter().map(String::as_str).collect(),
225        }
226    }
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
230pub struct LintConfig {
231    /// Shell command that must exit 0 for lint to run; skip with warning on failure.
232    pub precondition: Option<String>,
233    /// Command(s) to run before the main lint commands; aborts on failure.
234    pub before: Option<StringOrVec>,
235    pub format: Option<StringOrVec>,
236    pub check: Option<StringOrVec>,
237    pub typecheck: Option<StringOrVec>,
238}
239
240#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
241pub struct UpdateConfig {
242    /// Shell command that must exit 0 for update to run; skip with warning on failure.
243    pub precondition: Option<String>,
244    /// Command(s) to run before the main update commands; aborts on failure.
245    pub before: Option<StringOrVec>,
246    /// Command(s) for safe dependency updates (compatible versions only).
247    pub update: Option<StringOrVec>,
248    /// Command(s) for aggressive updates (including incompatible/major bumps).
249    pub upgrade: Option<StringOrVec>,
250}
251
252#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
253pub struct TestAppRunConfig {
254    /// Shell command that must exit 0 for the test-app run to proceed; skip with warning on failure.
255    pub precondition: Option<String>,
256    /// Command(s) to run before the main run commands; aborts on failure.
257    pub before: Option<StringOrVec>,
258    /// Command(s) that install the published package into the registry-mode test
259    /// app and exercise it (e.g. `cd test_apps/ruby && bundle install && bundle exec rspec`).
260    pub run: Option<StringOrVec>,
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, JsonSchema)]
264pub struct TestConfig {
265    /// Shell command that must exit 0 for test to run; skip with warning on failure.
266    pub precondition: Option<String>,
267    /// Command(s) to run before the main test commands; aborts on failure.
268    pub before: Option<StringOrVec>,
269    /// Command to run unit/integration tests for this language.
270    pub command: Option<StringOrVec>,
271    /// Command to run e2e tests for this language.
272    pub e2e: Option<StringOrVec>,
273    /// Command to run tests with coverage for this language.
274    pub coverage: Option<StringOrVec>,
275}
276
277#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
278pub struct SetupConfig {
279    /// Shell command that must exit 0 for setup to run; skip with warning on failure.
280    pub precondition: Option<String>,
281    /// Command(s) to run before the main setup commands; aborts on failure.
282    pub before: Option<StringOrVec>,
283    /// Command(s) to install dependencies for this language.
284    pub install: Option<StringOrVec>,
285    /// Timeout in seconds for the complete setup (precondition + before + install).
286    #[serde(default = "default_setup_timeout")]
287    pub timeout_seconds: u64,
288    /// Optional working directory (relative to repo root) for setup commands.
289    ///
290    /// When set, install commands run from `base_dir.join(workdir)` instead of
291    /// `base_dir`. Required for languages whose manifest does not live at the
292    /// workspace root (Swift's `Package.swift`, Kotlin-Android's `gradlew`,
293    /// Dart's `pubspec.yaml`, Zig's `build.zig`). Defaults to `None` (run from
294    /// repo root).
295    #[serde(default)]
296    pub workdir: Option<PathBuf>,
297}
298
299#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
300pub struct CleanConfig {
301    /// Shell command that must exit 0 for clean to run; skip with warning on failure.
302    pub precondition: Option<String>,
303    /// Command(s) to run before the main clean commands; aborts on failure.
304    pub before: Option<StringOrVec>,
305    /// Command(s) to clean build artifacts for this language.
306    pub clean: Option<StringOrVec>,
307}
308
309#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
310pub struct BuildCommandConfig {
311    /// Shell command that must exit 0 for build to run; skip with warning on failure.
312    pub precondition: Option<String>,
313    /// Command(s) to run before the main build commands; aborts on failure.
314    pub before: Option<StringOrVec>,
315    /// Command(s) to build in debug mode.
316    pub build: Option<StringOrVec>,
317    /// Command(s) to build in release mode.
318    pub build_release: Option<StringOrVec>,
319}
320
321impl BuildCommandConfig {
322    /// Overlay `other` onto this config field-by-field.
323    ///
324    /// Used for build command defaults where built-ins, workspace defaults, and
325    /// crate overrides should compose without forcing callers to restate every
326    /// command field.
327    pub fn merge_overlay(mut self, other: &Self) -> Self {
328        if other.precondition.is_some() {
329            self.precondition = other.precondition.clone();
330        }
331        if other.before.is_some() {
332            self.before = other.before.clone();
333        }
334        if other.build.is_some() {
335            self.build = other.build.clone();
336        }
337        if other.build_release.is_some() {
338            self.build_release = other.build_release.clone();
339        }
340        self
341    }
342}
343
344fn default_setup_timeout() -> u64 {
345    1800
346}
347
348/// Per-language output path templates for multi-crate workspaces.
349///
350/// Each entry is a path string that may contain `{crate}` and `{lang}` placeholders.
351/// Resolved by [`OutputTemplate::resolve`] to produce a concrete path for one
352/// `(crate, language)` pair.
353///
354/// Defaults (when a language entry is absent and no per-crate explicit override is set):
355/// - Single-crate workspaces resolve to `packages/{lang}/`.
356/// - Multi-crate workspaces resolve to `packages/{lang}/{crate}/`.
357///
358/// Per-crate explicit paths in [`OutputConfig`] always win over a workspace template.
359#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
360pub struct OutputTemplate {
361    pub python: Option<String>,
362    pub node: Option<String>,
363    pub ruby: Option<String>,
364    pub php: Option<String>,
365    pub elixir: Option<String>,
366    pub wasm: Option<String>,
367    pub ffi: Option<String>,
368    pub go: Option<String>,
369    pub java: Option<String>,
370    pub kotlin: Option<String>,
371    pub kotlin_android: Option<String>,
372    pub dart: Option<String>,
373    pub swift: Option<String>,
374    pub gleam: Option<String>,
375    pub csharp: Option<String>,
376    pub r: Option<String>,
377    pub zig: Option<String>,
378}
379
380impl OutputTemplate {
381    /// Resolve a `(crate, language)` pair to a concrete output path.
382    ///
383    /// Resolution order (highest priority first):
384    /// 1. Per-language template entry on `self`, if set, with `{crate}` and `{lang}`
385    ///    placeholders substituted.
386    /// 2. Default fallback: `packages/{lang}/{crate}/` if `multi_crate`, else
387    ///    language-specific historical defaults (`packages/python`, `packages/node`,
388    ///    `packages/ruby`, `packages/php`, `packages/elixir`) or `packages/{lang}` for
389    ///    languages without a historical default.
390    ///
391    /// # Panics
392    ///
393    /// Panics if `crate_name` contains a NUL byte, path separator (`/`, `\`),
394    /// or is a bare relative reference (`..`), and if the resolved path would
395    /// escape the project root via `..` components or an absolute root.
396    pub fn resolve(&self, crate_name: &str, lang: &str, multi_crate: bool) -> PathBuf {
397        validate_output_segment(crate_name, "crate_name");
398        validate_output_segment(lang, "lang");
399
400        let path = if let Some(template) = self.entry(lang) {
401            PathBuf::from(template.replace("{crate}", crate_name).replace("{lang}", lang))
402        } else if multi_crate {
403            PathBuf::from(format!("packages/{lang}/{crate_name}"))
404        } else {
405            match lang {
406                "python" => PathBuf::from("packages/python"),
407                "node" => PathBuf::from("packages/node"),
408                "ruby" => PathBuf::from("packages/ruby"),
409                "php" => PathBuf::from("packages/php"),
410                "elixir" => PathBuf::from("packages/elixir"),
411                other => PathBuf::from(format!("packages/{other}")),
412            }
413        };
414
415        validate_output_path(&path);
416        path
417    }
418
419    /// Return the raw template string for a language code, if set.
420    pub fn entry(&self, lang: &str) -> Option<&str> {
421        match lang {
422            "python" => self.python.as_deref(),
423            "node" => self.node.as_deref(),
424            "ruby" => self.ruby.as_deref(),
425            "php" => self.php.as_deref(),
426            "elixir" => self.elixir.as_deref(),
427            "wasm" => self.wasm.as_deref(),
428            "ffi" => self.ffi.as_deref(),
429            "go" => self.go.as_deref(),
430            "java" => self.java.as_deref(),
431            "kotlin" => self.kotlin.as_deref(),
432            "kotlin_android" => self.kotlin_android.as_deref(),
433            "dart" => self.dart.as_deref(),
434            "swift" => self.swift.as_deref(),
435            "gleam" => self.gleam.as_deref(),
436            "csharp" => self.csharp.as_deref(),
437            "r" => self.r.as_deref(),
438            "zig" => self.zig.as_deref(),
439            _ => None,
440        }
441    }
442}
443
444/// Validate that a user-supplied path segment (crate name or language code) does not
445/// contain characters that could enable path traversal.
446///
447/// # Panics
448///
449/// Panics if the segment contains a NUL byte, a forward slash, or a backslash.
450fn validate_output_segment(segment: &str, label: &str) {
451    if segment.contains('\0') {
452        panic!("invalid {label}: NUL byte is not allowed in output path segments (got {segment:?})");
453    }
454    if segment.contains('/') || segment.contains('\\') {
455        panic!("invalid {label}: path separators are not allowed in output path segments (got {segment:?})");
456    }
457}
458
459/// Validate that a resolved output `PathBuf` does not escape the project root.
460///
461/// # Panics
462///
463/// Panics if the path contains a `..` component or is absolute.
464fn validate_output_path(path: &std::path::Path) {
465    use std::path::Component;
466    for component in path.components() {
467        match component {
468            Component::ParentDir => {
469                panic!(
470                    "resolved output path `{}` contains `..` and would escape the project root",
471                    path.display()
472                );
473            }
474            Component::RootDir | Component::Prefix(_) => {
475                panic!(
476                    "resolved output path `{}` is absolute and would escape the project root",
477                    path.display()
478                );
479            }
480            _ => {}
481        }
482    }
483}
484
485/// A single text replacement rule for version sync.
486#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
487pub struct TextReplacement {
488    /// Glob pattern for files to process.
489    pub path: String,
490    /// Regex pattern to search for (may contain `{version}` placeholder).
491    pub search: String,
492    /// Replacement string (may contain `{version}` placeholder).
493    pub replace: String,
494}
495
496#[cfg(test)]
497mod tests;
498
499/// Configuration for the `sync-versions` command.
500#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
501pub struct SyncConfig {
502    /// Extra file paths to update version in (glob patterns).
503    #[serde(default)]
504    pub extra_paths: Vec<String>,
505    /// Arbitrary text replacements applied during version sync.
506    #[serde(default)]
507    pub text_replacements: Vec<TextReplacement>,
508}
509
510/// A single author entry in a `CITATION.cff` file. Per the Citation File Format
511/// schema, each entry is either a person (uses `family_names` + `given_names`)
512/// or a legal entity (uses `name`). Validation lives in the renderer rather
513/// than in serde because the choice is mutually exclusive.
514#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
515#[serde(deny_unknown_fields)]
516pub struct CitationAuthor {
517    /// Person author: family name(s).
518    #[serde(default, alias = "family-names")]
519    pub family_names: Option<String>,
520    /// Person author: given name(s).
521    #[serde(default, alias = "given-names")]
522    pub given_names: Option<String>,
523    /// Entity author: organisation or legal-entity name.
524    #[serde(default)]
525    pub name: Option<String>,
526    /// Optional contact email (applies to either person or entity).
527    #[serde(default)]
528    pub email: Option<String>,
529    /// Optional ORCID iD URL (`https://orcid.org/0000-0000-0000-0000`).
530    #[serde(default)]
531    pub orcid: Option<String>,
532}
533
534/// Configuration for the alef-generated `CITATION.cff` file at the repo root.
535///
536/// When this section is present in `alef.toml`, `alef sync-versions` writes a
537/// fully-rendered Citation File Format YAML using these fields plus the current
538/// workspace version (read from `Cargo.toml`). When absent, alef falls back to
539/// updating the `version:` line of a hand-authored CITATION.cff in place.
540///
541/// All field names follow Rust convention; the renderer emits the canonical
542/// CFF kebab-case keys (`cff-version`, `repository-code`, `date-released`,
543/// `family-names`, `given-names`).
544#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
545#[serde(deny_unknown_fields)]
546pub struct CitationConfig {
547    /// Software title (`title:`). Required.
548    pub title: String,
549    /// One-paragraph summary (`abstract:`). Required.
550    #[serde(rename = "abstract")]
551    pub abstract_: String,
552    /// Authors list — at least one entry required. Persons and legal entities
553    /// can be mixed (e.g. `Na'aman Hirschfeld` + `SampleCrate, Inc.`).
554    pub authors: Vec<CitationAuthor>,
555    /// Canonical citation message shown to consumers (`message:`).
556    #[serde(default = "default_citation_message")]
557    pub message: String,
558    /// Source-code repository URL (`repository-code:`). Required.
559    #[serde(rename = "repository-code", alias = "repository_code")]
560    pub repository_code: String,
561    /// Project landing-page URL (`url:`). Optional.
562    #[serde(default)]
563    pub url: Option<String>,
564    /// SPDX license identifier (`license:`). When omitted, the renderer falls
565    /// back to `Cargo.toml [workspace.package].license`.
566    #[serde(default)]
567    pub license: Option<String>,
568    /// Release date in `YYYY-MM-DD` form (`date-released:`). Optional override.
569    ///
570    /// When omitted (the recommended default), `alef sync-versions` stamps the
571    /// current system date on every regen so consumers do not need to hand-edit
572    /// alef.toml per release. Set this explicitly only when you need to replay
573    /// a historical release date (e.g. backports, CFF reproducibility audits).
574    #[serde(default, rename = "date-released", alias = "date_released")]
575    pub date_released: Option<String>,
576    /// Persistent DOI for the cited release (`doi:`). Optional.
577    #[serde(default)]
578    pub doi: Option<String>,
579}
580
581fn default_citation_message() -> String {
582    "If you use this software, please cite it using the metadata below.".to_string()
583}