rustev_surface/
explain.rs1use std::collections::BTreeMap;
3
4use rustev_contract::{
5 Document,
6 explanation::{Query, RefusalCode},
7 redaction::RedactionPolicy,
8 scope::Scope,
9 time::Timestamp,
10};
11use rustev_eval::{
12 explain::{self, Evidence, ExplainError, ExplainOptions},
13 resolve::{NoExternal, Resolver},
14};
15use serde::Deserialize;
16use serde_json::Value;
17
18#[derive(Deserialize)]
20#[serde(untagged)]
21enum Input {
22 Record(Record),
23 Bundle(Bundle),
24}
25#[derive(Deserialize)]
26#[serde(deny_unknown_fields)]
27struct Record {
28 record: String,
29 plan: Option<String>,
30}
31#[derive(Deserialize)]
32#[serde(deny_unknown_fields)]
33struct Bundle {
34 bundle: String,
35}
36#[derive(Deserialize)]
37#[serde(deny_unknown_fields)]
38struct Diff {
39 other: Input,
40}
41#[derive(Deserialize)]
42#[serde(deny_unknown_fields)]
43struct DiffQuery {
44 diff: Diff,
45}
46
47pub struct Explained {
48 pub explanation: Vec<u8>,
49 pub markdown: String,
50}
51fn error(code: RefusalCode, subject: &str, detail: impl ToString) -> ExplainError {
52 ExplainError {
53 code,
54 subject: subject.into(),
55 detail: detail.to_string(),
56 }
57}
58fn invalid(detail: impl ToString) -> ExplainError {
59 error(RefusalCode::InvalidOptions, "options", detail)
60}
61impl Input {
62 fn bundle(&self) -> bool {
63 matches!(self, Self::Bundle(_))
64 }
65 fn evidence<'a>(
66 &self,
67 files: &'a BTreeMap<String, Vec<u8>>,
68 config: Option<&'a (Scope, Timestamp)>,
69 resolver: &'a dyn Resolver,
70 ) -> Result<Evidence<'a>, ExplainError> {
71 let read = |name: &str| {
72 files
73 .get(name)
74 .map(Vec::as_slice)
75 .ok_or_else(|| error(RefusalCode::Parse, name, "missing byte input"))
76 };
77 Ok(match self {
78 Self::Record(r) => Evidence::Record {
79 record: read(&r.record)?,
80 plan: r.plan.as_deref().map(read).transpose()?,
81 },
82 Self::Bundle(b) => {
83 let (scope, now_ms) = config.expect("bundle options validated");
84 Evidence::Bundle {
85 bundle: read(&b.bundle)?,
86 scope,
87 now_ms: *now_ms,
88 resolver,
89 }
90 }
91 })
92 }
93}
94
95pub fn explain(
98 files: &BTreeMap<String, Vec<u8>>,
99 args: &Value,
100 resolver: Option<&dyn Resolver>,
101) -> Result<Explained, ExplainError> {
102 let a: Input = serde_json::from_value(args["evidence"].clone()).map_err(invalid)?;
103 let q = &args["query"];
104 let b = if q.get("diff").is_some() {
105 Some(
106 serde_json::from_value::<DiffQuery>(q.clone())
107 .map_err(|e| error(RefusalCode::InvalidQuery, "query", e))?
108 .diff
109 .other,
110 )
111 } else {
112 None
113 };
114 let options = args["options"]
115 .as_object()
116 .ok_or_else(|| invalid("options must be an object"))?;
117 for key in options.keys() {
118 if !matches!(key.as_str(), "nowMs" | "scope" | "redaction") {
119 return Err(invalid(format!("unknown option {key}")));
120 }
121 }
122 let bundle = a.bundle() || b.as_ref().is_some_and(Input::bundle);
123 let config = if bundle {
124 let scope = serde_json::from_value(
125 options
126 .get("scope")
127 .cloned()
128 .ok_or_else(|| invalid("scope is required"))?,
129 )
130 .map_err(invalid)?;
131 let ms = options
132 .get("nowMs")
133 .and_then(Value::as_f64)
134 .filter(|n| n.fract() == 0.0 && (0.0..=9_007_199_254_740_991.0).contains(n))
135 .ok_or_else(|| invalid("nowMs must be a non-negative safe integer"))?;
136 Some((scope, Timestamp::from_ms(ms as i64).map_err(invalid)?))
137 } else {
138 if options.contains_key("nowMs") || options.contains_key("scope") || resolver.is_some() {
139 return Err(invalid("nowMs, scope and resolve require a bundle side"));
140 }
141 None
142 };
143 let policy = options
144 .get("redaction")
145 .map(|name| {
146 let name = name
147 .as_str()
148 .ok_or_else(|| invalid("redaction must name byte input"))?;
149 let bytes = files
150 .get(name)
151 .ok_or_else(|| error(RefusalCode::Parse, "redaction", "missing byte input"))?;
152 RedactionPolicy::parse(bytes).map_err(|e| error(RefusalCode::Parse, "redaction", e))
153 })
154 .transpose()?;
155 let opts = ExplainOptions {
156 redaction: policy.as_ref(),
157 };
158 let resolver = resolver.unwrap_or(&NoExternal);
159 let ae = a.evidence(files, config.as_ref(), resolver)?;
160 let doc = if let Some(b) = b {
161 explain::explain_diff(&ae, &b.evidence(files, config.as_ref(), resolver)?, &opts)?
162 } else {
163 let query: Query = serde_json::from_value(q.clone())
164 .map_err(|e| error(RefusalCode::InvalidQuery, "query", e))?;
165 explain::explain(&ae, &query, &opts)?
166 };
167 Ok(Explained {
168 explanation: doc
169 .record_canonical()
170 .map_err(|e| error(RefusalCode::ExplanationTooLarge, "explanation", e))?,
171 markdown: explain::render_markdown(&doc),
172 })
173}