use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use clap::Subcommand;
use serde::Deserialize;
const PLUGINS_DIR: &str = "plugins";
const MANIFEST_DIR: &str = "plugins-manifest";
#[derive(Subcommand)]
pub enum PluginCommand {
Sync,
List,
}
pub fn run(command: PluginCommand) -> Result<()> {
match command {
PluginCommand::Sync => sync(),
PluginCommand::List => list(),
}
}
#[derive(Debug)]
struct Plugin {
author: String,
name: String,
crate_name: String,
}
impl Plugin {
fn ident(&self) -> String {
self.crate_name.replace('-', "_")
}
fn module_id(&self) -> String {
format!("{}.{}", self.author, self.name)
}
fn dep_path(&self) -> String {
format!("../{PLUGINS_DIR}/{}/{}", self.author, self.name)
}
}
#[derive(Deserialize)]
struct Manifest {
package: Package,
}
#[derive(Deserialize)]
struct Package {
name: String,
}
fn sync() -> Result<()> {
let plugins = discover(Path::new(PLUGINS_DIR))?;
write_manifest(Path::new(MANIFEST_DIR), &plugins)?;
if plugins.is_empty() {
println!("No plugins under {PLUGINS_DIR}/; wrote an empty {MANIFEST_DIR}.");
} else {
println!("Synced {} plugin(s) into {MANIFEST_DIR}/:", plugins.len());
for p in &plugins {
println!(" {} ({})", p.module_id(), p.crate_name);
}
}
Ok(())
}
fn list() -> Result<()> {
let plugins = discover(Path::new(PLUGINS_DIR))?;
if plugins.is_empty() {
println!("No plugins under {PLUGINS_DIR}/.");
return Ok(());
}
for p in &plugins {
println!(
"{:<24} {:<24} {}",
p.module_id(),
p.crate_name,
p.dep_path()
);
}
Ok(())
}
fn discover(root: &Path) -> Result<Vec<Plugin>> {
if !root.is_dir() {
bail!(
"no {}/ directory here; run this from a Laterite application that uses the plugin layout",
root.display()
);
}
let mut plugins = Vec::new();
let mut problems = Vec::new();
for author_dir in subdirs(root)? {
let author = file_name(&author_dir);
for plugin_dir in subdirs(&author_dir)? {
let manifest = plugin_dir.join("Cargo.toml");
if !manifest.is_file() {
continue; }
let name = file_name(&plugin_dir);
let crate_name = read_crate_name(&manifest)?;
let expected = format!("{author}-{name}");
if crate_name != expected {
problems.push(format!(
" {}: crate is '{crate_name}', but the {PLUGINS_DIR}/{author}/{name} layout requires '{expected}'",
plugin_dir.display()
));
continue;
}
plugins.push(Plugin {
author: author.clone(),
name,
crate_name,
});
}
}
if !problems.is_empty() {
bail!(
"plugin crate names must match their folders (crate = author-plugin):\n{}",
problems.join("\n")
);
}
Ok(plugins)
}
fn write_manifest(dir: &Path, plugins: &[Plugin]) -> Result<()> {
fs::create_dir_all(dir.join("src"))
.with_context(|| format!("creating {}/src", dir.display()))?;
fs::write(dir.join("Cargo.toml"), manifest_cargo(plugins))
.with_context(|| format!("writing {}/Cargo.toml", dir.display()))?;
fs::write(dir.join("src/lib.rs"), manifest_lib(plugins))
.with_context(|| format!("writing {}/src/lib.rs", dir.display()))?;
Ok(())
}
fn manifest_cargo(plugins: &[Plugin]) -> String {
let mut deps = String::from("laterite-core = { workspace = true }\n");
for p in plugins {
deps.push_str(&format!(
"{} = {{ path = \"{}\" }}\n",
p.crate_name,
p.dep_path()
));
}
format!(
r#"# @generated by `lat plugin sync` - do not edit.
# Regenerate after adding or removing a plugin under {PLUGINS_DIR}/.
[package]
name = "plugins-manifest"
version = "0.1.0"
edition.workspace = true
publish = false
[dependencies]
{deps}"#
)
}
fn manifest_lib(plugins: &[Plugin]) -> String {
let body = if plugins.is_empty() {
" vec![]".to_string()
} else {
let calls: String = plugins
.iter()
.map(|p| format!(" {}::module(),\n", p.ident()))
.collect();
format!(" vec![\n{calls} ]")
};
format!(
r#"// @generated by `lat plugin sync` - do not edit.
//
// Every plugin under {PLUGINS_DIR}/<author>/<plugin>/, aggregated into `all()` for
// `Bootstrap::modules`. Each plugin crate exposes `module()`. Regenerate with
// `lat plugin sync`.
use laterite_core::Module;
// One module per line and generated verbatim, so `cargo fmt` leaves it as `sync`
// wrote it and the drift check stays a plain comparison.
/// Every installed plugin, in a stable order (vendor then plugin).
#[rustfmt::skip]
pub fn all() -> Vec<Box<dyn Module>> {{
{body}
}}
"#
)
}
pub fn manifest_in_sync() -> Result<Option<bool>> {
let root = Path::new(PLUGINS_DIR);
if !root.is_dir() {
return Ok(None);
}
let plugins = discover(root)?;
let dir = Path::new(MANIFEST_DIR);
let cargo_ok =
fs::read_to_string(dir.join("Cargo.toml")).unwrap_or_default() == manifest_cargo(&plugins);
let lib_ok =
fs::read_to_string(dir.join("src/lib.rs")).unwrap_or_default() == manifest_lib(&plugins);
Ok(Some(cargo_ok && lib_ok))
}
fn read_crate_name(manifest: &Path) -> Result<String> {
let text =
fs::read_to_string(manifest).with_context(|| format!("reading {}", manifest.display()))?;
let parsed: Manifest =
toml::from_str(&text).with_context(|| format!("parsing {}", manifest.display()))?;
Ok(parsed.package.name)
}
fn subdirs(dir: &Path) -> Result<Vec<PathBuf>> {
let mut dirs: Vec<PathBuf> = fs::read_dir(dir)
.with_context(|| format!("reading {}", dir.display()))?
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.is_dir())
.collect();
dirs.sort();
Ok(dirs)
}
fn file_name(p: &Path) -> String {
p.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
fn write_plugin(root: &Path, author: &str, name: &str, crate_name: &str) {
let dir = root.join(author).join(name);
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("Cargo.toml"),
format!(
"[package]\nname = \"{crate_name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"
),
)
.unwrap();
}
#[test]
fn discover_finds_and_sorts_plugins() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
write_plugin(root, "acme", "shop", "acme-shop");
write_plugin(root, "acme", "blog", "acme-blog");
let plugins = discover(root).unwrap();
assert_eq!(plugins.len(), 2);
assert_eq!(plugins[0].module_id(), "acme.blog");
assert_eq!(plugins[1].module_id(), "acme.shop");
}
#[test]
fn discover_rejects_a_crate_name_that_mismatches_its_folder() {
let tmp = tempfile::tempdir().unwrap();
write_plugin(tmp.path(), "acme", "blog", "wrong-name");
let err = discover(tmp.path()).unwrap_err().to_string();
assert!(err.contains("acme-blog"), "{err}");
}
#[test]
fn discover_ignores_non_crate_directories() {
let tmp = tempfile::tempdir().unwrap();
write_plugin(tmp.path(), "acme", "blog", "acme-blog");
fs::create_dir_all(tmp.path().join("acme").join("assets")).unwrap();
assert_eq!(discover(tmp.path()).unwrap().len(), 1);
}
#[test]
fn manifest_lib_lists_each_plugins_entry_point() {
let plugins = vec![
Plugin {
author: "acme".into(),
name: "blog".into(),
crate_name: "acme-blog".into(),
},
Plugin {
author: "rainmill".into(),
name: "location".into(),
crate_name: "rainmill-location".into(),
},
];
let lib = manifest_lib(&plugins);
assert!(lib.contains("acme_blog::module(),"));
assert!(lib.contains("rainmill_location::module(),"));
assert!(lib.contains("pub fn all() -> Vec<Box<dyn Module>>"));
assert!(lib.contains("@generated"));
}
#[test]
fn manifest_cargo_declares_each_plugin_by_path() {
let plugins = vec![Plugin {
author: "rainmill".into(),
name: "location".into(),
crate_name: "rainmill-location".into(),
}];
let cargo = manifest_cargo(&plugins);
assert!(cargo.contains(r#"rainmill-location = { path = "../plugins/rainmill/location" }"#));
assert!(cargo.contains("laterite-core = { workspace = true }"));
}
#[test]
fn an_empty_tree_generates_an_empty_manifest() {
assert!(manifest_lib(&[]).contains("vec![]"));
}
}