Skip to main content

alef_e2e/
format.rs

1//! Post-generation formatter support for e2e test projects.
2//!
3//! Reads formatter commands from `E2eConfig.format` and runs them for each
4//! language that had files generated. The `{dir}` placeholder in the command
5//! is replaced with the actual output directory.
6
7use crate::config::E2eConfig;
8use alef_core::backend::GeneratedFile;
9use std::collections::HashSet;
10use std::path::Path;
11use tracing::warn;
12
13/// Default per-language formatter commands for e2e directories.
14///
15/// These are run automatically when the language's directory was generated and
16/// no custom override is present in `e2e_config.format`. The `{dir}` placeholder
17/// is replaced with the actual output directory path before execution.
18///
19/// * `rust` — `(cd {dir} && cargo fmt --all)` formats the standalone e2e
20///   crate's `.rs` files. `cargo fmt --manifest-path` is *not* a global cargo
21///   flag (it's an unstable cargo-fmt-only flag in nightly), so running cargo
22///   fmt from the e2e crate's own directory is the portable way to format a
23///   non-workspace-member crate. `Cargo.toml` normalisation is handled by the
24///   `normalize_rust_toml` post-pass (oxfmt), not by cargo-sort: cargo-sort
25///   relocates leading top-level comments to the bottom of the file, which
26///   pushes the alef header (and `# alef:hash:` line) past the 10-line
27///   detection window and breaks `alef verify` silently.
28/// * `python` — `ruff check --fix {dir} && ruff format {dir}` runs lint
29///   autofixes (unused imports, import sorting, TypeAlias annotations) then
30///   whitespace normalisation so both prek's `ruff check` and `ruff format`
31///   hooks are no-ops after generation.
32/// * `node` — `npx oxfmt {dir}` normalises TypeScript test files so prek's
33///   oxfmt hook is a no-op. Without this, hashes are computed over raw codegen
34///   output and reformatted by prek, causing `alef verify` to report stale files.
35/// * `wasm` — same as `node`; the wasm e2e suite uses the same TypeScript
36///   toolchain and oxfmt produces identical normalisation requirements.
37fn default_formatter(lang: &str) -> Option<&'static str> {
38    match lang {
39        "rust" => Some("(cd {dir} && cargo fmt --all)"),
40        "python" => Some("ruff check --fix {dir} && ruff format {dir}"),
41        "node" | "wasm" => Some("pnpm dlx oxfmt {dir}"),
42        _ => None,
43    }
44}
45
46/// Run a best-effort TOML normalization pass on the rust e2e crate's
47/// `Cargo.toml` after the language formatter has finished.
48///
49/// Runs `pnpm dlx oxfmt Cargo.toml` so that downstream `prek` setups that
50/// include the shared oxfmt hook produce no further changes after `alef e2e generate`. Without
51/// this pass, prek would rewrite the manifest (array wrapping, indentation)
52/// after `finalize_hashes` has captured the pre-prek content, causing
53/// `alef verify` to report the file as stale.
54///
55/// `cargo sort` is intentionally **not** invoked here — it relocates the
56/// alef header comments to the bottom of the file, pushing both the
57/// "auto-generated by alef" marker and the embedded `# alef:hash:` line past
58/// the 10-line detection window used by `alef_core::hash::{extract_hash,
59/// inject_hash_line}`. The result is silently broken verification (no hash
60/// found, file treated as fresh) — strictly worse than the prek-rewrite the
61/// invocation was meant to prevent. Consumers whose CI runs `cargo-sort`
62/// must either exclude `e2e/**/Cargo.toml` from the hook or place the alef
63/// header inside a `[package.metadata.alef]` section that cargo-sort
64/// preserves.
65///
66/// oxfmt is invoked via `pnpm dlx` and is best-effort: a missing binary or
67/// non-zero exit is ignored. This is intentional — alef cannot assume a
68/// particular host toolchain, and the calling project's own CI is
69/// responsible for enforcing that oxfmt is present when they ship the
70/// corresponding prek hook.
71fn normalize_rust_toml(dir: &str) {
72    let oxfmt_cmd = format!("(cd {dir} && pnpm dlx oxfmt Cargo.toml >/dev/null 2>&1) || true");
73    let _ = std::process::Command::new("sh").args(["-c", &oxfmt_cmd]).status();
74}
75
76/// Run per-language formatters for all languages that had files generated.
77///
78/// For each language present in `files`, picks the command from
79/// `e2e_config.format[lang]` when available, then falls back to
80/// [`default_formatter`] for languages that have a built-in default (rust,
81/// python). The `{dir}` placeholder is replaced with `{output}/{lang}`.
82/// Failures are logged as warnings and do not abort the process.
83pub fn run_formatters(files: &[GeneratedFile], e2e_config: &E2eConfig) {
84    // Collect the set of languages that had files generated by inspecting
85    // file paths. E2e files are written to `{output}/{lang}/...`, so the
86    // first path component after the output prefix is the language name.
87    let output_prefix = Path::new(e2e_config.effective_output());
88    let languages: HashSet<String> = files
89        .iter()
90        .filter_map(|f| {
91            let remainder = f.path.strip_prefix(output_prefix).ok()?;
92            let first = remainder.components().next()?;
93            Some(first.as_os_str().to_string_lossy().into_owned())
94        })
95        .collect();
96
97    for lang in &languages {
98        // User override takes precedence; then built-in default.
99        let cmd_template: &str = if let Some(custom) = e2e_config.format.get(lang.as_str()) {
100            custom.as_str()
101        } else if let Some(builtin) = default_formatter(lang.as_str()) {
102            builtin
103        } else {
104            continue;
105        };
106
107        let dir = format!("{}/{}", e2e_config.effective_output(), lang);
108        let cmd = cmd_template.replace("{dir}", &dir);
109
110        eprintln!("  Formatting {lang}: {cmd}");
111        let status = std::process::Command::new("sh").args(["-c", &cmd]).status();
112
113        match status {
114            Ok(s) if s.success() => {}
115            Ok(s) => {
116                warn!("Formatter for {lang} exited with {s}: {cmd}");
117            }
118            Err(e) => {
119                warn!("Failed to run formatter for {lang}: {e}");
120            }
121        }
122
123        // Rust-only TOML normalization pass: run oxfmt on the e2e crate's
124        // Cargo.toml so that downstream prek hooks that include oxfmt produce
125        // no further changes after generation. The user's
126        // `e2e_config.format[rust]` override (if any) typically only covers
127        // cargo fmt — which leaves `Cargo.toml` array wrapping at its raw
128        // codegen layout. Without this pass, prek would rewrite the manifest
129        // after `finalize_hashes` has captured pre-prek content, causing
130        // `alef verify` to report `e2e/rust/Cargo.toml` as stale.
131        if lang == "rust" {
132            normalize_rust_toml(&dir);
133        }
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn test_default_formatter_rust_uses_cd_into_dir() {
143        let cmd = default_formatter("rust").expect("rust must have a default formatter");
144        assert!(
145            cmd.contains("cd {dir}") && cmd.contains("cargo fmt"),
146            "rust formatter must cd into {{dir}} before invoking cargo fmt so it works on \
147             standalone (non-workspace-member) e2e crates: {cmd}"
148        );
149        assert!(
150            !cmd.contains("--manifest-path"),
151            "rust formatter must not use --manifest-path; cargo-fmt does not accept it as a \
152             global flag: {cmd}"
153        );
154        assert!(
155            cmd.contains("{dir}"),
156            "rust formatter must include {{dir}} placeholder: {cmd}"
157        );
158        // cargo-sort must NOT be in the default rust formatter: it relocates
159        // the alef header comments to the bottom of the file, pushing the
160        // `# alef:hash:` line past the 10-line detection window and silently
161        // breaking `alef verify`. TOML normalisation is delegated to oxfmt via
162        // `normalize_rust_toml`.
163        assert!(
164            !cmd.contains("cargo sort"),
165            "rust formatter must NOT run cargo sort — it scrambles the alef header \
166             location in Cargo.toml. Use oxfmt via normalize_rust_toml instead: {cmd}"
167        );
168    }
169
170    #[test]
171    fn test_default_formatter_python_uses_ruff_check_and_format() {
172        let cmd = default_formatter("python").expect("python must have a default formatter");
173        assert!(
174            cmd.contains("ruff check --fix"),
175            "python formatter must run ruff check --fix before ruff format: {cmd}"
176        );
177        assert!(
178            cmd.contains("ruff format"),
179            "python formatter must run ruff format: {cmd}"
180        );
181        assert!(
182            cmd.contains("{dir}"),
183            "python formatter must include {{dir}} placeholder: {cmd}"
184        );
185    }
186
187    #[test]
188    fn test_default_formatter_node_uses_oxfmt() {
189        let cmd = default_formatter("node").expect("node must have a default formatter");
190        assert!(cmd.contains("oxfmt"), "node formatter must use oxfmt: {cmd}");
191        assert!(
192            cmd.contains("{dir}"),
193            "node formatter must include {{dir}} placeholder: {cmd}"
194        );
195    }
196
197    #[test]
198    fn test_default_formatter_wasm_uses_oxfmt() {
199        let cmd = default_formatter("wasm").expect("wasm must have a default formatter");
200        assert!(cmd.contains("oxfmt"), "wasm formatter must use oxfmt: {cmd}");
201        assert!(
202            cmd.contains("{dir}"),
203            "wasm formatter must include {{dir}} placeholder: {cmd}"
204        );
205    }
206
207    #[test]
208    fn test_default_formatter_unknown_lang_returns_none() {
209        assert!(default_formatter("gleam").is_none());
210        assert!(default_formatter("zig").is_none());
211        assert!(default_formatter("java").is_none());
212    }
213
214    /// `normalize_rust_toml` is best-effort: missing tools and non-existent
215    /// directories must NOT panic and must NOT abort the surrounding pipeline.
216    /// Verified by pointing it at a path guaranteed not to exist.
217    #[test]
218    fn test_normalize_rust_toml_is_best_effort_on_missing_dir() {
219        // Should return cleanly even though the dir does not exist; both
220        // oxfmt invocation is wrapped in `|| true`.
221        normalize_rust_toml("/nonexistent/alef-e2e-test/dir");
222    }
223}