Skip to main content

lemma/mcp/
tools.rs

1use std::collections::HashMap;
2
3use serde_json::Value;
4
5use crate::documentation::{GuideTopic, EVALUATE_GUIDE};
6use crate::engine::{resolve_effective as resolve_effective_datetime, Engine};
7use crate::format_explanation;
8use crate::mcp::error::{map_engine_error, ToolError};
9use crate::parsing::ast::DateTimeValue;
10use crate::parsing::source::SourceType;
11use crate::spec_set_id::parse_spec_set_id;
12
13pub fn evaluate(engine: &Engine, args: &Value) -> Result<String, ToolError> {
14    let spec_set_id = required_string(args, "spec")?;
15    if spec_set_id.is_empty() {
16        return Err(ToolError::invalid_arguments("Spec set id cannot be empty"));
17    }
18    let spec_name = parse_spec_set_id(spec_set_id).map_err(map_engine_error)?;
19    let rule_names = optional_rule_names(args)?;
20    let data_values = parse_data(args)?;
21    let now = resolve_effective(args)?;
22    let rules = if rule_names.is_empty() {
23        None
24    } else {
25        Some(rule_names.as_slice())
26    };
27    let response = engine
28        .run(None, &spec_name, Some(&now), data_values, rules, true)
29        .map_err(map_engine_error)?;
30
31    let show_for_missing = if response
32        .results
33        .values()
34        .any(|result| result.awaits_missing_data())
35    {
36        Some(
37            engine
38                .show(None, &spec_name, Some(&now))
39                .unwrap_or_else(|error| {
40                    panic!("BUG: show must succeed after evaluate for '{spec_set_id}': {error}")
41                }),
42        )
43    } else {
44        None
45    };
46
47    let mut output = String::new();
48    output.push_str(&format!("spec: {spec_set_id}\n"));
49    output.push_str(&format!("effective: {now}\n"));
50    output.push('\n');
51
52    for result in response.results.values() {
53        output.push_str(&format!("{}: ", result.rule.name));
54        if result.vetoed {
55            if let Some(reason) = result.veto_reason.as_deref() {
56                output.push_str(reason);
57            }
58        } else {
59            let display = result.display().unwrap_or_else(|| {
60                panic!(
61                    "BUG: rule '{}' evaluated without display after evaluation",
62                    result.rule.name
63                )
64            });
65            output.push_str(display);
66            if let Some(value) = &result.value {
67                if let Some(measure) = &value.measure {
68                    append_unit_map(&mut output, measure);
69                } else if let Some(ratio) = &value.ratio {
70                    append_unit_map(&mut output, ratio);
71                }
72            }
73        }
74        output.push('\n');
75
76        if result.awaits_missing_data() {
77            let show = show_for_missing
78                .as_ref()
79                .expect("BUG: any awaiting rule requires show_for_missing after evaluate");
80            output.push_str("missing_data:\n");
81            for name in result.missing_data() {
82                let entry = show.data.get(name).unwrap_or_else(|| {
83                    panic!("BUG: missing_data key {name:?} must exist in show.data after evaluate")
84                });
85                let type_name = entry.lemma_type.specifications.to_string();
86                let help = entry.lemma_type.specifications.help();
87                if help.is_empty() {
88                    output.push_str(&format!("  {name}: {type_name}\n"));
89                } else {
90                    output.push_str(&format!("  {name}: {type_name} — {help}\n"));
91                }
92            }
93        }
94
95        if let Some(explanation) = &result.explanation {
96            let steps = format_explanation(explanation);
97            if !steps.is_empty() {
98                output.push_str("\nReasoning:\n");
99                output.push_str(&steps);
100                output.push('\n');
101            }
102        }
103    }
104
105    Ok(output)
106}
107
108pub fn list(engine: &Engine, args: &Value) -> Result<String, ToolError> {
109    require_object(args)?;
110    let list = engine.list();
111    Ok(serde_json::to_string_pretty(&list)
112        .unwrap_or_else(|error| panic!("BUG: engine list must serialize: {error}")))
113}
114
115pub fn show(engine: &Engine, args: &Value) -> Result<String, ToolError> {
116    let spec_set_id = required_string(args, "spec")?;
117    if spec_set_id.is_empty() {
118        return Err(ToolError::invalid_arguments("Spec set id cannot be empty"));
119    }
120    let spec_name = parse_spec_set_id(spec_set_id).map_err(map_engine_error)?;
121    let now = resolve_effective(args)?;
122    let show = engine
123        .show(None, &spec_name, Some(&now))
124        .map_err(map_engine_error)?;
125    Ok(serde_json::to_string_pretty(&show)
126        .unwrap_or_else(|error| panic!("BUG: show response must serialize: {error}")))
127}
128
129pub fn source(engine: &Engine, args: &Value) -> Result<String, ToolError> {
130    require_object(args)?;
131    if let Some(repository) = optional_nonempty_string(args, "repository")? {
132        return engine
133            .source(Some(repository), None, None)
134            .map_err(map_engine_error);
135    }
136    let spec_set_id = required_string(args, "spec")
137        .map_err(|_| ToolError::invalid_arguments("Missing 'spec' or 'repository' field"))?;
138    let spec_name = parse_spec_set_id(spec_set_id).map_err(map_engine_error)?;
139    let now = resolve_effective(args)?;
140    engine
141        .source(None, Some(&spec_name), Some(&now))
142        .map_err(map_engine_error)
143}
144
145pub fn check(args: &Value) -> Result<String, ToolError> {
146    let sources_value = args
147        .get("sources")
148        .ok_or_else(|| ToolError::invalid_arguments("Missing 'sources' array field"))?;
149    let sources_arr = sources_value
150        .as_array()
151        .ok_or_else(|| ToolError::invalid_arguments("Missing 'sources' array field"))?;
152    if sources_arr.is_empty() {
153        return Err(ToolError::invalid_arguments(
154            "'sources' must be a non-empty array of [label, code] pairs",
155        ));
156    }
157
158    let mut sources: Vec<(SourceType, String)> = Vec::with_capacity(sources_arr.len());
159    for (i, entry) in sources_arr.iter().enumerate() {
160        let pair = entry.as_array().ok_or_else(|| {
161            ToolError::invalid_arguments(format!("sources[{i}] must be a [label, code] array"))
162        })?;
163        if pair.len() != 2 {
164            return Err(ToolError::invalid_arguments(format!(
165                "sources[{i}] must have exactly 2 elements [label, code]"
166            )));
167        }
168        let label = pair[0].as_str().ok_or_else(|| {
169            ToolError::invalid_arguments(format!("sources[{i}][0] (label) must be a string"))
170        })?;
171        let code = pair[1].as_str().ok_or_else(|| {
172            ToolError::invalid_arguments(format!("sources[{i}][1] (code) must be a string"))
173        })?;
174        let source_type =
175            SourceType::from_binding_label(label).map_err(ToolError::invalid_arguments)?;
176        sources.push((source_type, code.to_string()));
177    }
178
179    let mut engine = Engine::new();
180    if let Err(load_err) = engine.load(sources) {
181        return Err(ToolError::diagnostics(&load_err.errors));
182    }
183
184    let recommendations = engine.quality();
185    let mut text = String::from(
186        "Parsed and planned. Syntax is valid; this does not verify the policy is correct.",
187    );
188    const MAX_RECOMMENDATIONS: usize = 20;
189    if !recommendations.is_empty() {
190        text.push_str("\n\nRecommendations:");
191        for (i, rec) in recommendations.iter().take(MAX_RECOMMENDATIONS).enumerate() {
192            text.push_str(&format!("\n{}. {}", i + 1, rec));
193        }
194        let omitted = recommendations.len().saturating_sub(MAX_RECOMMENDATIONS);
195        if omitted > 0 {
196            text.push_str(&format!("\n… and {omitted} more"));
197        }
198    }
199    Ok(text)
200}
201
202pub fn guide(args: &Value) -> Result<String, ToolError> {
203    require_object(args)?;
204    match args.get("topic") {
205        None => Ok(EVALUATE_GUIDE.to_string()),
206        Some(value) => {
207            let topic_name = value
208                .as_str()
209                .ok_or_else(|| ToolError::invalid_arguments("topic must be a string"))?;
210            let topic = GuideTopic::parse(topic_name).ok_or_else(|| {
211                ToolError::invalid_arguments(format!(
212                    "Unknown guide topic '{topic_name}'. Valid: {}",
213                    GuideTopic::VALID_LIST
214                ))
215            })?;
216            Ok(topic.section_text().to_string())
217        }
218    }
219}
220
221fn require_object(args: &Value) -> Result<(), ToolError> {
222    if args.is_object() || args.is_null() {
223        Ok(())
224    } else {
225        Err(ToolError::invalid_arguments("arguments must be an object"))
226    }
227}
228
229fn required_string<'a>(args: &'a Value, field: &str) -> Result<&'a str, ToolError> {
230    match args.get(field) {
231        Some(Value::String(value)) => Ok(value.trim()),
232        Some(_) => Err(ToolError::invalid_arguments(format!(
233            "'{field}' must be a string"
234        ))),
235        None => Err(ToolError::invalid_arguments(format!(
236            "Missing '{field}' field"
237        ))),
238    }
239}
240
241fn optional_nonempty_string<'a>(
242    args: &'a Value,
243    field: &str,
244) -> Result<Option<&'a str>, ToolError> {
245    match args.get(field) {
246        None => Ok(None),
247        Some(Value::Null) => Ok(None),
248        Some(Value::String(value)) => {
249            let trimmed = value.trim();
250            if trimmed.is_empty() {
251                Ok(None)
252            } else {
253                Ok(Some(trimmed))
254            }
255        }
256        Some(_) => Err(ToolError::invalid_arguments(format!(
257            "'{field}' must be a string"
258        ))),
259    }
260}
261
262fn optional_rule_names(args: &Value) -> Result<Vec<String>, ToolError> {
263    match args.get("rule") {
264        None | Some(Value::Null) => Ok(Vec::new()),
265        Some(Value::String(rule)) => {
266            let trimmed = rule.trim();
267            if trimmed.is_empty() {
268                Ok(Vec::new())
269            } else {
270                Ok(vec![trimmed.to_string()])
271            }
272        }
273        Some(_) => Err(ToolError::invalid_arguments("'rule' must be a string")),
274    }
275}
276
277fn parse_data(args: &Value) -> Result<HashMap<String, String>, ToolError> {
278    match args.get("data") {
279        None | Some(Value::Null) => Ok(HashMap::new()),
280        Some(Value::Array(entries)) => {
281            let mut data = HashMap::new();
282            for (i, entry) in entries.iter().enumerate() {
283                let Some(raw) = entry.as_str() else {
284                    return Err(ToolError::invalid_arguments(format!(
285                        "data[{i}] must be a string 'name=value'"
286                    )));
287                };
288                let Some((name, value)) = raw.split_once('=') else {
289                    return Err(ToolError::invalid_arguments(format!(
290                        "data[{i}] must be 'name=value', got '{raw}'"
291                    )));
292                };
293                data.insert(name.to_string(), value.to_string());
294            }
295            Ok(data)
296        }
297        Some(_) => Err(ToolError::invalid_arguments(
298            "'data' must be an array of 'name=value' strings",
299        )),
300    }
301}
302
303fn resolve_effective(args: &Value) -> Result<DateTimeValue, ToolError> {
304    match args.get("effective") {
305        None | Some(Value::Null) => resolve_effective_datetime(None).map_err(map_engine_error),
306        Some(Value::String(raw)) => resolve_effective_datetime(Some(raw)).map_err(map_engine_error),
307        Some(_) => Err(ToolError::invalid_arguments("'effective' must be a string")),
308    }
309}
310
311fn append_unit_map(output: &mut String, map: &std::collections::BTreeMap<String, String>) {
312    let parts: Vec<String> = map
313        .iter()
314        .map(|(unit, magnitude)| format!("{unit} {magnitude}"))
315        .collect();
316    output.push_str(" (");
317    output.push_str(&parts.join(", "));
318    output.push(')');
319}