1use std::path::Path;
22use std::sync::OnceLock;
23
24use serde::{Deserialize, Serialize};
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum Phase {
29 Setup,
31 Format,
33 Lint,
35 Test,
37 Check,
39 Clean,
41}
42
43impl Phase {
44 pub const ALL: [Self; 6] = [
46 Self::Setup,
47 Self::Format,
48 Self::Lint,
49 Self::Test,
50 Self::Check,
51 Self::Clean,
52 ];
53
54 #[must_use]
56 pub fn as_str(self) -> &'static str {
57 match self {
58 Self::Setup => "setup",
59 Self::Format => "format",
60 Self::Lint => "lint",
61 Self::Test => "test",
62 Self::Check => "check",
63 Self::Clean => "clean",
64 }
65 }
66
67 #[must_use]
71 pub fn from_key(key: &str) -> Option<Self> {
72 Self::ALL.into_iter().find(|p| p.as_str() == key)
73 }
74}
75
76#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
80#[serde(deny_unknown_fields)]
81pub struct PhaseCommands {
82 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub setup: Option<String>,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub format: Option<String>,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub lint: Option<String>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub test: Option<String>,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub check: Option<String>,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub clean: Option<String>,
94}
95
96impl PhaseCommands {
97 #[must_use]
99 pub fn get(&self, phase: Phase) -> Option<&str> {
100 match phase {
101 Phase::Setup => self.setup.as_deref(),
102 Phase::Format => self.format.as_deref(),
103 Phase::Lint => self.lint.as_deref(),
104 Phase::Test => self.test.as_deref(),
105 Phase::Check => self.check.as_deref(),
106 Phase::Clean => self.clean.as_deref(),
107 }
108 }
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(deny_unknown_fields)]
115pub struct ToolingPack {
116 pub name: String,
119 #[serde(default)]
123 pub detect: Vec<String>,
124 #[serde(default)]
128 pub require_all: bool,
129 #[serde(default)]
131 pub phases: PhaseCommands,
132}
133
134impl ToolingPack {
135 #[must_use]
137 fn matches(&self, repo_dir: &Path) -> bool {
138 if self.detect.is_empty() {
139 return false;
140 }
141 let exists = |m: &String| repo_dir.join(m).exists();
142 if self.require_all {
143 self.detect.iter().all(exists)
144 } else {
145 self.detect.iter().any(exists)
146 }
147 }
148}
149
150#[must_use]
153pub fn builtin_tooling_packs() -> Vec<ToolingPack> {
154 let pack = |name: &str, marker: &str, phases: PhaseCommands| ToolingPack {
155 name: name.into(),
156 detect: vec![marker.into()],
157 require_all: false,
158 phases,
159 };
160 let some = |s: &str| Some(s.to_string());
161 vec![
162 ToolingPack {
166 name: "pyo3".into(),
167 detect: vec!["Cargo.toml".into(), "pyproject.toml".into()],
168 require_all: true,
169 phases: PhaseCommands {
170 setup: some("maturin develop"),
171 format: some("cargo fmt && ruff format ."),
172 lint: some("cargo clippy --all-targets -- -D warnings && ruff check ."),
173 test: some("maturin develop && pytest -x"),
174 check: some(
175 "cargo fmt -- --check && cargo clippy --all-targets -- -D warnings \
176 && maturin develop && cargo test && pytest -x",
177 ),
178 clean: some("cargo clean"),
179 },
180 },
181 pack(
182 "rust",
183 "Cargo.toml",
184 PhaseCommands {
185 setup: some("cargo fetch"),
186 format: some("cargo fmt"),
187 lint: some("cargo clippy --all-targets -- -D warnings"),
188 test: some("cargo test"),
189 check: some("cargo fmt -- --check && cargo test"),
190 clean: some("cargo clean"),
191 },
192 ),
193 pack(
194 "python",
195 "pyproject.toml",
196 PhaseCommands {
197 format: some("ruff format ."),
198 lint: some("ruff check ."),
199 test: some("pytest -x"),
200 check: some("pytest -x"),
201 ..PhaseCommands::default()
202 },
203 ),
204 pack(
205 "go",
206 "go.mod",
207 PhaseCommands {
208 format: some("gofmt -w ."),
209 lint: some("go vet ./..."),
210 test: some("go test ./..."),
211 check: some("go test ./..."),
212 clean: some("go clean"),
213 ..PhaseCommands::default()
214 },
215 ),
216 ]
217}
218
219#[must_use]
223pub fn load_tooling_packs_from_dir(dir: &Path) -> Vec<ToolingPack> {
224 let Ok(entries) = std::fs::read_dir(dir) else {
225 return Vec::new();
226 };
227 let mut paths: Vec<std::path::PathBuf> = entries
228 .flatten()
229 .map(|e| e.path())
230 .filter(|p| p.extension().is_some_and(|x| x == "toml"))
231 .collect();
232 paths.sort();
233 let mut out = Vec::new();
234 for path in paths {
235 match std::fs::read_to_string(&path).map(|s| toml::from_str::<ToolingPack>(&s)) {
236 Ok(Ok(pack)) => out.push(pack),
237 Ok(Err(e)) => {
238 tracing::warn!(path = %path.display(), error = %e, "skipping malformed tooling pack");
239 }
240 Err(_) => {}
241 }
242 }
243 out
244}
245
246#[must_use]
248pub fn merge_tooling_packs(layers: Vec<Vec<ToolingPack>>) -> Vec<ToolingPack> {
249 let mut order: Vec<String> = Vec::new();
250 let mut by_name: std::collections::HashMap<String, ToolingPack> =
251 std::collections::HashMap::new();
252 for layer in layers {
253 for pack in layer {
254 if !by_name.contains_key(&pack.name) {
255 order.push(pack.name.clone());
256 }
257 by_name.insert(pack.name.clone(), pack);
258 }
259 }
260 order
261 .into_iter()
262 .filter_map(|n| by_name.remove(&n))
263 .collect()
264}
265
266#[must_use]
270pub fn phase_commands_from_packs(
271 repo_dir: &Path,
272 phase: Phase,
273 packs: &[ToolingPack],
274) -> Vec<String> {
275 matching_packs(repo_dir, packs)
276 .into_iter()
277 .filter_map(|p| p.phases.get(phase).map(str::to_string))
278 .collect()
279}
280
281fn matching_packs<'a>(repo_dir: &Path, packs: &'a [ToolingPack]) -> Vec<&'a ToolingPack> {
286 let matched: Vec<&ToolingPack> = packs.iter().filter(|p| p.matches(repo_dir)).collect();
287 let supersets: Vec<&[String]> = matched
288 .iter()
289 .filter(|p| p.require_all)
290 .map(|p| p.detect.as_slice())
291 .collect();
292 matched
293 .iter()
294 .filter(|p| {
295 !supersets
296 .iter()
297 .any(|sup| sup.len() > p.detect.len() && p.detect.iter().all(|m| sup.contains(m)))
298 })
299 .copied()
300 .collect()
301}
302
303static LIFECYCLE_OVERRIDE: OnceLock<PhaseCommands> = OnceLock::new();
305
306pub fn set_lifecycle_override(cmds: PhaseCommands) {
308 let _ = LIFECYCLE_OVERRIDE.set(cmds);
309}
310
311#[must_use]
316pub fn resolved_phase_commands(repo_dir: &Path, phase: Phase) -> Vec<String> {
317 if let Some(cmd) = LIFECYCLE_OVERRIDE.get().and_then(|c| c.get(phase)) {
318 return vec![cmd.to_string()];
319 }
320 let dropins = crate::config::Config::user_config_dir()
321 .map(|d| load_tooling_packs_from_dir(&d.join("tooling")))
322 .unwrap_or_default();
323 let packs = merge_tooling_packs(vec![builtin_tooling_packs(), dropins]);
324 phase_commands_from_packs(repo_dir, phase, &packs)
325}
326
327#[cfg(test)]
328mod tests {
329 use super::*;
330
331 #[test]
332 fn phase_keys_are_stable() {
333 assert_eq!(Phase::Format.as_str(), "format");
334 assert_eq!(Phase::Check.as_str(), "check");
335 assert_eq!(Phase::ALL.len(), 6);
336 }
337
338 #[test]
339 fn from_key_round_trips_every_phase_and_rejects_unknown() {
340 for p in Phase::ALL {
341 assert_eq!(Phase::from_key(p.as_str()), Some(p));
342 }
343 assert_eq!(Phase::from_key("deploy"), None);
344 assert_eq!(Phase::from_key(""), None);
345 }
346
347 #[test]
348 fn builtin_format_is_data_per_ecosystem() {
349 let by = |n: &str| {
350 builtin_tooling_packs()
351 .into_iter()
352 .find(|p| p.name == n)
353 .unwrap()
354 };
355 assert_eq!(by("rust").phases.format.as_deref(), Some("cargo fmt"));
356 assert_eq!(by("python").phases.format.as_deref(), Some("ruff format ."));
357 assert_eq!(by("go").phases.check.as_deref(), Some("go test ./..."));
358 }
359
360 #[test]
361 fn none_matching_yields_no_commands() {
362 let dir = tempfile::tempdir().unwrap();
363 assert!(
364 phase_commands_from_packs(dir.path(), Phase::Format, &builtin_tooling_packs())
365 .is_empty()
366 );
367 }
368
369 #[test]
370 fn multiple_toolchains_each_contribute_a_command() {
371 let dir = tempfile::tempdir().unwrap();
374 std::fs::write(dir.path().join("Cargo.toml"), "").unwrap();
375 std::fs::write(dir.path().join("go.mod"), "").unwrap();
376 let cmds = phase_commands_from_packs(dir.path(), Phase::Format, &builtin_tooling_packs());
377 assert!(cmds.contains(&"cargo fmt".to_string()), "{cmds:?}");
378 assert!(cmds.contains(&"gofmt -w .".to_string()), "{cmds:?}");
379 }
380
381 #[test]
382 fn pyo3_require_all_supersedes_rust_and_python() {
383 let dir = tempfile::tempdir().unwrap();
386 std::fs::write(dir.path().join("Cargo.toml"), "").unwrap();
387 std::fs::write(dir.path().join("pyproject.toml"), "").unwrap();
388 assert_eq!(
389 phase_commands_from_packs(dir.path(), Phase::Format, &builtin_tooling_packs()),
390 vec!["cargo fmt && ruff format .".to_string()]
391 );
392 let check = phase_commands_from_packs(dir.path(), Phase::Check, &builtin_tooling_packs());
393 assert_eq!(check.len(), 1, "one coherent check, not three: {check:?}");
394 assert!(check[0].contains("maturin develop"), "{check:?}");
395 let d2 = tempfile::tempdir().unwrap();
397 std::fs::write(d2.path().join("Cargo.toml"), "").unwrap();
398 assert_eq!(
399 phase_commands_from_packs(d2.path(), Phase::Format, &builtin_tooling_packs()),
400 vec!["cargo fmt".to_string()]
401 );
402 }
403
404 #[test]
405 fn a_dropin_overrides_by_name_and_adds_new_toolchains() {
406 let dropins = vec![
407 ToolingPack {
408 name: "rust".into(),
409 detect: vec!["Cargo.toml".into()],
410 require_all: false,
411 phases: PhaseCommands {
412 format: Some("cargo +nightly fmt".into()),
413 ..PhaseCommands::default()
414 },
415 },
416 ToolingPack {
417 name: "zig".into(),
418 detect: vec!["build.zig".into()],
419 require_all: false,
420 phases: PhaseCommands {
421 format: Some("zig fmt .".into()),
422 ..PhaseCommands::default()
423 },
424 },
425 ];
426 let merged = merge_tooling_packs(vec![builtin_tooling_packs(), dropins]);
427 let rust = merged.iter().find(|p| p.name == "rust").unwrap();
428 assert_eq!(rust.phases.format.as_deref(), Some("cargo +nightly fmt"));
429 assert!(merged.iter().any(|p| p.name == "zig"));
430 }
431
432 #[test]
433 fn pack_and_lifecycle_parse_from_toml() {
434 let p: ToolingPack = toml::from_str(
435 "name = \"zig\"\ndetect = [\"build.zig\"]\n[phases]\nformat = \"zig fmt .\"\n",
436 )
437 .unwrap();
438 assert_eq!(p.phases.format.as_deref(), Some("zig fmt ."));
439 let lc: PhaseCommands = toml::from_str(
441 "format = \"cargo fmt\"\ncheck = \"just check\"\nclean = \"just clean\"\n",
442 )
443 .unwrap();
444 assert_eq!(lc.check.as_deref(), Some("just check"));
445 }
446}