use anyhow::{Context, bail};
use serde::{Deserialize, Serialize};
use std::io::Write;
use std::process::{Command, Output, Stdio};
use crate::cookbook::{CookbookMetadata, CookbookPayload, Recipe};
use crate::plugin::Plugin;
const DEFAULT_PRIORITY: u32 = 50;
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
pub struct EnvScores {
pub launcher: f64,
pub slot: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedRecipe {
pub cookbook: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default)]
pub sort_order: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub scores: Option<EnvScores>,
}
pub trait CookbookTrait {
fn list_recipes(&self) -> anyhow::Result<Vec<Recipe>>;
fn cook(&self, recipe: &str) -> anyhow::Result<String>;
fn name(&self) -> &str;
fn priority(&self) -> u32 {
DEFAULT_PRIORITY
}
fn gear(&self, _recipe: &str) -> anyhow::Result<Option<serde_json::Value>> {
Ok(None)
}
}
pub fn sort_cookbooks(cookbooks: &mut [Box<dyn CookbookTrait>]) {
cookbooks.sort_by(|a, b| {
a.priority()
.cmp(&b.priority())
.then_with(|| a.name().cmp(b.name()))
});
}
pub struct CookbookClient {
plugin: Plugin,
metadata: CookbookMetadata,
config: serde_json::Value,
}
impl CookbookClient {
pub fn new(plugin: Plugin) -> Self {
let metadata = Self::fetch_metadata(&plugin.executable);
let config = resolve_config_with_walker(&plugin, &metadata);
Self {
plugin,
metadata,
config,
}
}
pub fn new_user_level_only(plugin: Plugin) -> Self {
let metadata = Self::fetch_metadata(&plugin.executable);
let config = resolve_user_level_only(&plugin);
Self {
plugin,
metadata,
config,
}
}
pub fn config(&self) -> &serde_json::Value {
&self.config
}
#[cfg(test)]
fn with_metadata(plugin: Plugin, metadata: CookbookMetadata) -> Self {
Self::with_metadata_and_config(
plugin,
metadata,
serde_json::Value::Object(Default::default()),
)
}
#[cfg(test)]
fn with_metadata_and_config(
plugin: Plugin,
metadata: CookbookMetadata,
config: serde_json::Value,
) -> Self {
Self {
plugin,
metadata,
config,
}
}
pub(crate) fn fetch_metadata(executable: &str) -> CookbookMetadata {
let result = (|| -> anyhow::Result<CookbookMetadata> {
let output = Command::new(executable)
.arg("metadata")
.output()
.context("Failed to run cookbook metadata command")?;
if !output.status.success() {
bail!("Cookbook does not support metadata subcommand");
}
let stdout = String::from_utf8(output.stdout)
.context("Cookbook metadata produced invalid UTF-8")?;
CookbookMetadata::from_json(&stdout)
})();
match result {
Ok(meta) => meta,
Err(e) => {
tracing::debug!(error = %e, "Could not fetch cookbook metadata, using defaults");
CookbookMetadata::default()
}
}
}
fn spawn_with_payload(&self, args: &[&str]) -> anyhow::Result<Output> {
let mut child = Command::new(&self.plugin.executable)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("Failed to spawn cookbook")?;
if let Some(mut stdin) = child.stdin.take() {
let payload = CookbookPayload::new(self.config.clone());
let bytes =
serde_json::to_vec(&payload).context("Failed to serialize cookbook payload")?;
stdin
.write_all(&bytes)
.context("Failed to write cookbook payload to stdin")?;
}
child.wait_with_output().context("Cookbook process failed")
}
}
fn resolve_config_with_walker(plugin: &Plugin, metadata: &CookbookMetadata) -> serde_json::Value {
let cwd = match std::env::current_dir() {
Ok(c) => c,
Err(e) => {
tracing::warn!(error = %e, "Could not determine cwd; cookbook config defaults to empty");
return serde_json::Value::Object(Default::default());
}
};
let scope = scope_for(plugin);
let allowlist: Vec<&str> = metadata
.project_overridable
.iter()
.map(String::as_str)
.collect();
match crate::config::build_cookbook_config(&cwd, &scope, &allowlist) {
Ok(v) => v,
Err(e) => {
tracing::warn!(cookbook = %plugin.name, error = %e, "Failed to resolve cookbook config; using empty config");
serde_json::Value::Object(Default::default())
}
}
}
fn resolve_user_level_only(plugin: &Plugin) -> serde_json::Value {
let scope = scope_for(plugin);
match crate::config::load_user_config(&scope) {
Ok(v) => v,
Err(e) => {
tracing::warn!(cookbook = %plugin.name, error = %e, "Failed to load user-level cookbook config; using empty config");
serde_json::Value::Object(Default::default())
}
}
}
fn scope_for(plugin: &Plugin) -> String {
format!("cookbook-{}", plugin.name)
}
pub struct RpcCookbookClient {
plugin: Plugin,
metadata: CookbookMetadata,
config: serde_json::Value,
runtime: tokio::runtime::Runtime,
}
impl RpcCookbookClient {
pub fn new(plugin: Plugin) -> Self {
let metadata = CookbookClient::fetch_metadata(&plugin.executable);
let config = resolve_config_with_walker(&plugin, &metadata);
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("build current-thread tokio runtime for RpcCookbookClient");
Self {
plugin,
metadata,
config,
runtime,
}
}
fn invoke(&self, op: &str, args: Vec<String>) -> anyhow::Result<String> {
let cookbook = self.plugin.name.to_string();
let op = op.to_string();
let payload = self.config.clone();
let call_chain = current_call_chain();
self.runtime.block_on(async move {
use crate::rpc::EnwiroRpcClient;
let client = crate::rpc::connect()
.await
.context("connect to enwiro-daemon")?;
let result = client
.cookbook_invoke(crate::rpc::CookbookInvokeParams {
cookbook,
op,
args,
payload,
call_chain,
})
.await
.context("rpc cookbook.invoke")?;
Ok(result.stdout)
})
}
}
fn current_call_chain() -> Vec<String> {
std::env::var(crate::rpc::CALL_CHAIN_ENV_VAR)
.unwrap_or_default()
.split(':')
.filter(|segment| !segment.is_empty())
.map(str::to_owned)
.collect()
}
impl CookbookTrait for RpcCookbookClient {
fn list_recipes(&self) -> anyhow::Result<Vec<Recipe>> {
let cookbook: &str = self.plugin.name.as_str();
tracing::debug!(%cookbook, "Listing recipes via daemon RPC");
let stdout = self
.invoke("list-recipes", vec![])
.with_context(|| format!("cookbook '{cookbook}' failed during 'list-recipes'"))?;
Ok(stdout
.lines()
.filter(|line| !line.is_empty())
.map(|line| {
serde_json::from_str::<Recipe>(line).unwrap_or_else(|e| {
tracing::warn!(
%cookbook, error = %e, %line,
"cookbook list-recipes produced non-Recipe-JSON line; treating as a bare name"
);
Recipe::new(line)
})
})
.collect())
}
fn cook(&self, recipe: &str) -> anyhow::Result<String> {
let cookbook = self.plugin.name.as_str();
tracing::debug!(%cookbook, %recipe, "Cooking recipe via daemon RPC");
let stdout = self
.invoke("cook", vec![recipe.to_string()])
.with_context(|| format!("cookbook '{cookbook}' failed during 'cook {recipe}'"))?;
Ok(stdout.trim().to_string())
}
fn name(&self) -> &str {
self.plugin.name.as_str()
}
fn priority(&self) -> u32 {
self.metadata.default_priority.unwrap_or(DEFAULT_PRIORITY)
}
fn gear(&self, recipe: &str) -> anyhow::Result<Option<serde_json::Value>> {
let stdout = match self.invoke("gear", vec![recipe.to_string()]) {
Ok(s) => s,
Err(e) => {
tracing::debug!(cookbook = %self.plugin.name, error = %e, "Cookbook gear RPC failed");
return Ok(None);
}
};
match serde_json::from_str::<serde_json::Value>(&stdout) {
Ok(v) => Ok(Some(v)),
Err(e) => {
tracing::debug!(cookbook = %self.plugin.name, error = %e, "Cookbook gear stdout was not valid JSON");
Ok(None)
}
}
}
}
impl CookbookTrait for CookbookClient {
fn list_recipes(&self) -> anyhow::Result<Vec<Recipe>> {
tracing::debug!(cookbook = %self.plugin.name, "Listing recipes from cookbook");
let output = self.spawn_with_payload(&["list-recipes"])?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
tracing::error!(cookbook = %self.plugin.name, %stderr, "Cookbook failed to list recipes");
bail!(
"Cookbook '{}' failed to list recipes: {}",
self.plugin.name,
stderr
);
}
let stdout =
String::from_utf8(output.stdout).context("Cookbook produced invalid UTF-8 output")?;
Ok(stdout
.lines()
.filter(|line| !line.is_empty())
.map(|line| serde_json::from_str::<Recipe>(line).unwrap_or_else(|_| Recipe::new(line)))
.collect())
}
fn cook(&self, recipe: &str) -> anyhow::Result<String> {
tracing::debug!(cookbook = %self.plugin.name, recipe = %recipe, "Cooking recipe");
let output = self.spawn_with_payload(&["cook", recipe])?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
tracing::error!(cookbook = %self.plugin.name, recipe = %recipe, %stderr, "Cookbook failed to cook recipe");
bail!(
"Cookbook '{}' failed to cook '{}': {}",
self.plugin.name,
recipe,
stderr
);
}
let stdout =
String::from_utf8(output.stdout).context("Cookbook produced invalid UTF-8 output")?;
Ok(stdout.trim().to_string())
}
fn name(&self) -> &str {
self.plugin.name.as_str()
}
fn priority(&self) -> u32 {
self.metadata.default_priority.unwrap_or(DEFAULT_PRIORITY)
}
fn gear(&self, recipe: &str) -> anyhow::Result<Option<serde_json::Value>> {
let output = match self.spawn_with_payload(&["gear", recipe]) {
Ok(o) => o,
Err(e) => {
tracing::debug!(cookbook = %self.plugin.name, error = %e, "Cookbook gear exec failed");
return Ok(None);
}
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
tracing::debug!(cookbook = %self.plugin.name, recipe = %recipe, %stderr, "Cookbook gear subcommand returned non-zero");
return Ok(None);
}
match serde_json::from_slice::<serde_json::Value>(&output.stdout) {
Ok(json) => Ok(Some(json)),
Err(e) => {
tracing::debug!(cookbook = %self.plugin.name, error = %e, "Cookbook gear stdout was not valid JSON");
Ok(None)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::plugin::PluginKind;
fn mock_plugin(name: &str) -> Plugin {
Plugin {
name: crate::plugin::PluginName::new(name).unwrap(),
kind: PluginKind::Cookbook,
executable: String::new(),
}
}
#[test]
fn test_cookbook_client_uses_priority_from_metadata() {
let client = CookbookClient::with_metadata(
mock_plugin("git"),
CookbookMetadata {
default_priority: Some(10),
project_overridable: vec![],
},
);
assert_eq!(client.priority(), 10);
}
#[test]
fn test_cookbook_client_default_priority_when_no_metadata() {
let client = CookbookClient::with_metadata(mock_plugin("git"), CookbookMetadata::default());
assert_eq!(client.priority(), DEFAULT_PRIORITY);
}
#[test]
fn test_cookbook_client_name_from_plugin() {
let client =
CookbookClient::with_metadata(mock_plugin("my-cookbook"), CookbookMetadata::default());
assert_eq!(client.name(), "my-cookbook");
}
#[test]
fn test_shell_cookbook_receives_defaults_when_no_user_or_project_config() {
use std::os::unix::fs::PermissionsExt;
let tempdir = tempfile::tempdir().expect("tempdir");
let home_dir = tempdir.path().join("home");
let project_dir = tempdir.path().join("proj");
std::fs::create_dir_all(&home_dir).expect("mkdir home");
std::fs::create_dir_all(&project_dir).expect("mkdir project");
let script = project_dir.join("fake-cookbook");
std::fs::write(
&script,
r#"#!/bin/sh
payload=$(cat)
echo "$payload"
"#,
)
.expect("write script");
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755))
.expect("chmod script");
let config = crate::config::ConfigLoader::with_home(home_dir.clone())
.build_cookbook_config(&project_dir, "cookbook-fake", &["repo_globs"])
.expect("no-files build_cookbook_config succeeds");
assert!(
config.is_object(),
"config from a no-files load must be a JSON object so #[serde(default)] structs deserialize cleanly; got {config:?}"
);
let plugin = Plugin {
name: crate::plugin::PluginName::new("fake").unwrap(),
kind: PluginKind::Cookbook,
executable: script.to_string_lossy().into_owned(),
};
let client =
CookbookClient::with_metadata_and_config(plugin, CookbookMetadata::default(), config);
let stdout = client.cook("anything").expect("cook returns stdout");
let payload: CookbookPayload =
serde_json::from_str(&stdout).expect("cookbook saw a valid CookbookPayload on stdin");
assert!(
payload.config.is_object(),
"cookbook must see config as an object (not null) so its #[serde(default)] struct can deserialize; got {:?}",
payload.config
);
}
#[test]
fn test_shell_cookbook_receives_merged_project_layer_config() {
use std::os::unix::fs::PermissionsExt;
let tempdir = tempfile::tempdir().expect("tempdir");
let home_dir = tempdir.path().join("home");
let project_dir = tempdir.path().join("proj");
std::fs::create_dir_all(&home_dir).expect("mkdir home");
std::fs::create_dir_all(&project_dir).expect("mkdir project");
let user_config_dir = home_dir.join(".config/enwiro");
std::fs::create_dir_all(&user_config_dir).expect("mkdir user config dir");
std::fs::write(
user_config_dir.join("cookbook-fake.toml"),
"repo_globs = [\"from-user\"]\n",
)
.expect("write user config");
std::fs::write(
project_dir.join(".enwiro.toml"),
"[cookbook-fake]\nrepo_globs = [\"from-project\"]\nnot_allowed = \"x\"\n",
)
.expect("write project config");
let script = project_dir.join("fake-cookbook");
std::fs::write(
&script,
r#"#!/bin/sh
payload=$(cat)
echo "$payload"
"#,
)
.expect("write script");
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755))
.expect("chmod script");
let config = crate::config::ConfigLoader::with_home(home_dir.clone())
.build_cookbook_config(&project_dir, "cookbook-fake", &["repo_globs"])
.expect("build_cookbook_config succeeds");
let metadata = CookbookMetadata {
default_priority: Some(99),
project_overridable: vec!["repo_globs".to_string()],
};
let plugin = Plugin {
name: crate::plugin::PluginName::new("fake").unwrap(),
kind: PluginKind::Cookbook,
executable: script.to_string_lossy().into_owned(),
};
let client = CookbookClient::with_metadata_and_config(plugin, metadata, config);
let stdout = client.cook("anything").expect("cook returns stdout");
let payload: CookbookPayload =
serde_json::from_str(&stdout).expect("cookbook saw a valid CookbookPayload on stdin");
assert_eq!(payload.version, 1, "payload version should be 1");
assert_eq!(
payload.config["repo_globs"],
serde_json::json!(["from-project"]),
"project layer must win over user layer for the allowlisted key"
);
assert!(
payload.config.get("not_allowed").is_none(),
"non-allowlisted key must be dropped before reaching the cookbook; got {:?}",
payload.config
);
}
}