mod env_spec;
pub use env_spec::{ActionSpec, EnvironmentSpec, EnvironmentSpecRegistry, ParamSpec};
use std::collections::HashMap;
use std::time::Duration;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum ActionCategory {
#[default]
NodeExpand,
NodeStateChange,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Action {
pub name: String,
pub params: ActionParams,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ActionParams {
pub target: Option<String>,
pub args: HashMap<String, String>,
pub data: Vec<u8>,
}
#[derive(Debug, Clone)]
pub enum ActionOutput {
Text(String),
Structured(serde_json::Value),
Binary(Vec<u8>),
}
impl ActionOutput {
pub fn as_text(&self) -> String {
match self {
Self::Text(s) => s.clone(),
Self::Structured(v) => v.to_string(),
Self::Binary(b) => format!("<binary: {} bytes>", b.len()),
}
}
pub fn as_structured(&self) -> Option<serde_json::Value> {
match self {
Self::Text(s) => serde_json::from_str(s).ok(),
Self::Structured(v) => Some(v.clone()),
Self::Binary(_) => None,
}
}
pub fn text(&self) -> Option<&str> {
match self {
Self::Text(s) => Some(s),
_ => None,
}
}
pub fn structured(&self) -> Option<&serde_json::Value> {
match self {
Self::Structured(v) => Some(v),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct ActionResult {
pub success: bool,
pub output: Option<ActionOutput>,
pub duration: Duration,
pub error: Option<String>,
pub discovered_targets: Vec<String>,
}
impl ActionResult {
pub fn success_text(output: impl Into<String>, duration: Duration) -> Self {
Self {
success: true,
output: Some(ActionOutput::Text(output.into())),
duration,
error: None,
discovered_targets: Vec::new(),
}
}
pub fn success_structured(output: serde_json::Value, duration: Duration) -> Self {
Self {
success: true,
output: Some(ActionOutput::Structured(output)),
duration,
error: None,
discovered_targets: Vec::new(),
}
}
pub fn success_binary(output: Vec<u8>, duration: Duration) -> Self {
Self {
success: true,
output: Some(ActionOutput::Binary(output)),
duration,
error: None,
discovered_targets: Vec::new(),
}
}
pub fn success(output: impl Into<String>, duration: Duration) -> Self {
Self::success_text(output, duration)
}
pub fn failure(error: String, duration: Duration) -> Self {
Self {
success: false,
output: None,
duration,
error: Some(error),
discovered_targets: Vec::new(),
}
}
pub fn with_discoveries(mut self, targets: Vec<String>) -> Self {
self.discovered_targets = targets;
self
}
}
#[derive(Debug, Clone)]
pub struct ParamDef {
pub name: String,
pub description: String,
pub required: bool,
}
impl ParamDef {
pub fn required(name: impl Into<String>, description: impl Into<String>) -> Self {
Self {
name: name.into(),
description: description.into(),
required: true,
}
}
pub fn optional(name: impl Into<String>, description: impl Into<String>) -> Self {
Self {
name: name.into(),
description: description.into(),
required: false,
}
}
}
#[derive(Debug, Clone)]
pub struct ParamVariants {
pub key: String,
pub values: Vec<String>,
}
impl ParamVariants {
pub fn new(key: impl Into<String>, values: Vec<String>) -> Self {
Self {
key: key.into(),
values,
}
}
}
#[derive(Debug, Clone)]
pub struct ActionDef {
pub name: String,
pub description: String,
pub category: ActionCategory,
pub groups: Vec<String>,
pub params: Vec<ParamDef>,
pub example: Option<String>,
pub param_variants: Option<ParamVariants>,
}
impl ActionDef {
pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
Self {
name: name.into(),
description: description.into(),
category: ActionCategory::default(),
groups: Vec::new(),
params: Vec::new(),
example: None,
param_variants: None,
}
}
pub fn param_variants(
mut self,
key: impl Into<String>,
values: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
self.param_variants = Some(ParamVariants::new(
key,
values.into_iter().map(|v| v.into()).collect(),
));
self
}
pub fn category(mut self, category: ActionCategory) -> Self {
self.category = category;
self
}
pub fn node_expand(mut self) -> Self {
self.category = ActionCategory::NodeExpand;
self
}
pub fn node_state_change(mut self) -> Self {
self.category = ActionCategory::NodeStateChange;
self
}
pub fn groups<I, S>(mut self, groups: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.groups = groups.into_iter().map(|s| s.into()).collect();
self
}
pub fn group(mut self, group: impl Into<String>) -> Self {
self.groups.push(group.into());
self
}
pub fn param(mut self, param: ParamDef) -> Self {
self.params.push(param);
self
}
pub fn required_param(self, name: impl Into<String>, description: impl Into<String>) -> Self {
self.param(ParamDef::required(name, description))
}
pub fn optional_param(self, name: impl Into<String>, description: impl Into<String>) -> Self {
self.param(ParamDef::optional(name, description))
}
pub fn example(mut self, example: impl Into<String>) -> Self {
self.example = Some(example.into());
self
}
pub fn has_group(&self, group: &str) -> bool {
self.groups.iter().any(|g| g == group)
}
pub fn has_any_group(&self, groups: &[&str]) -> bool {
groups.iter().any(|g| self.has_group(g))
}
}
#[derive(Debug, Clone, Default)]
pub struct ActionGroup {
pub name: String,
pub include_groups: Vec<String>,
pub exclude_groups: Vec<String>,
}
impl ActionGroup {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
include_groups: Vec::new(),
exclude_groups: Vec::new(),
}
}
pub fn include<I, S>(mut self, groups: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.include_groups = groups.into_iter().map(|s| s.into()).collect();
self
}
pub fn exclude<I, S>(mut self, groups: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.exclude_groups = groups.into_iter().map(|s| s.into()).collect();
self
}
pub fn matches(&self, action: &ActionDef) -> bool {
if self.exclude_groups.iter().any(|g| action.has_group(g)) {
return false;
}
if self.include_groups.is_empty() {
return true;
}
self.include_groups.iter().any(|g| action.has_group(g))
}
}
#[derive(Debug, Clone, Default)]
pub struct ActionsConfig {
actions: HashMap<String, ActionDef>,
groups: HashMap<String, ActionGroup>,
}
impl ActionsConfig {
pub fn new() -> Self {
Self::default()
}
pub fn action(mut self, name: impl Into<String>, def: ActionDef) -> Self {
let name = name.into();
let mut def = def;
def.name = name.clone();
self.actions.insert(name, def);
self
}
pub fn add_action(&mut self, name: impl Into<String>, def: ActionDef) {
let name = name.into();
let mut def = def;
def.name = name.clone();
self.actions.insert(name, def);
}
pub fn group(mut self, name: impl Into<String>, group: ActionGroup) -> Self {
let name = name.into();
let mut group = group;
group.name = name.clone();
self.groups.insert(name, group);
self
}
pub fn add_group(&mut self, name: impl Into<String>, group: ActionGroup) {
let name = name.into();
let mut group = group;
group.name = name.clone();
self.groups.insert(name, group);
}
pub fn all_action_names(&self) -> Vec<String> {
self.actions.keys().cloned().collect()
}
pub fn all_actions(&self) -> impl Iterator<Item = &ActionDef> {
self.actions.values()
}
pub fn get(&self, name: &str) -> Option<&ActionDef> {
self.actions.get(name)
}
pub fn get_group(&self, name: &str) -> Option<&ActionGroup> {
self.groups.get(name)
}
pub fn by_group(&self, group_name: &str) -> Vec<&ActionDef> {
if let Some(group) = self.groups.get(group_name) {
self.actions.values().filter(|a| group.matches(a)).collect()
} else {
self.actions
.values()
.filter(|a| a.has_group(group_name))
.collect()
}
}
pub fn candidates_for(&self, group_name: &str) -> Vec<String> {
self.by_group(group_name)
.into_iter()
.map(|a| a.name.clone())
.collect()
}
pub fn by_groups(&self, group_names: &[&str]) -> Vec<&ActionDef> {
self.actions
.values()
.filter(|a| group_names.iter().any(|g| a.has_group(g)))
.collect()
}
pub fn candidates_by_groups(&self, group_names: &[&str]) -> Vec<String> {
self.by_groups(group_names)
.into_iter()
.map(|a| a.name.clone())
.collect()
}
pub fn node_expand_actions(&self) -> Vec<String> {
self.actions
.values()
.filter(|a| a.category == ActionCategory::NodeExpand)
.map(|a| a.name.clone())
.collect()
}
pub fn node_state_change_actions(&self) -> Vec<String> {
self.actions
.values()
.filter(|a| a.category == ActionCategory::NodeStateChange)
.map(|a| a.name.clone())
.collect()
}
pub fn param_variants(&self, action_name: &str) -> Option<(&str, &[String])> {
self.actions
.get(action_name)
.and_then(|a| a.param_variants.as_ref())
.map(|pv| (pv.key.as_str(), pv.values.as_slice()))
}
pub fn build_action(
&self,
name: &str,
target: Option<String>,
args: HashMap<String, String>,
) -> Option<Action> {
self.actions.get(name).map(|_def| Action {
name: name.to_string(),
params: ActionParams {
target,
args,
data: Vec::new(),
},
})
}
pub fn build_action_unchecked(
&self,
name: impl Into<String>,
target: Option<String>,
args: HashMap<String, String>,
) -> Action {
Action {
name: name.into(),
params: ActionParams {
target,
args,
data: Vec::new(),
},
}
}
pub fn validate(&self, action: &Action) -> Result<(), ActionValidationError> {
let def = self
.actions
.get(&action.name)
.ok_or_else(|| ActionValidationError::UnknownAction(action.name.clone()))?;
for param in &def.params {
if param.required && !action.params.args.contains_key(¶m.name) {
return Err(ActionValidationError::MissingParam(param.name.clone()));
}
}
Ok(())
}
}
#[derive(Debug, Clone, thiserror::Error)]
pub enum ActionValidationError {
#[error("Unknown action: {0}")]
UnknownAction(String),
#[error("Missing required parameter: {0}")]
MissingParam(String),
#[error("Invalid parameter value: {0}")]
InvalidParam(String),
}
#[derive(Debug)]
pub struct ParamResolver<'a> {
action: &'a Action,
}
impl<'a> ParamResolver<'a> {
pub fn new(action: &'a Action) -> Self {
Self { action }
}
pub fn get(&self, key: &str) -> Option<&str> {
if let Some(value) = self.action.params.args.get(key) {
if !value.is_empty() {
return Some(value.as_str());
}
}
if let Some(ref target) = self.action.params.target {
if !target.is_empty() {
return Some(target.as_str());
}
}
None
}
pub fn require(&self, key: &str) -> Result<&str, ActionValidationError> {
self.get(key)
.ok_or_else(|| ActionValidationError::MissingParam(key.to_string()))
}
pub fn get_target_first(&self, key: &str) -> Option<&str> {
if let Some(ref target) = self.action.params.target {
if !target.is_empty() {
return Some(target.as_str());
}
}
if let Some(value) = self.action.params.args.get(key) {
if !value.is_empty() {
return Some(value.as_str());
}
}
None
}
pub fn require_target_first(&self, key: &str) -> Result<&str, ActionValidationError> {
self.get_target_first(key)
.ok_or_else(|| ActionValidationError::MissingParam(key.to_string()))
}
pub fn target(&self) -> Option<&str> {
self.action
.params
.target
.as_deref()
.filter(|s| !s.is_empty())
}
pub fn arg(&self, key: &str) -> Option<&str> {
self.action
.params
.args
.get(key)
.map(|s| s.as_str())
.filter(|s| !s.is_empty())
}
pub fn action_name(&self) -> &str {
&self.action.name
}
pub fn action(&self) -> &Action {
self.action
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_config() -> ActionsConfig {
ActionsConfig::new()
.action(
"read_file",
ActionDef::new("read_file", "ファイルを読み込む")
.groups(["file_ops", "exploration"])
.required_param("path", "ファイルパス"),
)
.action(
"grep",
ActionDef::new("grep", "パターン検索")
.groups(["search", "exploration"])
.required_param("pattern", "検索パターン"),
)
.action(
"write_file",
ActionDef::new("write_file", "ファイルを書き込む")
.groups(["file_ops", "mutation"])
.required_param("path", "ファイルパス")
.required_param("content", "内容"),
)
.action(
"escalate",
ActionDef::new("escalate", "Manager に報告").groups(["control"]),
)
.group(
"readonly",
ActionGroup::new("readonly")
.include(["exploration", "search"])
.exclude(["mutation"]),
)
.group(
"all",
ActionGroup::new("all"), )
}
#[test]
fn test_by_group_direct() {
let cfg = sample_config();
let file_ops = cfg.by_group("file_ops");
assert_eq!(file_ops.len(), 2);
let exploration = cfg.by_group("exploration");
assert_eq!(exploration.len(), 2);
}
#[test]
fn test_by_group_defined() {
let cfg = sample_config();
let readonly = cfg.candidates_for("readonly");
assert!(readonly.contains(&"read_file".to_string()));
assert!(readonly.contains(&"grep".to_string()));
assert!(!readonly.contains(&"write_file".to_string()));
let all = cfg.candidates_for("all");
assert_eq!(all.len(), 4);
}
#[test]
fn test_build_action() {
let cfg = sample_config();
let action = cfg.build_action(
"read_file",
Some("/path/to/file".to_string()),
HashMap::new(),
);
assert!(action.is_some());
assert_eq!(action.unwrap().name, "read_file");
let unknown = cfg.build_action("unknown", None, HashMap::new());
assert!(unknown.is_none());
}
#[test]
fn test_validate() {
let cfg = sample_config();
let action = Action {
name: "read_file".to_string(),
params: ActionParams {
target: None,
args: [("path".to_string(), "/tmp/test".to_string())]
.into_iter()
.collect(),
data: Vec::new(),
},
};
assert!(cfg.validate(&action).is_ok());
let action_missing = Action {
name: "read_file".to_string(),
params: ActionParams::default(),
};
assert!(matches!(
cfg.validate(&action_missing),
Err(ActionValidationError::MissingParam(_))
));
let unknown = Action {
name: "unknown".to_string(),
params: ActionParams::default(),
};
assert!(matches!(
cfg.validate(&unknown),
Err(ActionValidationError::UnknownAction(_))
));
}
fn make_action(name: &str, target: Option<&str>, args: Vec<(&str, &str)>) -> Action {
Action {
name: name.to_string(),
params: ActionParams {
target: target.map(|s| s.to_string()),
args: args
.into_iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
data: Vec::new(),
},
}
}
#[test]
fn test_param_resolver_get_from_args() {
let action = make_action("test", None, vec![("service", "user-service")]);
let resolver = ParamResolver::new(&action);
assert_eq!(resolver.get("service"), Some("user-service"));
assert_eq!(resolver.get("unknown"), None);
}
#[test]
fn test_param_resolver_get_fallback_to_target() {
let action = make_action("test", Some("user-service"), vec![]);
let resolver = ParamResolver::new(&action);
assert_eq!(resolver.get("service"), Some("user-service"));
}
#[test]
fn test_param_resolver_args_priority_over_target() {
let action = make_action(
"test",
Some("target-service"),
vec![("service", "args-service")],
);
let resolver = ParamResolver::new(&action);
assert_eq!(resolver.get("service"), Some("args-service"));
}
#[test]
fn test_param_resolver_empty_string_is_none() {
let action = make_action("test", Some(""), vec![("service", "")]);
let resolver = ParamResolver::new(&action);
assert_eq!(resolver.get("service"), None);
}
#[test]
fn test_param_resolver_empty_args_fallback_to_target() {
let action = make_action("test", Some("target-service"), vec![("service", "")]);
let resolver = ParamResolver::new(&action);
assert_eq!(resolver.get("service"), Some("target-service"));
}
#[test]
fn test_param_resolver_require_success() {
let action = make_action("test", Some("user-service"), vec![]);
let resolver = ParamResolver::new(&action);
assert_eq!(resolver.require("service").unwrap(), "user-service");
}
#[test]
fn test_param_resolver_require_failure() {
let action = make_action("test", None, vec![]);
let resolver = ParamResolver::new(&action);
let result = resolver.require("service");
assert!(matches!(
result,
Err(ActionValidationError::MissingParam(ref s)) if s == "service"
));
}
#[test]
fn test_param_resolver_get_target_first() {
let action = make_action(
"test",
Some("target-service"),
vec![("service", "args-service")],
);
let resolver = ParamResolver::new(&action);
assert_eq!(resolver.get_target_first("service"), Some("target-service"));
}
#[test]
fn test_param_resolver_get_target_first_fallback() {
let action = make_action("test", None, vec![("service", "args-service")]);
let resolver = ParamResolver::new(&action);
assert_eq!(resolver.get_target_first("service"), Some("args-service"));
}
#[test]
fn test_param_resolver_target_only() {
let action = make_action("test", Some("my-target"), vec![("service", "args-service")]);
let resolver = ParamResolver::new(&action);
assert_eq!(resolver.target(), Some("my-target"));
}
#[test]
fn test_param_resolver_arg_only() {
let action = make_action("test", Some("my-target"), vec![("service", "args-service")]);
let resolver = ParamResolver::new(&action);
assert_eq!(resolver.arg("service"), Some("args-service"));
assert_eq!(resolver.arg("unknown"), None);
}
}