use apcore::module::ModuleAnnotations;
use apcore_toolkit::ScannedModule;
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use crate::models::ScannedCLITool;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Risk {
Readonly,
Write,
Destructive,
OpenWorld,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommandContract {
pub id: String,
pub command: String,
pub description: String,
pub risk: Risk,
pub input_schema: JsonValue,
pub output_schema: JsonValue,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScanReport {
pub tools: Vec<ScannedCLITool>,
pub commands: Vec<CommandContract>,
}
impl ScanReport {
pub fn new(tools: Vec<ScannedCLITool>, modules: &[ScannedModule]) -> Self {
let commands = modules.iter().map(CommandContract::from_module).collect();
Self { tools, commands }
}
}
impl CommandContract {
fn from_module(module: &ScannedModule) -> Self {
Self {
id: module.module_id.clone(),
command: command_from_module_id(&module.module_id),
description: module.description.clone(),
risk: risk_from_annotations(module.annotations.as_ref()),
input_schema: module.input_schema.clone(),
output_schema: module.output_schema.clone(),
}
}
}
fn command_from_module_id(module_id: &str) -> String {
module_id
.split_once('.')
.map_or(module_id, |(_namespace, rest)| rest)
.replace('.', " ")
}
fn risk_from_annotations(annotations: Option<&ModuleAnnotations>) -> Risk {
match annotations {
Some(annotations) if annotations.destructive => Risk::Destructive,
Some(annotations) if annotations.readonly => Risk::Readonly,
_ => Risk::Write,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn module(module_id: &str, annotations: Option<ModuleAnnotations>) -> ScannedModule {
let mut module = ScannedModule::new(
module_id.to_string(),
"A description".to_string(),
json!({"type": "object"}),
json!({"type": "object"}),
vec![],
format!("exec:///usr/bin/{module_id}"),
);
module.annotations = annotations;
module
}
fn annotations(readonly: bool, destructive: bool) -> ModuleAnnotations {
ModuleAnnotations {
readonly,
destructive,
..Default::default()
}
}
#[test]
fn test_command_from_module_id_strips_namespace() {
assert_eq!(command_from_module_id("cli.git.commit"), "git commit");
}
#[test]
fn test_command_from_module_id_root_command() {
assert_eq!(command_from_module_id("cli.ls"), "ls");
}
#[test]
fn test_risk_readonly() {
assert_eq!(
risk_from_annotations(Some(&annotations(true, false))),
Risk::Readonly
);
}
#[test]
fn test_risk_destructive_wins_over_readonly() {
assert_eq!(
risk_from_annotations(Some(&annotations(true, true))),
Risk::Destructive
);
}
#[test]
fn test_risk_defaults_to_write() {
assert_eq!(risk_from_annotations(None), Risk::Write);
assert_eq!(
risk_from_annotations(Some(&annotations(false, false))),
Risk::Write
);
}
#[test]
fn test_risk_serializes_as_kebab_case() {
assert_eq!(
serde_json::to_string(&Risk::OpenWorld).unwrap(),
"\"open-world\""
);
assert_eq!(
serde_json::to_string(&Risk::Readonly).unwrap(),
"\"readonly\""
);
}
#[test]
fn test_scan_report_flattens_modules_into_commands() {
let modules = vec![
module("cli.git.commit", Some(annotations(false, false))),
module("cli.git.log", Some(annotations(true, false))),
];
let report = ScanReport::new(vec![], &modules);
assert_eq!(report.commands.len(), 2);
assert_eq!(report.commands[0].command, "git commit");
assert_eq!(report.commands[0].risk, Risk::Write);
assert_eq!(report.commands[1].command, "git log");
assert_eq!(report.commands[1].risk, Risk::Readonly);
}
#[test]
fn test_scan_report_round_trips_through_json() {
let modules = vec![module("cli.ls", Some(annotations(true, false)))];
let report = ScanReport::new(vec![], &modules);
let encoded = serde_json::to_string(&report).unwrap();
let decoded: ScanReport = serde_json::from_str(&encoded).unwrap();
assert_eq!(decoded.commands.len(), 1);
assert_eq!(decoded.commands[0].id, "cli.ls");
}
}