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