1use serde_json::Value;
2
3use crate::documentation::{GuideTopic, EVALUATE_GUIDE};
4use crate::engine::{resolve_effective as resolve_effective_datetime, Engine};
5use crate::mcp::error::ToolError;
6use crate::parse_run_data_object;
7use crate::parsing::ast::DateTimeValue;
8use crate::parsing::source::SourceType;
9use crate::resolve_run_rules;
10use crate::spec_set_id::parse_spec_set_id;
11
12pub fn run(engine: &Engine, args: &Value) -> Result<String, ToolError> {
14 require_object(args)?;
15 reject_explain_arg(args)?;
16 if args.get("rule").is_some() {
17 return Err(ToolError::invalid_arguments(
18 "Unknown field 'rule'. Use 'rules' (string or string array).",
19 ));
20 }
21
22 let spec_set_id = required_string(args, "spec")?;
23 if spec_set_id.is_empty() {
24 return Err(ToolError::invalid_arguments("Spec set id cannot be empty"));
25 }
26 let spec_name = parse_spec_set_id(spec_set_id).map_err(engine_error_to_diagnostics)?;
27 let repository = optional_nonempty_string(args, "repository")?;
28 let now = resolve_effective(args)?;
29 let data_values =
30 parse_run_data_object(&args.get("data").cloned()).map_err(ToolError::invalid_arguments)?;
31 let rule_names =
32 resolve_run_rules(&args.get("rules").cloned()).map_err(ToolError::invalid_arguments)?;
33 let rules = rule_names.as_deref();
34
35 let response = engine
36 .run(repository, &spec_name, Some(&now), data_values, rules, true)
37 .map_err(engine_error_to_diagnostics)?;
38
39 Ok(serde_json::to_string_pretty(&response)
40 .unwrap_or_else(|error| panic!("BUG: Response must serialize: {error}")))
41}
42
43pub fn evaluate(engine: &Engine, args: &Value) -> Result<String, ToolError> {
45 run(engine, args)
46}
47
48pub fn list(engine: &Engine, args: &Value) -> Result<String, ToolError> {
49 require_object(args)?;
50 let list = engine.list();
51 Ok(serde_json::to_string_pretty(&list)
52 .unwrap_or_else(|error| panic!("BUG: engine list must serialize: {error}")))
53}
54
55pub fn show(engine: &Engine, args: &Value) -> Result<String, ToolError> {
56 let repository = optional_nonempty_string(args, "repository")?;
57 let spec_set_id = required_string(args, "spec")?;
58 if spec_set_id.is_empty() {
59 return Err(ToolError::invalid_arguments("Spec set id cannot be empty"));
60 }
61 let spec_name = parse_spec_set_id(spec_set_id).map_err(engine_error_to_diagnostics)?;
62 let now = resolve_effective(args)?;
63 let show = engine
64 .show(repository, &spec_name, Some(&now))
65 .map_err(engine_error_to_diagnostics)?;
66 Ok(serde_json::to_string_pretty(&show)
67 .unwrap_or_else(|error| panic!("BUG: show response must serialize: {error}")))
68}
69
70pub fn source(engine: &Engine, args: &Value) -> Result<String, ToolError> {
71 require_object(args)?;
72 let repository = optional_nonempty_string(args, "repository")?;
73 let spec = optional_nonempty_string(args, "spec")?;
74 match (repository, spec) {
75 (Some(repo), None) => engine
76 .source(Some(repo), None, None)
77 .map_err(engine_error_to_diagnostics),
78 (repo, Some(spec_set_id)) => {
79 let spec_name = parse_spec_set_id(spec_set_id).map_err(engine_error_to_diagnostics)?;
80 let now = resolve_effective(args)?;
81 engine
82 .source(repo, Some(&spec_name), Some(&now))
83 .map_err(engine_error_to_diagnostics)
84 }
85 (None, None) => Err(ToolError::invalid_arguments(
86 "Missing 'spec' or 'repository' field",
87 )),
88 }
89}
90
91pub fn check(args: &Value) -> Result<String, ToolError> {
92 let sources_value = args
93 .get("sources")
94 .ok_or_else(|| ToolError::invalid_arguments("Missing 'sources' array field"))?;
95 let sources_arr = sources_value
96 .as_array()
97 .ok_or_else(|| ToolError::invalid_arguments("Missing 'sources' array field"))?;
98 if sources_arr.is_empty() {
99 return Err(ToolError::invalid_arguments(
100 "'sources' must be a non-empty array of [label, code] pairs",
101 ));
102 }
103
104 let mut sources: Vec<(SourceType, String)> = Vec::with_capacity(sources_arr.len());
105 for (i, entry) in sources_arr.iter().enumerate() {
106 let pair = entry.as_array().ok_or_else(|| {
107 ToolError::invalid_arguments(format!("sources[{i}] must be a [label, code] array"))
108 })?;
109 if pair.len() != 2 {
110 return Err(ToolError::invalid_arguments(format!(
111 "sources[{i}] must have exactly 2 elements [label, code]"
112 )));
113 }
114 let label = pair[0].as_str().ok_or_else(|| {
115 ToolError::invalid_arguments(format!("sources[{i}][0] (label) must be a string"))
116 })?;
117 let code = pair[1].as_str().ok_or_else(|| {
118 ToolError::invalid_arguments(format!("sources[{i}][1] (code) must be a string"))
119 })?;
120 let source_type =
121 SourceType::from_binding_label(label).map_err(ToolError::invalid_arguments)?;
122 sources.push((source_type, code.to_string()));
123 }
124
125 let mut engine = Engine::new();
126 if let Err(load_err) = engine.load(sources) {
127 return Err(ToolError::diagnostics(&load_err.errors));
128 }
129
130 let recommendations = engine.quality();
131 Ok(serde_json::to_string_pretty(&recommendations)
132 .unwrap_or_else(|error| panic!("BUG: quality recommendations must serialize: {error}")))
133}
134
135pub fn guide(args: &Value) -> Result<String, ToolError> {
136 require_object(args)?;
137 match args.get("topic") {
138 None => Ok(EVALUATE_GUIDE.to_string()),
139 Some(value) => {
140 let topic_name = value
141 .as_str()
142 .ok_or_else(|| ToolError::invalid_arguments("topic must be a string"))?;
143 let topic = GuideTopic::parse(topic_name).ok_or_else(|| {
144 ToolError::invalid_arguments(format!(
145 "Unknown guide topic '{topic_name}'. Valid: {}",
146 GuideTopic::VALID_LIST
147 ))
148 })?;
149 Ok(topic.section_text().to_string())
150 }
151 }
152}
153
154fn engine_error_to_diagnostics(error: crate::Error) -> ToolError {
155 ToolError::diagnostics(std::slice::from_ref(&error))
156}
157
158fn reject_explain_arg(args: &Value) -> Result<(), ToolError> {
159 if args.get("explain").is_some() {
160 return Err(ToolError::invalid_arguments(
161 "MCP run always includes explanations; do not pass 'explain'",
162 ));
163 }
164 Ok(())
165}
166
167fn require_object(args: &Value) -> Result<(), ToolError> {
168 if args.is_object() || args.is_null() {
169 Ok(())
170 } else {
171 Err(ToolError::invalid_arguments("arguments must be an object"))
172 }
173}
174
175fn required_string<'a>(args: &'a Value, field: &str) -> Result<&'a str, ToolError> {
176 match args.get(field) {
177 Some(Value::String(value)) => Ok(value.trim()),
178 Some(_) => Err(ToolError::invalid_arguments(format!(
179 "'{field}' must be a string"
180 ))),
181 None => Err(ToolError::invalid_arguments(format!(
182 "Missing '{field}' field"
183 ))),
184 }
185}
186
187fn optional_nonempty_string<'a>(
188 args: &'a Value,
189 field: &str,
190) -> Result<Option<&'a str>, ToolError> {
191 match args.get(field) {
192 None => Ok(None),
193 Some(Value::Null) => Ok(None),
194 Some(Value::String(value)) => {
195 let trimmed = value.trim();
196 if trimmed.is_empty() {
197 Ok(None)
198 } else {
199 Ok(Some(trimmed))
200 }
201 }
202 Some(_) => Err(ToolError::invalid_arguments(format!(
203 "'{field}' must be a string"
204 ))),
205 }
206}
207
208fn resolve_effective(args: &Value) -> Result<DateTimeValue, ToolError> {
209 match args.get("effective") {
210 None | Some(Value::Null) => {
211 resolve_effective_datetime(None).map_err(engine_error_to_diagnostics)
212 }
213 Some(Value::String(raw)) => {
214 resolve_effective_datetime(Some(raw)).map_err(engine_error_to_diagnostics)
215 }
216 Some(_) => Err(ToolError::invalid_arguments("'effective' must be a string")),
217 }
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223 use std::path::PathBuf;
224 use std::sync::Arc;
225
226 fn load_pricing() -> Engine {
227 let mut engine = Engine::new();
228 engine
229 .load([(
230 SourceType::Path(Arc::new(PathBuf::from("pricing.lemma"))),
231 "spec pricing\ndata quantity: number\nrule total: quantity * 10\n".to_string(),
232 )])
233 .expect("load");
234 engine
235 }
236
237 #[test]
238 fn run_returns_response_json_with_explanation() {
239 let engine = load_pricing();
240 let text = run(
241 &engine,
242 &serde_json::json!({
243 "spec": "pricing",
244 "rules": "total",
245 "data": { "quantity": 3 }
246 }),
247 )
248 .expect("run");
249 let value: Value = serde_json::from_str(&text).expect("Response JSON");
250 assert_eq!(value["results"]["total"]["display"], "30");
251 assert!(value["results"]["total"]["explanation"].is_object());
252 }
253
254 #[test]
255 fn run_rejects_explain_arg() {
256 let engine = load_pricing();
257 let err = run(
258 &engine,
259 &serde_json::json!({
260 "spec": "pricing",
261 "explain": false
262 }),
263 )
264 .expect_err("explain forbidden");
265 assert!(matches!(err, ToolError::InvalidArguments(_)));
266 }
267
268 #[test]
269 fn run_rejects_legacy_rule_field() {
270 let engine = load_pricing();
271 let err = run(
272 &engine,
273 &serde_json::json!({
274 "spec": "pricing",
275 "rule": "total"
276 }),
277 )
278 .expect_err("rule forbidden");
279 assert!(matches!(err, ToolError::InvalidArguments(_)));
280 }
281
282 #[test]
283 fn run_rules_array() {
284 let engine = load_pricing();
285 let text = run(
286 &engine,
287 &serde_json::json!({
288 "spec": "pricing",
289 "rules": ["total"],
290 "data": { "quantity": 2 }
291 }),
292 )
293 .expect("run");
294 let value: Value = serde_json::from_str(&text).expect("Response JSON");
295 assert_eq!(value["results"]["total"]["display"], "20");
296 }
297
298 #[test]
299 fn show_accepts_repository() {
300 let engine = Engine::new();
301 let text = show(
302 &engine,
303 &serde_json::json!({
304 "repository": "lemma",
305 "spec": "units"
306 }),
307 )
308 .expect("show lemma units");
309 let value: Value = serde_json::from_str(&text).expect("Show JSON");
310 assert_eq!(value["spec"], "units");
311 }
312
313 #[test]
314 fn missing_spec_is_diagnostics() {
315 let engine = Engine::new();
316 let err =
317 run(&engine, &serde_json::json!({ "spec": "nonexistent" })).expect_err("missing spec");
318 match err {
319 ToolError::Diagnostics(text) => {
320 let value: Value = serde_json::from_str(&text).expect("EngineError JSON");
321 assert!(value.is_array());
322 assert!(!value.as_array().expect("array").is_empty());
323 }
324 other => panic!("expected Diagnostics, got {other}"),
325 }
326 }
327
328 #[test]
329 fn evaluate_aliases_run() {
330 let engine = load_pricing();
331 let a = run(
332 &engine,
333 &serde_json::json!({
334 "spec": "pricing",
335 "data": { "quantity": 1 }
336 }),
337 )
338 .expect("run");
339 let b = evaluate(
340 &engine,
341 &serde_json::json!({
342 "spec": "pricing",
343 "data": { "quantity": 1 }
344 }),
345 )
346 .expect("evaluate");
347 let va: Value = serde_json::from_str(&a).expect("run JSON");
348 let vb: Value = serde_json::from_str(&b).expect("evaluate JSON");
349 assert_eq!(
350 va["results"]["total"]["display"],
351 vb["results"]["total"]["display"]
352 );
353 assert_eq!(
354 va["results"]["total"]["explanation"]["body"],
355 vb["results"]["total"]["explanation"]["body"]
356 );
357 }
358
359 #[test]
360 fn check_success_is_quality_json() {
361 let text = check(&serde_json::json!({
362 "sources": [["ok.lemma", "spec ok\nrule r: 1\n"]]
363 }))
364 .expect("check");
365 let value: Value = serde_json::from_str(&text).expect("quality JSON");
366 assert!(value.is_array());
367 }
368}