Skip to main content

newt_core/
tooling.rs

1//! **Lifecycle phases** — a repo's build/dev commands as configurable DATA, not
2//! hard-coded `match` arms (#880).
3//!
4//! A fixed, extensible vocabulary of phase names ([`Phase`]) — `setup`, `format`,
5//! `lint`, `test`, `check`, `clean` — each mapped to a single command. The
6//! mapping is resolved:
7//!
8//! 1. the repo's **`[lifecycle]`** table in `.newt/config.toml` (project-local,
9//!    tracked — the repo declares its own `just clean` / `just test` / …),
10//!    published once via [`set_lifecycle_override`];
11//! 2. else the matching **tooling packs** — per-ecosystem defaults as data
12//!    (built-in + `~/.newt/tooling/*.toml` drop-ins), the sibling of the
13//!    language-pack model. A polyglot repo matches SEVERAL; an unknown one NONE.
14//!
15//! The crew consumes `format` (its normalize-before-commit step) today; the other
16//! phases are declared-and-available for `newt lifecycle <phase>` and future
17//! wiring. Adding/removing a phase name is a small, deliberate code change — the
18//! set is intentionally hard-coded (a stable vocabulary), while the *commands*
19//! are data.
20
21use std::path::Path;
22use std::sync::OnceLock;
23
24use serde::{Deserialize, Serialize};
25
26/// The fixed lifecycle-phase vocabulary. Extend deliberately in a future build.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum Phase {
29    /// Resolve deps / prepare a fresh checkout (e.g. `just install`).
30    Setup,
31    /// Auto-format the tree (e.g. `cargo fmt`). The crew's normalize step.
32    Format,
33    /// Static analysis (e.g. `cargo clippy`).
34    Lint,
35    /// Run the tests (e.g. `cargo test`).
36    Test,
37    /// The gate a change must pass (e.g. `just check`). The crew's verify.
38    Check,
39    /// Remove build / scratch artifacts (e.g. `just clean`).
40    Clean,
41}
42
43impl Phase {
44    /// Every phase, in lifecycle order.
45    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    /// The stable string key (the TOML field name).
55    #[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    /// Parse a phase key — the inverse of [`as_str`](Self::as_str). Unknown → `None`.
68    /// (Named `from_key`, not `from_str`, to avoid the `FromStr` trait's error
69    /// contract and the `should_implement_trait` clippy lint.)
70    #[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/// A per-phase command map — used both as a pack's default commands (`[phases]`)
77/// and as the repo's `[lifecycle]` override. Every field optional (an unset phase
78/// falls through to the next source).
79#[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    /// The command for `phase`, if set.
98    #[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/// A toolchain's lifecycle commands, as data. Selected for a repo when ANY of its
112/// `detect` marker files exists at the repo root.
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(deny_unknown_fields)]
115pub struct ToolingPack {
116    /// Pack name — the merge / drop-in key (`rust`, `python`, …). A drop-in with
117    /// an existing name overrides the built-in; a new name adds a toolchain.
118    pub name: String,
119    /// Marker files whose presence at the repo root selects this toolchain.
120    /// By default ANY match selects it; with `require_all` **every** marker must
121    /// exist (e.g. PyO3 = `Cargo.toml` AND `pyproject.toml`).
122    #[serde(default)]
123    pub detect: Vec<String>,
124    /// Require ALL `detect` markers (a compound toolchain like Rust+PyO3), and
125    /// SUPERSEDE the component packs it subsumes (so a PyO3 repo uses the `pyo3`
126    /// pack, not `rust` + `python` on top of it).
127    #[serde(default)]
128    pub require_all: bool,
129    /// The toolchain's default command per lifecycle phase (`[phases]`).
130    #[serde(default)]
131    pub phases: PhaseCommands,
132}
133
134impl ToolingPack {
135    /// Does this pack's detection match a repo at `repo_dir`?
136    #[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/// The built-in tooling packs — worked examples a contributor copies into a
151/// `~/.newt/tooling/<name>.toml` drop-in. Override any by name; add new ones.
152#[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        // Rust + PyO3 (maturin) — a compound toolchain: BOTH Cargo.toml AND
163        // pyproject.toml. `require_all` supersedes the plain `rust` + `python`
164        // packs so a PyO3 repo gets one coherent lifecycle, not three stacked.
165        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/// Load tooling packs from a drop-in dir (`<dir>/*.toml`, one per file). Malformed
220/// files are skipped with a warning; a missing dir → `[]`. (The tolerant drop-in
221/// contract shared with the language-pack / `[backends]` dirs.)
222#[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/// Merge pack layers **by name** — later layers win (built-ins < drop-in dir).
247#[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/// Every `phase` command from the packs whose `detect` markers exist in
267/// `repo_dir`, in pack order. **None** matching → empty; **several** → each
268/// matching toolchain's command (a polyglot repo). Pure.
269#[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
281/// The packs that select `repo_dir`, after **supersession**: a matching
282/// `require_all` pack drops the matching packs whose `detect` markers are a
283/// strict subset of its own — so a Rust+PyO3 repo runs the `pyo3` pack, not
284/// `rust` + `python` stacked underneath it.
285fn 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
303/// The repo's `[lifecycle]` override, published once when config resolves.
304static LIFECYCLE_OVERRIDE: OnceLock<PhaseCommands> = OnceLock::new();
305
306/// Publish the repo `[lifecycle]` table (from `Config::resolve`), once.
307pub fn set_lifecycle_override(cmds: PhaseCommands) {
308    let _ = LIFECYCLE_OVERRIDE.set(cmds);
309}
310
311/// The resolved commands for `phase` in `repo_dir`:
312/// the repo `[lifecycle].<phase>` override (a single command) wins; else every
313/// matching tooling pack's default (built-in merged under `~/.newt/tooling/`).
314/// Configuration over Convention; empty = nothing declared for this phase.
315#[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        // Polyglot (Rust + Go): the `format` phase runs BOTH formatters — a case a
372        // hard-coded if/else chain could not express.
373        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        // Rust + PyO3 (BOTH markers) → the `pyo3` pack ONLY, not rust + python
384        // stacked. One coherent format + one check (with `maturin develop`).
385        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        // A pure-Rust repo still resolves just `rust`.
396        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        // The repo `[lifecycle]` table is the same shape (flat, no [phases]).
440        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}