use std::collections::BTreeSet;
use dekopon_core::CapabilityId;
use serde_json::Value;
use crate::{
CapabilityInvoker,
builtins::{self, BuiltinKind, CommandFailure},
parser::REJECTED_COMMANDS,
value::{object_from_pairs, scalar_from_token},
};
pub(crate) enum Resolution {
Function,
Builtin(BuiltinKind),
Capability,
Rejected(&'static str),
NotFound,
}
pub(crate) fn resolve(
word: &str,
functions: &BTreeSet<String>,
invoker: &dyn CapabilityInvoker,
) -> Resolution {
if let Some((_, reason)) = REJECTED_COMMANDS
.iter()
.find(|(rejected, _)| *rejected == word)
{
return Resolution::Rejected(reason);
}
if functions.contains(word) {
return Resolution::Function;
}
if let Some(builtin) = builtins::lookup(word) {
return Resolution::Builtin(builtin);
}
if looks_like_capability(word) && invoker.is_granted(word) {
return Resolution::Capability;
}
Resolution::NotFound
}
#[must_use]
pub(crate) fn looks_like_capability(word: &str) -> bool {
word.contains(['.', '-', '_']) && word.parse::<CapabilityId>().is_ok()
}
pub(crate) fn arguments_to_input(
command: &str,
arguments: &[String],
) -> Result<Value, CommandFailure> {
if arguments.is_empty() {
return Ok(Value::Object(serde_json::Map::new()));
}
if let [single] = arguments {
if single.trim_start().starts_with('{') {
let parsed = serde_json::from_str::<Value>(single).map_err(|error| {
CommandFailure::usage(format!("{command}: input is not valid JSON: {error}"))
})?;
if !parsed.is_object() {
return Err(CommandFailure::usage(format!(
"{command}: capability input must be a JSON object"
)));
}
return Ok(parsed);
}
}
let mut pairs = Vec::new();
let mut index = 0;
while index < arguments.len() {
let argument = arguments[index].as_str();
let Some(flag) = argument.strip_prefix("--") else {
return Err(CommandFailure::usage(format!(
"{command}: unexpected argument {argument:?}; pass capability input as --kebab-case flags or one JSON object"
)));
};
if flag.is_empty() {
return Err(CommandFailure::usage(format!(
"{command}: `--` is not a capability input flag"
)));
}
let key = to_camel_case(flag);
match arguments.get(index + 1) {
None => {
pairs.push((key, Value::Bool(true)));
index += 1;
}
Some(next) if next.starts_with("--") => {
pairs.push((key, Value::Bool(true)));
index += 1;
}
Some(next) => {
pairs.push((key, scalar_from_token(next)));
index += 2;
}
}
}
Ok(object_from_pairs(pairs))
}
fn to_camel_case(flag: &str) -> String {
let mut output = String::with_capacity(flag.len());
let mut capitalize = false;
for character in flag.chars() {
if character == '-' || character == '_' {
capitalize = true;
continue;
}
if capitalize {
output.extend(character.to_uppercase());
capitalize = false;
} else {
output.push(character);
}
}
output
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use serde_json::{Value, json};
use crate::{CapabilityCallResult, CapabilityInvoker, builtins};
use super::{Resolution, arguments_to_input, looks_like_capability, resolve, to_camel_case};
struct Granted;
impl CapabilityInvoker for Granted {
fn granted(&self) -> Vec<String> {
vec![
"echo.echo".to_owned(),
"http-probe.fetch".to_owned(),
"with_underscore".to_owned(),
]
}
fn invoke(&self, _capability: &str, input: Value) -> CapabilityCallResult {
CapabilityCallResult::Succeeded(input)
}
}
fn arguments(items: &[&str]) -> Vec<String> {
items.iter().map(|item| (*item).to_owned()).collect()
}
#[test]
fn resolution_follows_the_documented_priority_order() {
let mut functions = BTreeSet::new();
functions.insert("greet".to_owned());
assert!(matches!(
resolve("greet", &functions, &Granted),
Resolution::Function
));
assert!(matches!(
resolve("jq", &functions, &Granted),
Resolution::Builtin(_)
));
assert!(matches!(
resolve("echo.echo", &functions, &Granted),
Resolution::Capability
));
assert!(matches!(
resolve("eval", &functions, &Granted),
Resolution::Rejected(_)
));
assert!(matches!(
resolve("nothing.here", &functions, &Granted),
Resolution::NotFound
));
assert!(matches!(
resolve("unknown", &functions, &Granted),
Resolution::NotFound
));
}
#[test]
fn a_function_takes_priority_over_a_granted_capability() {
let mut functions = BTreeSet::new();
functions.insert("echo.echo".to_owned());
assert!(matches!(
resolve("echo.echo", &functions, &Granted),
Resolution::Function
));
}
#[test]
fn a_function_shadows_a_builtin_only_by_being_declared() {
let mut functions = BTreeSet::new();
assert!(matches!(
resolve("jq", &functions, &Granted),
Resolution::Builtin(_)
));
functions.insert("jq".to_owned());
assert!(matches!(
resolve("jq", &functions, &Granted),
Resolution::Function
));
}
#[test]
fn builtin_and_capability_namespaces_are_disjoint() {
for name in builtins::names() {
assert!(
!looks_like_capability(name),
"builtin {name:?} is reachable through capability fallback"
);
}
assert!(looks_like_capability("echo.echo"));
assert!(looks_like_capability("http-probe.fetch"));
assert!(looks_like_capability("with_underscore"));
assert!(!looks_like_capability("echo"));
assert!(!looks_like_capability("Echo.Echo"));
assert!(!looks_like_capability("bad..id"));
}
#[test]
fn kebab_flags_become_camel_case_keys() {
assert_eq!(to_camel_case("post-id"), "postId");
assert_eq!(to_camel_case("include-body"), "includeBody");
assert_eq!(to_camel_case("id"), "id");
assert_eq!(to_camel_case("a-b-c"), "aBC");
}
#[test]
fn argv_converts_to_the_camel_case_input_object() {
assert_eq!(
arguments_to_input("cap", &arguments(&["--post-id", "7", "--include-body"]))
.expect("valid argv"),
json!({"postId": 7, "includeBody": true})
);
assert_eq!(
arguments_to_input("cap", &arguments(&["--message", "hello"])).expect("valid argv"),
json!({"message": "hello"})
);
assert_eq!(
arguments_to_input("cap", &arguments(&[])).expect("valid argv"),
json!({})
);
}
#[test]
fn a_single_json_argument_bypasses_flag_conversion() {
assert_eq!(
arguments_to_input("cap", &arguments(&[r#"{"postId": 7}"#])).expect("valid argv"),
json!({"postId": 7})
);
assert!(arguments_to_input("cap", &arguments(&["{not json}"])).is_err());
assert!(arguments_to_input("cap", &arguments(&["{\"a\": 1}", "extra"])).is_err());
}
#[test]
fn repeated_flags_fold_into_arrays() {
assert_eq!(
arguments_to_input("cap", &arguments(&["--tag", "a", "--tag", "b"]))
.expect("valid argv"),
json!({"tag": ["a", "b"]})
);
}
#[test]
fn positional_arguments_are_rejected_with_guidance() {
let failure =
arguments_to_input("cap", &arguments(&["bare"])).expect_err("bare words are rejected");
assert!(format!("{failure:?}").contains("kebab-case"), "{failure:?}");
}
}