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_evaluate_data_object;
9use crate::parsing::ast::DateTimeValue;
10use crate::parsing::source::SourceType;
11use crate::resolve_evaluate_rules;
12use crate::spec_set_id::parse_spec_set_id;
13
14pub fn evaluate(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 = parse_evaluate_data_object(&args.get("data").cloned())
33 .map_err(|e| ToolError::invalid_arguments(e.to_string()))?;
34 let rule_names = resolve_evaluate_rules(&args.get("rules").cloned())
35 .map_err(|e| ToolError::invalid_arguments(e.to_string()))?;
36 let rules = rule_names.as_deref();
37
38 let response = engine
39 .evaluate(repository, &spec_name, Some(&now), data_values, rules, true)
40 .map_err(engine_error_to_diagnostics)?;
41
42 Ok(format_evaluate_text(&response))
43}
44
45pub fn run(engine: &Engine, args: &Value) -> Result<String, ToolError> {
47 evaluate(engine, args)
48}
49
50fn format_evaluate_text(response: &Response) -> String {
51 let mut output = String::new();
52 let missing: Vec<&str> = response
53 .results
54 .values()
55 .filter(|result| result.is_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 evaluate always explains");
77 output.push_str(&format_explanation(explanation));
78 output.push('\n');
79 }
80 output
81}
82
83pub fn specs(engine: &Engine, args: &Value) -> Result<String, ToolError> {
84 require_object(args)?;
85 let text = optional_bool(args, "text")?;
86 let all = optional_bool(args, "all")?;
87 let mut repositories = engine.specs();
88 if text {
89 return Ok(crate::format_repositories(&repositories, all));
90 }
91 if !all {
92 repositories.retain(|repository| !repository.from_dependency);
93 }
94 Ok(serde_json::to_string_pretty(&repositories)
95 .unwrap_or_else(|error| panic!("BUG: engine specs must serialize: {error}")))
96}
97
98pub fn show(engine: &Engine, args: &Value) -> Result<String, ToolError> {
99 let repository = optional_nonempty_string(args, "repository")?;
100 let text = optional_bool(args, "text")?;
101 let spec_set_id = required_string(args, "spec")?;
102 if spec_set_id.is_empty() {
103 return Err(ToolError::invalid_arguments("Spec set id cannot be empty"));
104 }
105 let spec_name = parse_spec_set_id(spec_set_id).map_err(engine_error_to_diagnostics)?;
106 let now = resolve_effective(args)?;
107 let show = engine
108 .show(repository, &spec_name, Some(&now))
109 .map_err(engine_error_to_diagnostics)?;
110 if text {
111 return Ok(format!("{show}\n"));
112 }
113 Ok(
114 serde_json::to_string_pretty(&crate::api::show_document(engine, &show))
115 .unwrap_or_else(|error| panic!("BUG: show response must serialize: {error}")),
116 )
117}
118
119pub fn source(engine: &Engine, args: &Value) -> Result<String, ToolError> {
120 require_object(args)?;
121 let repository = optional_nonempty_string(args, "repository")?;
122 let spec = optional_nonempty_string(args, "spec")?;
123 match (repository, spec) {
124 (Some(repo), None) => engine
125 .source(Some(repo), None, None)
126 .map_err(engine_error_to_diagnostics),
127 (repo, Some(spec_set_id)) => {
128 let spec_name = parse_spec_set_id(spec_set_id).map_err(engine_error_to_diagnostics)?;
129 let now = resolve_effective(args)?;
130 engine
131 .source(repo, Some(&spec_name), Some(&now))
132 .map_err(engine_error_to_diagnostics)
133 }
134 (None, None) => Err(ToolError::invalid_arguments(
135 "Missing 'spec' or 'repository' field",
136 )),
137 }
138}
139
140pub fn check(args: &Value) -> Result<String, ToolError> {
141 let text = optional_bool(args, "text")?;
142 let sources_value = args
143 .get("sources")
144 .ok_or_else(|| ToolError::invalid_arguments("Missing 'sources' array field"))?;
145 let sources_arr = sources_value
146 .as_array()
147 .ok_or_else(|| ToolError::invalid_arguments("Missing 'sources' array field"))?;
148 if sources_arr.is_empty() {
149 return Err(ToolError::invalid_arguments(
150 "'sources' must be a non-empty array of [label, code] pairs",
151 ));
152 }
153
154 let mut sources: Vec<(SourceType, String)> = Vec::with_capacity(sources_arr.len());
155 for (i, entry) in sources_arr.iter().enumerate() {
156 let pair = entry.as_array().ok_or_else(|| {
157 ToolError::invalid_arguments(format!("sources[{i}] must be a [label, code] array"))
158 })?;
159 if pair.len() != 2 {
160 return Err(ToolError::invalid_arguments(format!(
161 "sources[{i}] must have exactly 2 elements [label, code]"
162 )));
163 }
164 let label = pair[0].as_str().ok_or_else(|| {
165 ToolError::invalid_arguments(format!("sources[{i}][0] (label) must be a string"))
166 })?;
167 let code = pair[1].as_str().ok_or_else(|| {
168 ToolError::invalid_arguments(format!("sources[{i}][1] (code) must be a string"))
169 })?;
170 let source_type =
171 SourceType::from_binding_label(label).map_err(ToolError::invalid_arguments)?;
172 sources.push((source_type, code.to_string()));
173 }
174
175 let mut engine = Engine::new();
176 if let Err(load_err) = engine.load(sources) {
177 return Err(ToolError::diagnostics(&load_err.errors));
178 }
179
180 let recommendations = engine.quality();
181 if text {
182 return Ok(format_recommendations_text(&recommendations));
183 }
184 Ok(serde_json::to_string_pretty(&recommendations)
185 .unwrap_or_else(|error| panic!("BUG: quality recommendations must serialize: {error}")))
186}
187
188fn format_recommendations_text(recommendations: &[crate::Recommendation]) -> String {
189 if recommendations.is_empty() {
190 return "No recommendations.\n".to_string();
191 }
192 let mut output = String::new();
193 for recommendation in recommendations {
194 output.push_str(&recommendation.to_string());
195 output.push('\n');
196 }
197 output
198}
199
200pub fn guide(args: &Value) -> Result<String, ToolError> {
201 require_object(args)?;
202 match args.get("topic") {
203 None => Ok(EVALUATE_GUIDE.to_string()),
204 Some(value) => {
205 let topic_name = value
206 .as_str()
207 .ok_or_else(|| ToolError::invalid_arguments("topic must be a string"))?;
208 let topic = GuideTopic::parse(topic_name).ok_or_else(|| {
209 ToolError::invalid_arguments(format!(
210 "Unknown guide topic '{topic_name}'. Valid: {}",
211 GuideTopic::VALID_LIST
212 ))
213 })?;
214 Ok(topic.section_text().to_string())
215 }
216 }
217}
218
219fn engine_error_to_diagnostics(error: crate::Error) -> ToolError {
220 ToolError::diagnostics(std::slice::from_ref(&error))
221}
222
223fn reject_explain_arg(args: &Value) -> Result<(), ToolError> {
224 if args.get("explain").is_some() {
225 return Err(ToolError::invalid_arguments(
226 "MCP run always includes explanations; do not pass 'explain'",
227 ));
228 }
229 Ok(())
230}
231
232fn require_object(args: &Value) -> Result<(), ToolError> {
233 if args.is_object() || args.is_null() {
234 Ok(())
235 } else {
236 Err(ToolError::invalid_arguments("arguments must be an object"))
237 }
238}
239
240fn required_string<'a>(args: &'a Value, field: &str) -> Result<&'a str, ToolError> {
241 match args.get(field) {
242 Some(Value::String(value)) => Ok(value.trim()),
243 Some(_) => Err(ToolError::invalid_arguments(format!(
244 "'{field}' must be a string"
245 ))),
246 None => Err(ToolError::invalid_arguments(format!(
247 "Missing '{field}' field"
248 ))),
249 }
250}
251
252fn optional_nonempty_string<'a>(
253 args: &'a Value,
254 field: &str,
255) -> Result<Option<&'a str>, ToolError> {
256 match args.get(field) {
257 None => Ok(None),
258 Some(Value::Null) => Ok(None),
259 Some(Value::String(value)) => {
260 let trimmed = value.trim();
261 if trimmed.is_empty() {
262 Ok(None)
263 } else {
264 Ok(Some(trimmed))
265 }
266 }
267 Some(_) => Err(ToolError::invalid_arguments(format!(
268 "'{field}' must be a string"
269 ))),
270 }
271}
272
273fn optional_bool(args: &Value, field: &str) -> Result<bool, ToolError> {
274 match args.get(field) {
275 None | Some(Value::Null) => Ok(false),
276 Some(Value::Bool(value)) => Ok(*value),
277 Some(_) => Err(ToolError::invalid_arguments(format!(
278 "'{field}' must be a boolean"
279 ))),
280 }
281}
282
283fn resolve_effective(args: &Value) -> Result<DateTimeValue, ToolError> {
284 match args.get("effective") {
285 None | Some(Value::Null) => {
286 resolve_effective_datetime(None).map_err(engine_error_to_diagnostics)
287 }
288 Some(Value::String(raw)) => {
289 resolve_effective_datetime(Some(raw)).map_err(engine_error_to_diagnostics)
290 }
291 Some(_) => Err(ToolError::invalid_arguments("'effective' must be a string")),
292 }
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298 use std::path::PathBuf;
299 use std::sync::Arc;
300
301 fn load_pricing() -> Engine {
302 let mut engine = Engine::new();
303 engine
304 .load([(
305 SourceType::Path(Arc::new(PathBuf::from("pricing.lemma"))),
306 "spec pricing\ndata quantity: number\nrule total: quantity * 10\n".to_string(),
307 )])
308 .expect("load");
309 engine
310 }
311
312 #[test]
313 fn evaluate_returns_formatted_explanation_tree() {
314 let engine = load_pricing();
315 let text = run(
316 &engine,
317 &serde_json::json!({
318 "spec": "pricing",
319 "rules": "total",
320 "data": { "quantity": "3" }
321 }),
322 )
323 .expect("evaluate");
324 assert!(
325 text.contains("total: 30"),
326 "expected formatted tree with total: 30, got: {text}"
327 );
328 assert!(
329 text.contains("└─") || text.contains("quantity"),
330 "expected tree connector or quantity in body, got: {text}"
331 );
332 }
333
334 #[test]
335 fn evaluate_rejects_json_number_data() {
336 let engine = load_pricing();
337 let err = run(
338 &engine,
339 &serde_json::json!({
340 "spec": "pricing",
341 "data": { "quantity": 3 }
342 }),
343 )
344 .expect_err("json number");
345 match err {
346 ToolError::InvalidArguments(message) => {
347 assert!(message.contains("pass as string: \"3\""), "{message}");
348 }
349 other => panic!("expected InvalidArguments, got {other}"),
350 }
351 }
352
353 #[test]
354 fn evaluate_rejects_explain_arg() {
355 let engine = load_pricing();
356 let err = run(
357 &engine,
358 &serde_json::json!({
359 "spec": "pricing",
360 "explain": false
361 }),
362 )
363 .expect_err("explain forbidden");
364 assert!(matches!(err, ToolError::InvalidArguments(_)));
365 }
366
367 #[test]
368 fn evaluate_rejects_legacy_rule_field() {
369 let engine = load_pricing();
370 let err = run(
371 &engine,
372 &serde_json::json!({
373 "spec": "pricing",
374 "rule": "total"
375 }),
376 )
377 .expect_err("rule forbidden");
378 assert!(matches!(err, ToolError::InvalidArguments(_)));
379 }
380
381 #[test]
382 fn evaluate_rules_array() {
383 let engine = load_pricing();
384 let text = run(
385 &engine,
386 &serde_json::json!({
387 "spec": "pricing",
388 "rules": ["total"],
389 "data": { "quantity": "2" }
390 }),
391 )
392 .expect("evaluate");
393 assert!(
394 text.contains("total: 20"),
395 "expected formatted tree with total: 20, got: {text}"
396 );
397 }
398
399 #[test]
400 fn show_accepts_repository() {
401 let engine = Engine::new();
402 let text = show(
403 &engine,
404 &serde_json::json!({
405 "repository": "lemma",
406 "spec": "units"
407 }),
408 )
409 .expect("show lemma units");
410 let value: Value = serde_json::from_str(&text).expect("Show JSON");
411 assert_eq!(value["title"], "units");
412 assert_eq!(value["x-lemma-repository"], "lemma");
413 }
414
415 #[test]
416 fn missing_spec_is_diagnostics() {
417 let engine = Engine::new();
418 let err =
419 run(&engine, &serde_json::json!({ "spec": "nonexistent" })).expect_err("missing spec");
420 match err {
421 ToolError::Diagnostics(text) => {
422 let value: Value = serde_json::from_str(&text).expect("EngineError JSON");
423 assert!(value.is_array());
424 assert!(!value.as_array().expect("array").is_empty());
425 }
426 other => panic!("expected Diagnostics, got {other}"),
427 }
428 }
429
430 #[test]
431 fn evaluate_aliases_run() {
432 let engine = load_pricing();
433 let a = run(
434 &engine,
435 &serde_json::json!({
436 "spec": "pricing",
437 "data": { "quantity": "1" }
438 }),
439 )
440 .expect("evaluate");
441 let b = evaluate(
442 &engine,
443 &serde_json::json!({
444 "spec": "pricing",
445 "data": { "quantity": "1" }
446 }),
447 )
448 .expect("evaluate");
449 assert_eq!(a, b);
450 }
451
452 #[test]
453 fn check_success_is_quality_json() {
454 let text = check(&serde_json::json!({
455 "sources": [["ok.lemma", "spec ok\nrule r: 1\n"]]
456 }))
457 .expect("check");
458 let value: Value = serde_json::from_str(&text).expect("quality JSON");
459 assert!(value.is_array());
460 }
461
462 fn assert_text_must_be_boolean(result: Result<String, ToolError>) {
463 match result.expect_err("non-boolean text") {
464 ToolError::InvalidArguments(message) => {
465 assert_eq!(message, "'text' must be a boolean")
466 }
467 other => panic!("expected InvalidArguments, got {other}"),
468 }
469 }
470
471 #[test]
472 fn specs_text_is_the_cli_view() {
473 let engine = load_pricing();
474 let text = specs(&engine, &serde_json::json!({ "text": true })).expect("specs text");
475 assert_eq!(text, crate::format_repositories(&engine.specs(), false));
476 assert_eq!(text, "pricing\n");
477 let all_text =
478 specs(&engine, &serde_json::json!({ "text": true, "all": true })).expect("specs all");
479 assert_eq!(all_text, crate::format_repositories(&engine.specs(), true));
480 assert!(
481 all_text.starts_with("pricing\n\nlemma\n units\n"),
482 "{all_text}"
483 );
484 let json = specs(&engine, &serde_json::json!({ "text": false })).expect("specs JSON");
485 let value: Value = serde_json::from_str(&json).expect("specs JSON parses");
486 assert!(value.is_array());
487 assert!(
488 !value
489 .as_array()
490 .expect("array")
491 .iter()
492 .any(|g| g["repository"] == "lemma"),
493 "default specs JSON omits lemma: {json}"
494 );
495 assert_eq!(json, specs(&engine, &serde_json::json!({})).expect("specs"));
496 let all_json = specs(&engine, &serde_json::json!({ "all": true })).expect("specs all JSON");
497 let all_value: Value = serde_json::from_str(&all_json).expect("all JSON");
498 assert!(
499 all_value
500 .as_array()
501 .expect("array")
502 .iter()
503 .any(|g| g["repository"] == "lemma"),
504 "all: true JSON includes lemma: {all_json}"
505 );
506 assert_text_must_be_boolean(specs(&engine, &serde_json::json!({ "text": "true" })));
507 assert_text_must_be_boolean(specs(&engine, &serde_json::json!({ "text": 1 })));
508 }
509
510 #[test]
511 fn show_text_is_the_cli_view() {
512 let engine = load_pricing();
513 let now = DateTimeValue::now();
514 let expected = format!(
515 "{}\n",
516 engine.show(None, "pricing", Some(&now)).expect("show")
517 );
518 let text = show(
519 &engine,
520 &serde_json::json!({ "spec": "pricing", "text": true }),
521 )
522 .expect("show text");
523 assert_eq!(text, expected);
524 let json = show(
525 &engine,
526 &serde_json::json!({ "spec": "pricing", "text": null }),
527 )
528 .expect("show JSON");
529 let value: Value = serde_json::from_str(&json).expect("Show JSON");
530 assert_eq!(
531 value["$schema"],
532 "https://json-schema.org/draft/2020-12/schema"
533 );
534 assert_text_must_be_boolean(show(
535 &engine,
536 &serde_json::json!({ "spec": "pricing", "text": "yes" }),
537 ));
538 }
539
540 #[test]
541 fn check_text_lists_one_recommendation_per_line() {
542 let source = "spec pricing 2026-01-01\n\"\"\"\nx\n\"\"\"\n\ndata qty: number\n -> help \"Order quantity.\"\nrule total: qty\n";
543 let mut engine = Engine::new();
544 let source_type = SourceType::from_binding_label("pricing.lemma").expect("label");
545 engine
546 .load([(source_type, source.to_string())])
547 .expect("load");
548 let recommendations = engine.quality();
549 assert!(!recommendations.is_empty());
550 let expected: String = recommendations
551 .iter()
552 .map(|recommendation| format!("{recommendation}\n"))
553 .collect();
554 let text = check(&serde_json::json!({
555 "sources": [["pricing.lemma", source]],
556 "text": true
557 }))
558 .expect("check text");
559 assert_eq!(text, expected);
560 assert_text_must_be_boolean(check(&serde_json::json!({
561 "sources": [["pricing.lemma", source]],
562 "text": 0
563 })));
564 }
565
566 #[test]
567 fn check_text_without_recommendations_says_so() {
568 let source = "spec pricing 2026-01-01\n\"\"\"\nBulk pricing.\n\"\"\"\n\ndata qty: number\n -> minimum 0\n -> maximum 1000000\n -> help \"Order quantity.\"\n\nrule total: qty\n";
569 let text = check(&serde_json::json!({
570 "sources": [["pricing.lemma", source]],
571 "text": true
572 }))
573 .expect("check text");
574 assert_eq!(text, "No recommendations.\n");
575 }
576}