Skip to main content

gize_generator/
plugin.rs

1//! Plugin API v0 (ADR-008) — **unstable**.
2//!
3//! A plugin is a [`Generator`]: given the project context and its arguments, it produces a
4//! [`Plan`]. Applying the plan goes through the same safe [`Writer`] as the built-in
5//! generators, so a third-party generator inherits the whole safety model for free (never
6//! clobber without `--force`, honor `--dry-run`, manifest as source of truth). No plugin
7//! writes files directly.
8//!
9//! Two integration paths (ADR-008):
10//! - **In-process:** a crate implements [`Generator`] and either builds a custom `gize` or its
11//!   own binary, calling [`run`].
12//! - **Subcommand fallback:** a `gize-<name>` binary on `PATH` is invoked by `gize <name> …`.
13//!
14//! The trait and [`GenContext`] are **v0 and may change** until stabilized in the RC.
15
16use std::path::{Path, PathBuf};
17
18use anyhow::{Context, Result};
19use gize_core::Manifest;
20
21use crate::plan::Plan;
22use crate::writer::{Options, Report, Writer};
23
24/// What a generator gets to work with: the project manifest and its root directory.
25#[derive(Debug, Clone)]
26pub struct GenContext {
27    /// The parsed `gize.toml`.
28    pub manifest: Manifest,
29    /// The project root (paths in the returned [`Plan`] are relative to it).
30    pub root: PathBuf,
31}
32
33impl GenContext {
34    /// Build a context from a project rooted at `root` (reads `root/gize.toml`).
35    pub fn from_root(root: impl Into<PathBuf>) -> Result<Self> {
36        let root = root.into();
37        let manifest_path = root.join("gize.toml");
38        let text = std::fs::read_to_string(&manifest_path)
39            .with_context(|| format!("reading {}", manifest_path.display()))?;
40        let manifest = Manifest::from_toml(&text)?;
41        Ok(Self { manifest, root })
42    }
43
44    /// Build a context from the current directory (the common case for a CLI plugin).
45    pub fn from_current_dir() -> Result<Self> {
46        Self::from_root(Path::new("."))
47    }
48}
49
50/// A third-party (or built-in) code generator. Implement this to extend `gize` (ADR-008, v0).
51pub trait Generator {
52    /// The subcommand name, e.g. `"healthcheck"` (invoked as `gize healthcheck …`).
53    fn name(&self) -> &str;
54
55    /// Build a [`Plan`] from the project context and the plugin's arguments. **Pure** — do no
56    /// I/O here, so the plan stays testable and `--dry-run` works.
57    fn plan(&self, ctx: &GenContext, args: &[String]) -> Result<Plan>;
58}
59
60/// Run a generator against the current project: build the context, ask it for a plan, and
61/// apply that plan through the safe [`Writer`] (honoring `force`/`dry_run`). This is the entry
62/// point a plugin binary calls from `main`.
63pub fn run(generator: &dyn Generator, args: &[String], opts: Options) -> Result<Report> {
64    let ctx = GenContext::from_current_dir().context("not a gize project here (no gize.toml)")?;
65    let plan = generator
66        .plan(&ctx, args)
67        .with_context(|| format!("plugin `{}` failed to build its plan", generator.name()))?;
68    Writer::new(opts).apply(&ctx.root, &plan)
69}
70
71/// Apply a generator's plan against an explicit context (useful for tests and custom hosts).
72pub fn run_in(
73    ctx: &GenContext,
74    generator: &dyn Generator,
75    args: &[String],
76    opts: Options,
77) -> Result<Report> {
78    let plan = generator.plan(ctx, args)?;
79    Writer::new(opts).apply(&ctx.root, &plan)
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    struct Dummy;
87    impl Generator for Dummy {
88        fn name(&self) -> &str {
89            "dummy"
90        }
91        fn plan(&self, _ctx: &GenContext, _args: &[String]) -> Result<Plan> {
92            Ok(Plan::new().create("generated/by_plugin.txt", "hello from a plugin\n"))
93        }
94    }
95
96    #[test]
97    fn a_plugin_generates_through_the_safe_writer() {
98        use std::sync::atomic::{AtomicUsize, Ordering};
99        static COUNTER: AtomicUsize = AtomicUsize::new(0);
100        let root = std::env::temp_dir().join(format!(
101            "gize-plugin-{}-{}",
102            std::process::id(),
103            COUNTER.fetch_add(1, Ordering::Relaxed)
104        ));
105        std::fs::create_dir_all(&root).unwrap();
106        std::fs::write(root.join("gize.toml"), "[project]\nname = \"demo\"\n").unwrap();
107
108        let ctx = GenContext::from_root(&root).unwrap();
109        let report = run_in(&ctx, &Dummy, &[], Options::default()).unwrap();
110        assert_eq!(report.created, vec!["generated/by_plugin.txt".to_string()]);
111        assert!(root.join("generated/by_plugin.txt").is_file());
112
113        // Re-running without --force skips (safety model inherited from the Writer).
114        let again = run_in(&ctx, &Dummy, &[], Options::default()).unwrap();
115        assert_eq!(again.skipped, vec!["generated/by_plugin.txt".to_string()]);
116    }
117}