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}