use serde::{Deserialize, Serialize};
use tocat_api::{
BuildCtx, Execution, ExternalStage, PluginError, PluginFactory, Result, Stage, StderrMode,
};
pub const NAME: &str = "process";
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct ProcessConfig {
#[serde(default)]
pub argv: Option<Vec<String>>,
#[serde(default, alias = "cmd")]
pub command: Option<String>,
#[serde(default)]
pub stderr: StderrMode,
}
pub struct ProcessFactory;
impl PluginFactory for ProcessFactory {
fn name(&self) -> &str {
NAME
}
fn description(&self) -> &str {
"pipe this direction through a subprocess"
}
fn execution(&self) -> Execution {
Execution::Detached
}
fn build(&self, ctx: &mut BuildCtx<'_>) -> Result<Stage> {
let config: ProcessConfig = ctx.config()?;
let (argv, shell) = match (config.argv, config.command) {
(Some(_), Some(_)) => {
return Err(PluginError::config(
NAME,
"`argv` and `command` are alternatives; give one",
));
}
(None, None) => {
return Err(PluginError::config(NAME, "needs `argv` or `command`"));
}
(Some(argv), None) => {
if argv.iter().any(String::is_empty) || argv.is_empty() {
return Err(PluginError::config(NAME, "`argv` has an empty entry"));
}
(argv, false)
}
(None, Some(command)) => {
if command.trim().is_empty() {
return Err(PluginError::config(NAME, "`command` is empty"));
}
(vec![command], true)
}
};
Ok(Stage::External(ExternalStage {
argv,
shell,
stderr: config.stderr,
name: ctx.stage().name.to_string(),
}))
}
}
#[cfg(test)]
mod tests {
use serde_json::{Value, json};
use tocat_api::{ChannelId, ChannelTarget, Direction, HostBuilder, PipelineMeta, StageInfo};
use super::*;
struct NullHost;
impl HostBuilder for NullHost {
fn open_channel(&mut self, _target: ChannelTarget) -> Result<ChannelId> {
Ok(ChannelId(0))
}
}
fn build(config: Value) -> Result<Stage> {
let map = config.as_object().expect("object").clone();
let meta = PipelineMeta::new(Direction::SourceToSink, "src", "sink");
let mut host = NullHost;
let stage = StageInfo {
index: 0,
total: 1,
name: "gzip",
upstream: "src",
downstream: "sink",
};
let mut ctx = BuildCtx::new(NAME, &map, &meta, stage, &mut host);
ProcessFactory.build(&mut ctx)
}
#[test]
fn argv_form_takes_the_display_name() {
match build(json!({ "argv": ["gzip", "-c"] })).unwrap() {
Stage::External(e) => {
assert_eq!(e.argv, ["gzip", "-c"]);
assert!(!e.shell);
assert_eq!(e.name, "gzip");
}
Stage::Filter(_) => panic!("process is external"),
}
}
#[test]
fn command_form_is_shelled() {
match build(json!({ "command": "grep -v DEBUG | sort -u" })).unwrap() {
Stage::External(e) => assert!(e.shell && e.argv.len() == 1),
Stage::Filter(_) => panic!("process is external"),
}
}
#[test]
fn rejects_ambiguous_or_empty_invocations() {
assert!(build(json!({})).is_err());
assert!(build(json!({ "argv": [], "command": "cat" })).is_err());
assert!(build(json!({ "argv": [] })).is_err());
assert!(build(json!({ "command": " " })).is_err());
}
}