Skip to main content

lemma/mcp/
tools.rs

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
14/// Evaluate a spec. Always explains (`Engine::run(..., true)`). No `explain` arg.
15/// Success text is ASCII explanation trees plus a Missing data block when unbound.
16pub 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
45/// Deprecated alias of [`run`]. Same args and formatted trees.
46pub 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}