use serde_json::Value;
use super::reach::Reach;
pub const INPUT_FIELD: &str = "input";
pub const REACH_FIELD: &str = "reach";
pub const METADATA_FIELD: &str = "metadata";
pub const FILES_FIELD: &str = "files";
pub(crate) const LOCAL_TARGET: &str = "local";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Command,
Agent,
}
impl Mode {
pub const fn as_str(self) -> &'static str {
match self {
Self::Command => "command",
Self::Agent => "agent",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Spawn {
mode: Mode,
body: String,
target: Option<String>,
reach: Option<Reach>,
metadata: Option<serde_json::Map<String, Value>>,
files: Vec<String>,
}
impl Spawn {
pub const fn mode(&self) -> Mode {
self.mode
}
pub fn body(&self) -> &str {
&self.body
}
pub const fn reach(&self) -> Option<Reach> {
self.reach
}
pub fn metadata(&self) -> Option<&serde_json::Map<String, Value>> {
self.metadata.as_ref()
}
pub fn files(&self) -> &[String] {
&self.files
}
pub fn target(&self) -> Option<&str> {
self.target.as_deref()
}
pub fn is_remote_wait(&self) -> bool {
self.mode == Mode::Agent && self.target.is_some() && self.body.is_empty()
}
}
pub fn parse(input: &Value) -> Result<Spawn, String> {
let raw = input
.get(INPUT_FIELD)
.and_then(Value::as_str)
.ok_or_else(|| {
format!(
"spawn takes one string field, `{INPUT_FIELD}`: a command to run when it starts \
with `!`, otherwise a task to delegate"
)
})?;
let mode = classify_spawn_input(input).expect("the same string field was read above");
let reach = match input.get(REACH_FIELD) {
None | Some(Value::Null) => None,
Some(Value::String(said)) => Some(Reach::parse(said).ok_or_else(|| {
format!(
"`{REACH_FIELD}` is one of `auto`, `remote` or `local`, not `{said}`; leave it \
out to let a name reach a remote agent or a teammate here, whichever has it"
)
})?),
Some(_) => {
return Err(format!(
"`{REACH_FIELD}` is a string, one of `auto`, `remote` or `local`"
));
}
};
let mut spawn = read(raw, mode)?;
spawn.reach = reach;
spawn.metadata = match input.get(METADATA_FIELD) {
None | Some(Value::Null) => None,
Some(Value::Object(map)) if map.is_empty() => None,
Some(Value::Object(map)) => {
if spawn.mode != Mode::Agent || spawn.target.is_none() || spawn.body.is_empty() {
return Err(format!(
"`{METADATA_FIELD}` goes with a task for a remote agent, `@<agent> <task>`: \
it is sent beside the task as the agent's parameters. `@<agent>` alone \
keeps waiting on a task that already has its parameters, so leave \
`{METADATA_FIELD}` out there"
));
}
Some(map.clone())
}
Some(_) => {
return Err(format!(
"`{METADATA_FIELD}` is a JSON object of parameters for the agent, as \
{{\"project\": \"lama\"}}"
));
}
};
spawn.files = files(input.get(FILES_FIELD), &spawn)?;
Ok(spawn)
}
fn files(field: Option<&Value>, spawn: &Spawn) -> Result<Vec<String>, String> {
let shape =
|| format!("`{FILES_FIELD}` is a list of workspace paths, as [\"screens/login.png\"]");
let paths = match field {
None | Some(Value::Null) => return Ok(Vec::new()),
Some(Value::Array(paths)) => paths
.iter()
.map(|path| match path {
Value::String(path) if !path.trim().is_empty() => Ok(path.clone()),
_ => Err(shape()),
})
.collect::<Result<Vec<_>, _>>()?,
Some(_) => return Err(shape()),
};
if !paths.is_empty() && (spawn.mode != Mode::Agent || spawn.body.is_empty()) {
return Err(format!(
"`{FILES_FIELD}` goes with a task: they are sent to the agent the task is handed \
to. A command has nowhere to put them, and `@<agent>` alone keeps waiting on a \
task that already has its files, so leave `{FILES_FIELD}` out there"
));
}
Ok(paths)
}
pub fn classify_spawn_input(input: &Value) -> Option<Mode> {
let raw = input.get(INPUT_FIELD).and_then(Value::as_str)?;
Some(classify_raw(raw))
}
fn classify_raw(raw: &str) -> Mode {
match raw.trim().strip_prefix('!') {
Some(rest) if !rest.starts_with('!') => Mode::Command,
Some(_) | None => Mode::Agent,
}
}
fn read(raw: &str, mode: Mode) -> Result<Spawn, String> {
let trimmed = raw.trim();
if mode == Mode::Agent {
if let Some(escaped) = trimmed.strip_prefix('!') {
return delegation(escaped);
}
if let Some(escaped) = trimmed.strip_prefix("@@") {
return delegation(&format!("@{escaped}"));
}
return match trimmed.strip_prefix('@') {
Some(addressed) => remote(addressed),
None => delegation(trimmed),
};
}
let rest = trimmed
.strip_prefix('!')
.expect("command classification requires a leading bang");
match rest.strip_prefix('@') {
Some(targeted) => target(targeted),
None => command(rest, None),
}
}
fn target(rest: &str) -> Result<Spawn, String> {
if rest.is_empty() || rest.starts_with(char::is_whitespace) {
return Err(
"`!@` names where a command runs and needs the target right after it: write \
`!@<target> <command>`, as `!@mac xcodebuild -list`"
.to_string(),
);
}
let (name, body) = rest.split_once(char::is_whitespace).unwrap_or((rest, ""));
if !is_target_name(name) {
return Err(format!(
"`{name}` is not a target name: a target is letters, digits, `_` or `-`, with `/` \
between parts, as `!@mac xcodebuild -list`"
));
}
if name == LOCAL_TARGET {
return Err(format!(
"`{LOCAL_TARGET}` is not a target name: a command with no `@` already runs where \
basis is running, as `!cargo test`"
));
}
if body.trim().is_empty() {
return Err(format!(
"`!@{name}` names a target but no command; write the command after it, as \
`!@{name} cargo test`"
));
}
command(body, Some(name.to_string()))
}
pub(crate) fn is_target_name(name: &str) -> bool {
!name.is_empty()
&& !name.starts_with('/')
&& !name.ends_with('/')
&& !name.contains("//")
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '/')
}
pub(crate) fn is_namespaced(name: &str) -> bool {
name.contains('/')
}
fn command(body: &str, target: Option<String>) -> Result<Spawn, String> {
let body = body.trim();
if body.is_empty() {
return Err(
"`!` on its own runs nothing; write the command after it, as `!cargo test`".to_string(),
);
}
Ok(Spawn {
mode: Mode::Command,
body: body.to_string(),
target,
reach: None,
metadata: None,
files: Vec::new(),
})
}
fn remote(rest: &str) -> Result<Spawn, String> {
if rest.is_empty() || rest.starts_with(char::is_whitespace) {
return Err(
"`@` names an agent and needs its name right after it: write \
`@<agent> <task>`, as `@reviewer look at this diff`, or `@@` to begin a task with \
a literal `@`"
.to_string(),
);
}
let (name, body) = rest.split_once(char::is_whitespace).unwrap_or((rest, ""));
if !is_target_name(name) {
return Err(format!(
"`{name}` is not an agent name: a name is letters, digits, `_` or `-`, with `/` \
between parts, as `@reviewer look at this diff` or `@team/reviewer …`; write `@@` \
to begin a task with a literal `@`"
));
}
if name == LOCAL_TARGET {
return Err(format!(
"`{LOCAL_TARGET}` is not an agent name: a task with no `@` already goes to a \
subagent here"
));
}
Ok(Spawn {
mode: Mode::Agent,
body: body.trim().to_string(),
target: Some(name.to_string()),
reach: None,
metadata: None,
files: Vec::new(),
})
}
fn delegation(body: &str) -> Result<Spawn, String> {
let body = body.trim();
if body.is_empty() {
return Err(
"spawn needs something to do: a command after `!`, or a task to delegate".to_string(),
);
}
Ok(Spawn {
mode: Mode::Agent,
body: body.to_string(),
target: None,
reach: None,
metadata: None,
files: Vec::new(),
})
}