use std::collections::BTreeMap;
use std::path::Path;
use crate::ontology::rdf::{iri_path, Graph, Term, ONT_BASE, RDF_TYPE};
pub const SCHEMA: &str = "apr-cli-surface/v1";
pub const SCHEMAS: [&str; 2] = [SCHEMA, "apr-cli-surface/v1.1"];
pub const ROLES: [&str; 8] = [
"model",
"prompt",
"input-file",
"backend",
"sampling",
"mode",
"other",
"unknown",
];
pub const UNSET: &str = "unset";
#[must_use]
pub fn cli(name: &str) -> String {
format!("{ONT_BASE}cli/{name}")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Arg {
pub id: String,
pub long: Option<String>,
pub short: Option<String>,
pub positional: bool,
pub required: bool,
pub value_type: String,
pub values: Vec<String>,
pub role: String,
pub marker: Option<String>,
pub hidden: bool,
pub conflicts_with: Vec<String>,
pub sampling_kind: Option<String>,
}
impl Arg {
#[must_use]
pub fn spelling(&self) -> String {
match (&self.long, &self.short, self.positional) {
(Some(l), _, _) => format!("--{l}"),
(None, Some(s), _) => format!("-{s}"),
_ => format!("<{}>", self.id),
}
}
#[must_use]
pub fn levels(&self) -> Vec<String> {
if self.role != "mode" {
return Vec::new();
}
let mut out = Vec::new();
if !self.required {
out.push(UNSET.to_string());
}
if self.value_type == "flag" || self.value_type == "count" {
out.push("true".to_string());
} else {
out.extend(self.values.iter().cloned());
}
out
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Command {
pub key: String,
pub path: Vec<String>,
pub leaf: bool,
pub hidden: bool,
pub foreign: bool,
pub generates: Option<bool>,
pub args: Vec<Arg>,
}
impl Command {
#[must_use]
pub fn takes_model(&self) -> bool {
self.args.iter().any(|a| a.role == "model")
}
#[must_use]
pub fn input_shapes(&self) -> Vec<&Arg> {
self.args
.iter()
.filter(|a| a.role == "prompt" || a.role == "input-file")
.collect()
}
pub fn mode_args<'a>(&'a self, globals: &'a [Arg]) -> impl Iterator<Item = &'a Arg> {
self.args
.iter()
.chain(globals.iter())
.filter(|a| a.role == "mode" && !a.levels().is_empty())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Surface {
pub file: String,
pub version: String,
pub git_sha: String,
pub global_args: Vec<Arg>,
pub commands: Vec<Command>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SurfaceError {
pub file: String,
pub what: String,
}
impl std::fmt::Display for SurfaceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.file, self.what)
}
}
impl std::error::Error for SurfaceError {}
pub fn read(path: &Path) -> Result<Surface, SurfaceError> {
let file = path.display().to_string();
let text = std::fs::read_to_string(path).map_err(|e| SurfaceError {
file: file.clone(),
what: format!("unreadable: {e}"),
})?;
parse(&file, &text)
}
fn str_of(v: &serde_json::Value, key: &str) -> Option<String> {
v.get(key)
.and_then(serde_json::Value::as_str)
.map(str::to_string)
}
fn bool_of(v: &serde_json::Value, key: &str) -> bool {
v.get(key)
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
}
fn strings(v: &serde_json::Value, key: &str) -> Vec<String> {
v.get(key)
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
.filter_map(serde_json::Value::as_str)
.map(str::to_string)
.collect()
}
pub fn parse(file: &str, text: &str) -> Result<Surface, SurfaceError> {
let err = |what: String| SurfaceError {
file: file.to_string(),
what,
};
let v: serde_json::Value =
serde_json::from_str(text).map_err(|e| err(format!("not JSON: {e}")))?;
let schema = v
.get("schema")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
if !SCHEMAS.contains(&schema) {
return Err(err(format!(
"schema {schema:?} is not {} — refused by name",
SCHEMAS.join(" or ")
)));
}
let args_of = |node: &serde_json::Value, key: &str, owner: &str| {
node.get(key)
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
.map(|a| parse_arg(a).map_err(|w| err(format!("{owner}: {w}"))))
.collect::<Result<Vec<Arg>, SurfaceError>>()
};
let global_args = args_of(&v, "global_args", "global_args")?;
let mut commands = Vec::new();
for c in v
.get("commands")
.and_then(serde_json::Value::as_array)
.into_iter()
.flatten()
{
let key = str_of(c, "key").unwrap_or_default();
commands.push(Command {
path: strings(c, "path"),
leaf: bool_of(c, "leaf"),
hidden: bool_of(c, "hidden"),
foreign: bool_of(c, "foreign"),
generates: c.get("generates").and_then(serde_json::Value::as_bool),
args: args_of(c, "args", &key)?,
key,
});
}
let binary = v.get("binary").cloned().unwrap_or_default();
Ok(Surface {
file: file.to_string(),
version: str_of(&binary, "version").unwrap_or_default(),
git_sha: str_of(&binary, "git_sha").unwrap_or_default(),
global_args,
commands,
})
}
fn parse_arg(a: &serde_json::Value) -> Result<Arg, String> {
let id = str_of(a, "id").unwrap_or_default();
let role = str_of(a, "role").unwrap_or_default();
if !ROLES.contains(&role.as_str()) {
return Err(format!(
"arg {id:?} has role {role:?}, which apr-cli-surface/v1 does not define — refused by name"
));
}
Ok(Arg {
long: str_of(a, "long"),
short: str_of(a, "short"),
positional: bool_of(a, "positional"),
required: bool_of(a, "required"),
value_type: str_of(a, "value_type").unwrap_or_default(),
values: strings(a, "values"),
role,
marker: str_of(a, "marker"),
hidden: bool_of(a, "hidden"),
conflicts_with: strings(a, "conflicts_with"),
sampling_kind: str_of(a, "sampling_kind"),
id,
})
}
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
pub struct SurfaceStats {
pub commands: usize,
pub leaves: usize,
pub model_commands: usize,
pub generating_commands: usize,
pub generates_declared: bool,
pub by_role: BTreeMap<String, usize>,
pub unknown_args: usize,
pub unknown_outside_foreign: usize,
pub stdin_undeclared: usize,
}
fn path_of(c: &Command) -> Vec<&str> {
c.path.iter().map(String::as_str).collect()
}
pub fn emit(g: &mut Graph, s: &Surface) -> SurfaceStats {
let mut st = SurfaceStats {
generates_declared: true,
..SurfaceStats::default()
};
for a in &s.global_args {
emit_arg(g, &["-"], a);
}
for c in &s.commands {
emit_command(g, c, &mut st);
}
st
}
fn emit_command(g: &mut Graph, c: &Command, st: &mut SurfaceStats) {
st.commands += 1;
let path = path_of(c);
let n = iri_path("cli-command", &path);
g.insert(n.clone(), RDF_TYPE, Term::iri(cli("Command")));
g.insert(n.clone(), cli("key"), Term::string(&c.key));
g.insert(n.clone(), cli("leaf"), Term::boolean(c.leaf));
g.insert(n.clone(), cli("hidden"), Term::boolean(c.hidden));
g.insert(n.clone(), cli("foreign"), Term::boolean(c.foreign));
if let Some(gen) = c.generates {
g.insert(n.clone(), cli("generates"), Term::boolean(gen));
}
if c.leaf {
st.leaves += 1;
}
if c.takes_model() {
st.model_commands += 1;
g.insert(n.clone(), RDF_TYPE, Term::iri(cli("ModelCommand")));
match c.generates {
Some(true) => st.generating_commands += 1,
Some(false) => {}
None => st.generates_declared = false,
}
}
for a in &c.args {
*st.by_role.entry(a.role.clone()).or_default() += 1;
if a.role == "unknown" {
st.unknown_args += 1;
if !c.foreign {
st.unknown_outside_foreign += 1;
}
}
let stdin_capable = matches!(a.role.as_str(), "model" | "input-file" | "prompt");
if a.positional && stdin_capable {
st.stdin_undeclared += 1;
}
let an = emit_arg(g, &path, a);
g.insert(n.clone(), cli("arg"), Term::iri(an));
}
}
fn emit_arg(g: &mut Graph, path: &[&str], a: &Arg) -> String {
let mut segs: Vec<&str> = path.to_vec();
segs.push(&a.id);
let n = iri_path("cli-arg", &segs);
g.insert(n.clone(), RDF_TYPE, Term::iri(cli("Arg")));
g.insert(n.clone(), cli("id"), Term::string(&a.id));
g.insert(n.clone(), cli("spelling"), Term::string(a.spelling()));
g.insert(n.clone(), cli("role"), Term::string(&a.role));
g.insert(n.clone(), cli("valueType"), Term::string(&a.value_type));
g.insert(n.clone(), cli("positional"), Term::boolean(a.positional));
g.insert(n.clone(), cli("required"), Term::boolean(a.required));
if let Some(m) = &a.marker {
g.insert(n.clone(), cli("marker"), Term::string(m));
}
for c in &a.conflicts_with {
g.insert(n.clone(), cli("conflictsWith"), Term::string(c));
}
for level in a.levels() {
let mut s2 = segs.clone();
s2.push(&level);
let m = iri_path("cli-mode", &s2);
g.insert(m.clone(), RDF_TYPE, Term::iri(cli("ModeValue")));
g.insert(m.clone(), cli("level"), Term::string(&level));
g.insert(n.clone(), cli("modeValue"), Term::iri(m));
}
if a.role == "prompt" || a.role == "input-file" {
let s = iri_path("cli-shape", &segs);
g.insert(s.clone(), RDF_TYPE, Term::iri(cli("InputShape")));
g.insert(s.clone(), cli("spelling"), Term::string(a.spelling()));
g.insert(n.clone(), cli("inputShape"), Term::iri(s));
}
n
}
#[must_use]
pub fn positive_control() -> bool {
let typed = |role: &str| {
let text = PC_SURFACE.replace("__ROLE__", role);
let Ok(s) = parse("__pc_surface__.json", &text) else {
return None;
};
let mut g = Graph::new();
let st = emit(&mut g, &s);
let run = iri_path("cli-command", &["run"]);
let is_model = g
.objects(&run, RDF_TYPE)
.iter()
.any(|t| t.as_iri() == Some(cli("ModelCommand").as_str()));
Some((is_model, st.model_commands))
};
matches!(
(typed("model"), typed("other")),
(Some((true, 1)), Some((false, 0)))
)
}
const PC_SURFACE: &str = r#"{"schema":"apr-cli-surface/v1","binary":{"name":"apr","version":"0.0.0-pc","git_sha":"pc"},
"global_args":[{"id":"json","long":"json","value_type":"flag","values":["true","false"],"role":"mode"}],
"commands":[
{"path":["run"],"key":"run","leaf":true,"hidden":false,"foreign":false,"generates":true,"args":[
{"id":"model","positional":true,"required":true,"value_type":"path","role":"__ROLE__","marker":"ModelRef"},
{"id":"prompt","long":"prompt","value_type":"text","role":"prompt","marker":"PromptText"},
{"id":"chat","long":"chat","value_type":"flag","values":["true","false"],"role":"mode"}]},
{"path":["list"],"key":"list","leaf":true,"hidden":false,"foreign":false,"args":[]}]}"#;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_control_fires_and_a_foreign_schema_or_role_is_refused_by_name() {
assert!(positive_control());
let e = parse("x.json", r#"{"schema":"apr-cli-surface/v9"}"#).expect_err("refused");
assert!(e.what.contains("v9") && e.what.contains(SCHEMA), "{e}");
let bad = PC_SURFACE.replace("__ROLE__", "modelish");
let e = parse("x.json", &bad).expect_err("unknown role refused");
assert!(e.what.contains("modelish"), "{e}");
}
#[test]
fn levels_start_at_unset_and_the_ratchets_count_what_v1_cannot_say() {
let s = parse("pc.json", &PC_SURFACE.replace("__ROLE__", "model")).expect("parses");
let run = &s.commands[0];
let modes: Vec<(String, Vec<String>)> = run
.mode_args(&s.global_args)
.map(|a| (a.spelling(), a.levels()))
.collect();
assert_eq!(
modes,
vec![
("--chat".to_string(), vec!["unset".into(), "true".into()]),
("--json".to_string(), vec!["unset".into(), "true".into()]),
],
"the command's own mode args, then the propagated globals"
);
let mut g = Graph::new();
let st = emit(&mut g, &s);
assert_eq!(st.model_commands, 1);
assert_eq!(st.generating_commands, 1);
assert!(st.generates_declared);
assert_eq!(st.stdin_undeclared, 1, "the model positional");
let no_gen = PC_SURFACE
.replace("__ROLE__", "model")
.replace(r#""generates":true,"#, "");
let st2 = emit(
&mut Graph::new(),
&parse("pc.json", &no_gen).expect("parses"),
);
assert!(
!st2.generates_declared,
"a model command that does not say is named"
);
}
const COUNT_SURFACE: &str = r#"{"schema":"apr-cli-surface/v1","binary":{},
"global_args":[],
"commands":[
{"path":["a"],"key":"a","leaf":true,"hidden":false,"foreign":false,"args":[
{"id":"u1","long":"u1","value_type":"text","role":"unknown"},
{"id":"p","long":"p","value_type":"text","role":"prompt"},
{"id":"f","long":"f","value_type":"path","role":"input-file"},
{"id":"o","long":"o","value_type":"text","role":"other"},
{"id":"m","positional":true,"value_type":"path","role":"model"}]},
{"path":["b"],"key":"b","leaf":false,"hidden":false,"foreign":true,"args":[
{"id":"u2","long":"u2","value_type":"text","role":"unknown"},
{"id":"u3","long":"u3","value_type":"text","role":"unknown"},
{"id":"u5","long":"u5","value_type":"text","role":"unknown"}]},
{"path":["c"],"key":"c","leaf":true,"hidden":false,"foreign":false,"args":[
{"id":"u4","long":"u4","value_type":"text","role":"unknown"}]}]}"#;
#[test]
fn emit_counts_commands_leaves_roles_and_unknowns_exactly() {
let s = parse("c.json", COUNT_SURFACE).expect("parses");
let mut g = Graph::new();
let st = emit(&mut g, &s);
assert_eq!(st.commands, 3);
assert_eq!(st.leaves, 2);
assert_eq!(st.model_commands, 1);
assert_eq!(st.by_role.get("unknown"), Some(&5));
assert_eq!(st.by_role.get("prompt"), Some(&1));
assert_eq!(st.by_role.get("model"), Some(&1));
assert_eq!(st.unknown_args, 5);
assert_eq!(st.unknown_outside_foreign, 2, "a and c are not foreign");
}
#[test]
fn emit_arg_returns_its_node_and_types_only_prompt_and_input_file_as_shapes() {
let s = parse("c.json", COUNT_SURFACE).expect("parses");
let mut g = Graph::new();
let _ = emit(&mut g, &s);
let shape_of = |g: &Graph, id: &str| {
g.objects(&iri_path("cli-arg", &["a", id]), &cli("inputShape"))
.len()
};
assert_eq!(shape_of(&g, "p"), 1);
assert_eq!(shape_of(&g, "f"), 1);
assert_eq!(shape_of(&g, "o"), 0);
assert_eq!(shape_of(&g, "m"), 0);
let arg = &s.commands[0].args[1];
let mut g2 = Graph::new();
let n = emit_arg(&mut g2, &["a"], arg);
assert_eq!(n, iri_path("cli-arg", &["a", "p"]));
assert!(!g2.is_empty());
}
#[test]
fn mode_args_drops_a_mode_arg_with_no_levels_and_display_names_file_and_what() {
let text = r#"{"schema":"apr-cli-surface/v1","binary":{},"global_args":[],
"commands":[{"path":["x"],"key":"x","leaf":true,"hidden":false,"foreign":false,"args":[
{"id":"empty","long":"empty","required":true,"value_type":"enum","values":[],"role":"mode"},
{"id":"ok","long":"ok","value_type":"flag","role":"mode"}]}]}"#;
let s = parse("m.json", text).expect("parses");
let ids: Vec<&str> = s.commands[0]
.mode_args(&s.global_args)
.map(|a| a.id.as_str())
.collect();
assert_eq!(ids, vec!["ok"]);
let e = SurfaceError {
file: "f.json".into(),
what: "broken".into(),
};
assert_eq!(e.to_string(), "f.json: broken");
}
}