Skip to main content

fse_cli/
modules.rs

1//! Module crates from the app's point of view: discovery via `cargo
2//! metadata`, their shipped schema snapshots for `fse migrate`, and
3//! `fse sync` — copying their `frontend/` sources into `.fse/modules/` where
4//! the app's Astro build layers them in.
5
6use std::fs;
7use std::path::{Path, PathBuf};
8
9use color_eyre::eyre::{Result, WrapErr, bail, eyre};
10use fse_schema::{Schema, snapshot};
11
12use crate::config::OrmConfig;
13
14pub struct ModuleInfo {
15    pub name: String,
16    /// The crate's source directory (inside the cargo registry cache for
17    /// published modules, a local path for path dependencies).
18    pub dir: PathBuf,
19}
20
21/// Locates every configured module crate through `cargo metadata`. Requires
22/// each to be an actual dependency of the app.
23pub fn discover(root: &Path, cfg: &OrmConfig) -> Result<Vec<ModuleInfo>> {
24    if cfg.modules.is_empty() {
25        return Ok(Vec::new());
26    }
27    let output = std::process::Command::new("cargo")
28        .args(["metadata", "--format-version", "1"])
29        .current_dir(root)
30        .output()
31        .wrap_err("cannot run cargo metadata")?;
32    if !output.status.success() {
33        bail!(
34            "cargo metadata failed: {}",
35            String::from_utf8_lossy(&output.stderr)
36        );
37    }
38    let meta: serde_json::Value =
39        serde_json::from_slice(&output.stdout).wrap_err("cargo metadata output")?;
40    let packages = meta["packages"]
41        .as_array()
42        .ok_or_else(|| eyre!("cargo metadata output has no packages"))?;
43
44    let mut modules = Vec::new();
45    for name in &cfg.modules {
46        let package = packages
47            .iter()
48            .find(|p| p["name"].as_str() == Some(name))
49            .ok_or_else(|| {
50                eyre!("module crate `{name}` not found — is it a dependency in Cargo.toml?")
51            })?;
52        let manifest = package["manifest_path"]
53            .as_str()
54            .ok_or_else(|| eyre!("module `{name}`: no manifest_path"))?;
55        let dir = PathBuf::from(manifest)
56            .parent()
57            .ok_or_else(|| eyre!("module `{name}`: bad manifest_path"))?
58            .to_path_buf();
59        modules.push(ModuleInfo {
60            name: name.clone(),
61            dir,
62        });
63    }
64    Ok(modules)
65}
66
67/// A module's shipped schema snapshot — the tables it contributes.
68pub fn load_schema(module: &ModuleInfo) -> Result<Schema> {
69    let path = module.dir.join(".fse/schema.json");
70    let raw = fs::read_to_string(&path).map_err(|_| {
71        eyre!(
72            "module `{}` ships no schema snapshot ({}) — the module author must run \
73             `fse migrate` and include .fse/schema.json in the published crate",
74            module.name,
75            path.display()
76        )
77    })?;
78    Ok(snapshot::schema_from_json(&raw)?)
79}
80
81/// `fse sync`: refreshes `.fse/modules/<name>/frontend/` from every
82/// configured module's `frontend/` sources. The whole `.fse/modules/`
83/// directory is regenerated (it's build output — removed modules disappear).
84pub fn sync(root: &Path, cfg: &OrmConfig) -> Result<()> {
85    let modules = discover(root, cfg)?;
86    let base = root.join(".fse/modules");
87    if base.exists() {
88        fs::remove_dir_all(&base).wrap_err_with(|| format!("cannot clear {}", base.display()))?;
89    }
90    if modules.is_empty() {
91        println!("no modules configured (fse.toml [orm] modules).");
92        return Ok(());
93    }
94    for module in &modules {
95        let src = module.dir.join("frontend");
96        if !src.exists() {
97            println!("{}: no frontend/ sources.", module.name);
98            continue;
99        }
100        let dest = base.join(&module.name).join("frontend");
101        copy_dir(&src, &dest)?;
102        println!("{}: frontend synced to {}", module.name, dest.display());
103    }
104    Ok(())
105}
106
107fn copy_dir(src: &Path, dest: &Path) -> Result<()> {
108    fs::create_dir_all(dest).wrap_err_with(|| dest.display().to_string())?;
109    for entry in fs::read_dir(src).wrap_err_with(|| src.display().to_string())? {
110        let entry = entry.wrap_err_with(|| src.display().to_string())?;
111        let from = entry.path();
112        let to = dest.join(entry.file_name());
113        if from.is_dir() {
114            copy_dir(&from, &to)?;
115        } else {
116            fs::copy(&from, &to).wrap_err_with(|| from.display().to_string())?;
117        }
118    }
119    Ok(())
120}