Skip to main content

rustev_surface/
explain.rs

1//! Pure explanation command shared by the bindings (spec 040, 3.15.2).
2use 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/// Named byte inputs keep document parsing in the library, never in JavaScript.
19#[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
95/// Validate all options before parsing evidence or calling the resolver.
96/// `args` holds named `evidence`, `query`, and `options`; documents stay bytes.
97pub 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}