use clap::{Parser, Subcommand};
use serde_json::{Map, Value};
#[derive(Parser)]
#[command(
name = "moadim",
version,
no_binary_name = true,
about = "moadim data commands"
)]
struct DataCli {
#[command(subcommand)]
command: DataCommand,
}
#[derive(Subcommand)]
enum DataCommand {
#[command(subcommand, visible_alias = "routine")]
Routines(Box<RoutineCmd>),
#[command(subcommand, visible_alias = "sched")]
Schedule(ScheduleCmd),
Agents,
Echo {
message: String,
},
}
#[derive(Subcommand)]
enum ScheduleCmd {
Trigger {
id: String,
},
}
#[derive(Subcommand)]
enum RoutineCmd {
Create {
#[arg(long)]
schedule: String,
#[arg(long)]
title: String,
#[arg(long)]
agent: String,
#[arg(long)]
model: Option<String>,
#[arg(long)]
prompt: String,
#[arg(long)]
repositories: Option<String>,
#[arg(long)]
machines: Option<String>,
#[arg(long)]
ttl_secs: Option<u64>,
#[arg(long)]
max_runtime_secs: Option<u64>,
#[arg(long = "tag")]
tags: Vec<String>,
#[arg(long)]
disabled: bool,
},
List,
Get {
id: String,
},
Update {
id: String,
#[arg(long)]
schedule: Option<String>,
#[arg(long)]
title: Option<String>,
#[arg(long)]
agent: Option<String>,
#[arg(long)]
model: Option<String>,
#[arg(long)]
prompt: Option<String>,
#[arg(long)]
repositories: Option<String>,
#[arg(long)]
machines: Option<String>,
#[arg(long)]
enabled: Option<bool>,
#[arg(long)]
ttl_secs: Option<u64>,
#[arg(long)]
max_runtime_secs: Option<u64>,
#[arg(long = "tag")]
tags: Vec<String>,
},
Replace {
id: String,
#[arg(long)]
schedule: String,
#[arg(long)]
title: String,
#[arg(long)]
agent: String,
#[arg(long)]
model: Option<String>,
#[arg(long)]
prompt: String,
#[arg(long)]
repositories: Option<String>,
#[arg(long)]
machines: Option<String>,
#[arg(long)]
ttl_secs: Option<u64>,
#[arg(long)]
max_runtime_secs: Option<u64>,
#[arg(long = "tag")]
tags: Vec<String>,
#[arg(long)]
disabled: bool,
},
Delete {
id: String,
},
Trigger {
id: String,
},
Logs {
id: String,
},
Ical,
}
pub fn run(args: Vec<String>) -> i32 {
match DataCli::try_parse_from(args) {
Ok(cli) => dispatch(cli.command),
Err(err) => {
let _ = err.print();
match err.kind() {
clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion => 0,
_ => 2,
}
}
}
}
fn dispatch(command: DataCommand) -> i32 {
match command {
DataCommand::Routines(cmd) => dispatch_routine(*cmd),
DataCommand::Schedule(ScheduleCmd::Trigger { id }) => request(
"POST",
&format!("{}/scheduled-trigger", routine_path(&id)),
None,
),
DataCommand::Agents => request("GET", "/api/v1/agents", None),
DataCommand::Echo { message } => {
let body = object([("message", Value::String(message))]);
request("POST", "/api/v1/echo", Some(&body))
}
}
}
fn dispatch_routine(cmd: RoutineCmd) -> i32 {
match cmd {
RoutineCmd::Create {
schedule,
title,
agent,
model,
prompt,
repositories,
machines,
ttl_secs,
max_runtime_secs,
tags,
disabled,
} => match routine_body(
schedule,
title,
agent,
model,
prompt,
repositories,
machines,
ttl_secs,
max_runtime_secs,
tags,
disabled,
) {
Ok(body) => request("POST", "/api/v1/routines", Some(&body)),
Err(code) => code,
},
RoutineCmd::List => request("GET", "/api/v1/routines", None),
RoutineCmd::Get { id } => request("GET", &routine_path(&id), None),
RoutineCmd::Update {
id,
schedule,
title,
agent,
model,
prompt,
repositories,
machines,
enabled,
ttl_secs,
max_runtime_secs,
tags,
} => {
let mut map = Map::new();
insert_opt(&mut map, "schedule", schedule.map(Value::String));
insert_opt(&mut map, "title", title.map(Value::String));
insert_opt(&mut map, "agent", agent.map(Value::String));
insert_opt(&mut map, "model", model.map(Value::String));
insert_opt(&mut map, "prompt", prompt.map(Value::String));
match insert_json_opt(&mut map, "repositories", repositories) {
Ok(()) => {}
Err(code) => return code,
}
match insert_json_opt(&mut map, "machines", machines) {
Ok(()) => {}
Err(code) => return code,
}
insert_opt(&mut map, "enabled", enabled.map(Value::Bool));
insert_opt(&mut map, "ttl_secs", ttl_secs.map(Value::from));
insert_opt(
&mut map,
"max_runtime_secs",
max_runtime_secs.map(Value::from),
);
insert_opt(
&mut map,
"tags",
(!tags.is_empty()).then(|| tags_value(tags)),
);
request("PATCH", &routine_path(&id), Some(&to_body(map)))
}
RoutineCmd::Replace {
id,
schedule,
title,
agent,
model,
prompt,
repositories,
machines,
ttl_secs,
max_runtime_secs,
tags,
disabled,
} => match routine_body(
schedule,
title,
agent,
model,
prompt,
repositories,
machines,
ttl_secs,
max_runtime_secs,
tags,
disabled,
) {
Ok(body) => request("PUT", &routine_path(&id), Some(&body)),
Err(code) => code,
},
RoutineCmd::Delete { id } => request("DELETE", &routine_path(&id), None),
RoutineCmd::Trigger { id } => {
request("POST", &format!("{}/trigger", routine_path(&id)), None)
}
RoutineCmd::Logs { id } => request("GET", &format!("{}/logs", routine_path(&id)), None),
RoutineCmd::Ical => request("GET", "/api/v1/routines.ics", None),
}
}
fn routine_path(id: &str) -> String {
format!("/api/v1/routines/{id}")
}
#[allow(clippy::too_many_arguments)]
fn routine_body(
schedule: String,
title: String,
agent: String,
model: Option<String>,
prompt: String,
repositories: Option<String>,
machines: Option<String>,
ttl_secs: Option<u64>,
max_runtime_secs: Option<u64>,
tags: Vec<String>,
disabled: bool,
) -> Result<String, i32> {
let mut map = Map::new();
map.insert("schedule".to_string(), Value::String(schedule));
map.insert("title".to_string(), Value::String(title));
map.insert("agent".to_string(), Value::String(agent));
insert_opt(&mut map, "model", model.map(Value::String));
map.insert("prompt".to_string(), Value::String(prompt));
insert_json_opt(&mut map, "repositories", repositories)?;
insert_json_opt(&mut map, "machines", machines)?;
insert_opt(&mut map, "ttl_secs", ttl_secs.map(Value::from));
insert_opt(
&mut map,
"max_runtime_secs",
max_runtime_secs.map(Value::from),
);
map.insert("tags".to_string(), tags_value(tags));
map.insert("enabled".to_string(), Value::Bool(!disabled));
Ok(to_body(map))
}
fn tags_value(tags: Vec<String>) -> Value {
Value::Array(tags.into_iter().map(Value::String).collect())
}
fn insert_opt(map: &mut Map<String, Value>, key: &str, value: Option<Value>) {
if let Some(value) = value {
map.insert(key.to_string(), value);
}
}
fn insert_json_opt(
map: &mut Map<String, Value>,
key: &str,
raw: Option<String>,
) -> Result<(), i32> {
let Some(raw) = raw else { return Ok(()) };
match serde_json::from_str::<Value>(&raw) {
Ok(value) => {
map.insert(key.to_string(), value);
Ok(())
}
Err(err) => {
eprintln!("error: --{key} is not valid JSON: {err}");
Err(2)
}
}
}
fn object<const N: usize>(pairs: [(&str, Value); N]) -> String {
let mut map = Map::new();
for (key, value) in pairs {
map.insert(key.to_string(), value);
}
to_body(map)
}
fn to_body(map: Map<String, Value>) -> String {
Value::Object(map).to_string()
}
fn request(method: &str, path: &str, body: Option<&str>) -> i32 {
match crate::cli::http_request_json(method, path, body) {
Ok((status, resp)) if (200..300).contains(&status) => {
print_body(&resp);
0
}
Ok((status, resp)) => {
eprintln!("error: server returned HTTP {status}");
if !resp.is_empty() {
eprintln!("{resp}");
}
1
}
Err(_) => {
eprintln!("moadim is not running");
crate::cli::EXIT_NOT_RUNNING
}
}
}
fn print_body(body: &str) {
if body.is_empty() {
return;
}
match serde_json::from_str::<Value>(body) {
Ok(value) => println!(
"{}",
serde_json::to_string_pretty(&value).unwrap_or_default()
),
Err(_) => println!("{body}"),
}
}
#[cfg(test)]
#[path = "commands_tests.rs"]
mod commands_tests;