use serde::Deserialize;
use crate::routine::{RoutinePayload, RoutineProvenance, RoutineSchedule};
use crate::routine_reconcile::validate_spec;
use crate::{RoutineScope, RoutineSpec};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum GrantMode {
#[default]
Individual,
Auto,
ApproveAllDangerous,
}
pub const ARG_APPROVAL_MODE: &str = "approval_mode";
pub const APPROVAL_MODE_INDIVIDUAL: &str = "individual";
pub const APPROVAL_MODE_AUTO: &str = "auto";
pub const APPROVAL_MODE_APPROVE_ALL_DANGEROUS: &str = "approve-all-dangerous";
pub fn parse_grant_mode(args_json: &str) -> Result<GrantMode, String> {
let Ok(serde_json::Value::Object(map)) = serde_json::from_str(args_json) else {
return Ok(GrantMode::default());
};
let Some(value) = map.get(ARG_APPROVAL_MODE) else {
return Ok(GrantMode::default());
};
let Some(mode) = value.as_str() else {
return Err(
"the approval mode has to be one of \"individual\", \"auto\", or \
\"approve-all-dangerous\""
.to_owned(),
);
};
match mode {
APPROVAL_MODE_INDIVIDUAL => Ok(GrantMode::Individual),
APPROVAL_MODE_AUTO => Ok(GrantMode::Auto),
APPROVAL_MODE_APPROVE_ALL_DANGEROUS => Ok(GrantMode::ApproveAllDangerous),
other => Err(format!(
"\"{other}\" isn't a routine approval mode — use \"individual\" to approve each \
tool the routine wants the first time, \"auto\" to pre-approve its ordinary \
tools, or \"approve-all-dangerous\" to also pre-approve its high-risk tools."
)),
}
}
#[derive(Debug, Deserialize)]
struct RoutineCreateArgs {
schedule: RoutineSchedule,
prompt: String,
#[serde(default)]
scope: Option<RoutineScope>,
}
pub fn compile_spec(args_json: &str, provenance: RoutineProvenance) -> Result<RoutineSpec, String> {
reject_unknown_scope(args_json)?;
let args: RoutineCreateArgs = serde_json::from_str(args_json)
.map_err(|e| format!("couldn't read the schedule and prompt: {e}"))?;
let spec = RoutineSpec {
description: None,
scope: args.scope.unwrap_or_default(),
schedule: args.schedule,
payload: RoutinePayload {
prompt: args.prompt,
},
provenance,
suspend: None,
};
validate_spec(&spec)?;
Ok(spec)
}
fn reject_unknown_scope(args_json: &str) -> Result<(), String> {
let Ok(serde_json::Value::Object(map)) = serde_json::from_str(args_json) else {
return Ok(());
};
let Some(serde_json::Value::String(scope)) = map.get("scope") else {
return Ok(());
};
if scope == "public" || scope == "private" {
return Ok(());
}
Err(format!(
"\"{scope}\" isn't a routine sharing choice — use \"private\" so only the \
person who created the routine can see and copy it, or \"public\" so \
other members of this instance can see and copy its definition."
))
}
#[cfg(test)]
mod tests {
#![allow(clippy::pedantic, clippy::nursery, missing_docs)]
use super::*;
fn provenance() -> RoutineProvenance {
RoutineProvenance {
creator_persona: "persona-1".to_owned(),
conversation_id: "conv-1".to_owned(),
}
}
#[test]
fn cron_args_compile_to_a_private_scoped_spec_by_default() {
let args =
r#"{"schedule":{"cron":{"expression":"0 9 * * 1-5"}},"prompt":"Post the standup."}"#;
let spec = compile_spec(args, provenance()).expect("valid cron spec compiles");
assert_eq!(
spec.scope,
RoutineScope::Private,
"create-by-chat defaults to private when the admin names no sharing intent"
);
assert_eq!(spec.suspend, None);
assert_eq!(spec.payload.prompt, "Post the standup.");
assert!(matches!(spec.schedule, RoutineSchedule::Cron { .. }));
assert_eq!(spec.provenance, provenance());
}
#[test]
fn cron_args_naming_public_scope_compile_to_a_public_spec() {
let args = r#"{"schedule":{"cron":{"expression":"0 9 * * 1-5"}},"prompt":"Post the standup.","scope":"public"}"#;
let spec = compile_spec(args, provenance()).expect("valid cron spec compiles");
assert_eq!(spec.scope, RoutineScope::Public);
}
#[test]
fn cron_args_naming_private_scope_compile_to_a_private_spec() {
let args = r#"{"schedule":{"cron":{"expression":"0 9 * * 1-5"}},"prompt":"Post the standup.","scope":"private"}"#;
let spec = compile_spec(args, provenance()).expect("valid cron spec compiles");
assert_eq!(spec.scope, RoutineScope::Private);
}
#[test]
fn once_args_compile_to_a_once_schedule() {
let args =
r#"{"schedule":{"once":{"at":"2026-08-01T15:00:00Z"}},"prompt":"Remind them once."}"#;
let spec = compile_spec(args, provenance()).expect("valid once spec compiles");
assert_eq!(
spec.schedule,
RoutineSchedule::Once {
at: "2026-08-01T15:00:00Z".to_owned()
}
);
}
#[test]
fn malformed_args_json_is_refused() {
let err = compile_spec("not json", provenance()).expect_err("must refuse");
assert!(err.contains("schedule and prompt"), "{err}");
}
#[test]
fn retired_scope_values_are_refused_with_a_clear_message() {
for retired in ["instance", "persona", "shared"] {
let args = format!(
r#"{{"schedule":{{"cron":{{"expression":"0 9 * * 1-5"}}}},"prompt":"Post the standup.","scope":"{retired}"}}"#
);
let err = compile_spec(&args, provenance()).expect_err("retired scope must be refused");
assert!(err.contains(retired), "{err}");
assert!(err.contains("private"), "{err}");
assert!(err.contains("public"), "{err}");
assert!(!err.contains("unknown variant"), "{err}");
}
}
#[test]
fn an_arbitrary_unknown_scope_value_is_refused_with_a_clear_message() {
let args = r#"{"schedule":{"cron":{"expression":"0 9 * * 1-5"}},"prompt":"Post the standup.","scope":"everyone"}"#;
let err = compile_spec(args, provenance()).expect_err("unknown scope must be refused");
assert!(err.contains("everyone"), "{err}");
assert!(err.contains("private"), "{err}");
assert!(err.contains("public"), "{err}");
}
#[test]
fn absent_approval_mode_defaults_to_individual() {
let args =
r#"{"schedule":{"cron":{"expression":"0 9 * * 1-5"}},"prompt":"Post the standup."}"#;
assert_eq!(parse_grant_mode(args).unwrap(), GrantMode::Individual);
}
#[test]
fn recognized_approval_mode_values_parse() {
for (value, expected) in [
(APPROVAL_MODE_INDIVIDUAL, GrantMode::Individual),
(APPROVAL_MODE_AUTO, GrantMode::Auto),
(
APPROVAL_MODE_APPROVE_ALL_DANGEROUS,
GrantMode::ApproveAllDangerous,
),
] {
let args = format!(
r#"{{"schedule":{{"cron":{{"expression":"0 9 * * 1-5"}}}},"prompt":"Post the standup.","approval_mode":"{value}"}}"#
);
assert_eq!(parse_grant_mode(&args).unwrap(), expected, "{value}");
}
}
#[test]
fn unrecognized_approval_mode_is_refused_with_a_clear_message() {
let args = r#"{"schedule":{"cron":{"expression":"0 9 * * 1-5"}},"prompt":"Post the standup.","approval_mode":"yolo"}"#;
let err = parse_grant_mode(args).expect_err("must refuse");
assert!(err.contains("yolo"), "{err}");
assert!(err.contains("individual"), "{err}");
assert!(err.contains("auto"), "{err}");
assert!(err.contains("approve-all-dangerous"), "{err}");
}
#[test]
fn missing_prompt_is_refused() {
let args = r#"{"schedule":{"cron":{"expression":"0 9 * * *"}}}"#;
compile_spec(args, provenance()).expect_err("prompt is required");
}
#[test]
fn a_spec_validate_spec_would_reject_is_refused_before_any_card() {
let bad_cron = r#"{"schedule":{"cron":{"expression":"not a cron"}},"prompt":"hi"}"#;
let err = compile_spec(bad_cron, provenance()).expect_err("bad cron must be refused");
assert!(err.contains("cron expression"), "{err}");
let empty_prompt = r#"{"schedule":{"cron":{"expression":"0 9 * * *"}},"prompt":" "}"#;
let err = compile_spec(empty_prompt, provenance()).expect_err("blank prompt refused");
assert!(err.contains("payload.prompt"), "{err}");
}
}