use crate::core::config::Step;
use crate::runners;
pub trait Plugin {
fn id(&self) -> &str;
fn description(&self) -> &str;
fn markers(&self) -> &[&str];
fn default_steps(&self) -> Vec<Step>;
fn builtin(&self) -> bool {
true
}
}
struct LanguagePlugin {
id: &'static str,
description: &'static str,
markers: &'static [&'static str],
}
impl Plugin for LanguagePlugin {
fn id(&self) -> &str {
self.id
}
fn description(&self) -> &str {
self.description
}
fn markers(&self) -> &[&str] {
self.markers
}
fn default_steps(&self) -> Vec<Step> {
runners::default_steps(self.id).unwrap_or_default()
}
}
pub fn registry() -> Vec<Box<dyn Plugin>> {
vec![
Box::new(LanguagePlugin {
id: "rust",
description: "Rust / Cargo build pipeline",
markers: &["Cargo.toml"],
}),
Box::new(LanguagePlugin {
id: "node",
description: "Node / npm build pipeline",
markers: &["package.json"],
}),
Box::new(LanguagePlugin {
id: "python",
description: "Python build pipeline",
markers: &["requirements.txt", "pyproject.toml"],
}),
]
}
pub fn for_language(language: &str) -> Option<Box<dyn Plugin>> {
registry().into_iter().find(|p| p.id() == language)
}
const CATALOG: &[(&str, &str)] = &[
("docker", "container"),
("kubernetes", "infrastructure"),
("terraform", "infrastructure"),
("helm", "infrastructure"),
("aws", "cloud"),
("gcp", "cloud"),
("azure", "cloud"),
("cloudflare", "cloud"),
("npm", "language"),
("cargo", "language"),
("python", "language"),
];
fn known(name: &str) -> bool {
CATALOG.iter().any(|(n, _)| *n == name)
}
const BUILTIN_CATEGORY: &str = "language";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SearchHit {
pub name: String,
pub category: String,
pub builtin: bool,
}
pub fn search(query: &str) -> Vec<SearchHit> {
let q = query.to_lowercase();
let mut out: Vec<SearchHit> = Vec::new();
for plugin in registry() {
let name = plugin.id().to_string();
if name.to_lowercase().contains(&q) || BUILTIN_CATEGORY.contains(&q) {
out.push(SearchHit {
name,
category: BUILTIN_CATEGORY.to_string(),
builtin: true,
});
}
}
for (name, category) in CATALOG.iter().copied() {
if !(name.contains(&q) || category.contains(&q)) {
continue;
}
if out.iter().any(|hit| hit.name == name) {
continue;
}
out.push(SearchHit {
name: name.to_string(),
category: category.to_string(),
builtin: false,
});
}
out
}
pub struct PluginCheck {
pub name: String,
pub ok: bool,
pub detail: String,
}
pub fn verify(root: &std::path::Path) -> Vec<PluginCheck> {
installed(root)
.into_iter()
.map(|name| {
let path = plugins_dir(root).join(format!("{name}.plugin"));
let ok = std::fs::read_to_string(&path)
.map(|t| t.contains("name ="))
.unwrap_or(false);
PluginCheck {
name,
ok,
detail: if ok {
"manifest ok".into()
} else {
"malformed or missing manifest".into()
},
}
})
.collect()
}
fn plugins_dir(root: &std::path::Path) -> std::path::PathBuf {
root.join(".flux-cache").join("plugins")
}
pub fn install(root: &std::path::Path, name: &str) -> anyhow::Result<()> {
if !known(name) {
let names: Vec<&str> = CATALOG.iter().map(|(n, _)| *n).collect();
anyhow::bail!(
"unknown plugin '{name}'. Known plugins: {}",
names.join(", ")
);
}
let dir = plugins_dir(root);
std::fs::create_dir_all(&dir)?;
std::fs::write(
dir.join(format!("{name}.plugin")),
format!("name = {name}\nstatus = installed\n"),
)?;
Ok(())
}
pub fn create(root: &std::path::Path, name: &str) -> anyhow::Result<std::path::PathBuf> {
if name.is_empty()
|| !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
{
anyhow::bail!("invalid plugin name '{name}' (use letters, digits, '-' or '_')");
}
let dir = root.join("plugins").join(name);
if dir.exists() {
anyhow::bail!("plugins/{name} already exists");
}
std::fs::create_dir_all(dir.join("src"))?;
std::fs::create_dir_all(dir.join("tests"))?;
std::fs::write(
dir.join("manifest.toml"),
format!(
"name = \"{name}\"\nversion = \"0.1.0\"\nauthor = \"you\"\ncapabilities = []\n\n\
# Declare what this plugin can do, e.g.:\n\
# capabilities = [\"container-build\", \"image-push\"]\n"
),
)?;
std::fs::write(
dir.join("src").join("plugin.rs"),
"// Implement the Flux plugin contract.\n\
//\n\
// trait FluxPlugin {\n\
// fn execute(&self, ctx: &Context) -> Result<()>;\n\
// fn validate(&self) -> Result<()>;\n\
// }\n",
)?;
std::fs::write(
dir.join("tests").join("plugin_test.rs"),
"// Add plugin tests here.\n",
)?;
std::fs::write(
dir.join("README.md"),
format!("# {name}\n\nA Flux plugin. Describe what it does and how to configure it.\n"),
)?;
Ok(dir)
}
pub fn installed(root: &std::path::Path) -> Vec<String> {
let mut out = Vec::new();
if let Ok(entries) = std::fs::read_dir(plugins_dir(root)) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("plugin") {
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
out.push(stem.to_string());
}
}
}
}
out.sort();
out
}
#[cfg(test)]
mod tests {
use super::*;
fn names(query: &str) -> Vec<String> {
search(query).into_iter().map(|h| h.name).collect()
}
#[test]
fn search_finds_builtin_plugins() {
for builtin in registry() {
let id = builtin.id();
let hits = search(id);
let hit = hits
.iter()
.find(|h| h.name == id)
.unwrap_or_else(|| panic!("built-in '{id}' not found by search"));
assert!(hit.builtin, "built-in '{id}' should be flagged as built-in");
}
}
#[test]
fn search_still_finds_catalog_entries() {
assert!(names("docker").contains(&"docker".to_string()));
assert!(names("cloud").contains(&"aws".to_string()));
}
#[test]
fn search_does_not_duplicate_builtin_and_catalog_entries() {
let hits = search("python");
let python: Vec<_> = hits.iter().filter(|h| h.name == "python").collect();
assert_eq!(python.len(), 1, "expected one 'python' hit, got {python:?}");
assert!(python[0].builtin);
}
#[test]
fn search_excludes_unrelated_plugins() {
assert!(!names("docker").contains(&"terraform".to_string()));
assert!(search("definitely-not-a-plugin").is_empty());
}
#[test]
fn builtins_are_listed_first() {
let hits = search("language");
let first_catalog = hits.iter().position(|h| !h.builtin);
let last_builtin = hits.iter().rposition(|h| h.builtin);
if let (Some(first_catalog), Some(last_builtin)) = (first_catalog, last_builtin) {
assert!(last_builtin < first_catalog, "built-ins should sort first");
}
}
}