1use serde::Deserialize;
10
11use crate::error::NikaError;
12
13#[derive(Debug, Clone, Deserialize)]
37pub struct InvokeParams {
38 #[serde(alias = "server", default)]
43 pub mcp: Option<String>,
44
45 #[serde(default)]
47 pub tool: Option<String>,
48
49 #[serde(default)]
51 pub params: Option<serde_json::Value>,
52
53 #[serde(default)]
55 pub resource: Option<String>,
56
57 #[serde(default)]
62 pub timeout: Option<u64>,
63}
64
65impl InvokeParams {
66 #[inline]
68 pub fn is_builtin_tool(&self) -> bool {
69 self.tool.as_ref().is_some_and(|t| t.starts_with("nika:"))
70 }
71}
72
73impl InvokeParams {
74 pub fn validate(&self) -> Result<(), NikaError> {
86 if !self.is_builtin_tool() {
89 match &self.mcp {
90 None => {
91 return Err(NikaError::ValidationError {
92 reason: "'mcp' server name is required for non-builtin tools".into(),
93 })
94 }
95 Some(mcp) if mcp.trim().is_empty() => {
96 return Err(NikaError::ValidationError {
97 reason: "'mcp' server name cannot be empty".into(),
98 });
99 }
100 _ => {}
101 }
102 }
103
104 match (&self.tool, &self.resource) {
105 (Some(tool), Some(_)) if !tool.trim().is_empty() => Err(NikaError::ValidationError {
106 reason: "'tool' and 'resource' are mutually exclusive - specify only one".into(),
107 }),
108 (Some(tool), None) if tool.trim().is_empty() => Err(NikaError::ValidationError {
109 reason: "'tool' name cannot be empty".into(),
110 }),
111 (None, Some(resource)) if resource.trim().is_empty() => {
112 Err(NikaError::ValidationError {
113 reason: "'resource' URI cannot be empty".into(),
114 })
115 }
116 (Some(_), Some(_)) => Err(NikaError::ValidationError {
117 reason: "'tool' and 'resource' are mutually exclusive - specify only one".into(),
118 }),
119 (None, None) => Err(NikaError::ValidationError {
120 reason: "either 'tool' or 'resource' must be specified".into(),
121 }),
122 _ => Ok(()),
123 }
124 }
125
126 #[inline]
128 pub fn is_tool_call(&self) -> bool {
129 self.tool.is_some()
130 }
131
132 #[inline]
134 pub fn is_resource_read(&self) -> bool {
135 self.resource.is_some()
136 }
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142 use crate::serde_yaml;
143 use serde_json::json;
144
145 #[test]
146 fn parse_tool_call() {
147 let yaml = r#"
148mcp: novanet
149tool: novanet_context
150params:
151 entity: qr-code
152"#;
153 let params: InvokeParams = serde_yaml::from_str(yaml).unwrap();
154 assert_eq!(params.mcp, Some("novanet".to_string()));
155 assert_eq!(params.tool, Some("novanet_context".to_string()));
156 assert_eq!(params.params, Some(json!({"entity": "qr-code"})));
157 assert!(params.resource.is_none());
158 }
159
160 #[test]
161 fn parse_resource_read() {
162 let yaml = r#"
163mcp: novanet
164resource: entity://qr-code/fr-FR
165"#;
166 let params: InvokeParams = serde_yaml::from_str(yaml).unwrap();
167 assert_eq!(params.mcp, Some("novanet".to_string()));
168 assert!(params.tool.is_none());
169 assert_eq!(params.resource, Some("entity://qr-code/fr-FR".to_string()));
170 }
171
172 #[test]
173 fn validate_ok_tool() {
174 let params = InvokeParams {
175 mcp: Some("test".to_string()),
176 tool: Some("test_tool".to_string()),
177 params: None,
178 resource: None,
179 timeout: None,
180 };
181 assert!(params.validate().is_ok());
182 assert!(params.is_tool_call());
183 assert!(!params.is_resource_read());
184 }
185
186 #[test]
187 fn validate_ok_resource() {
188 let params = InvokeParams {
189 mcp: Some("test".to_string()),
190 tool: None,
191 params: None,
192 resource: Some("test://resource".to_string()),
193 timeout: None,
194 };
195 assert!(params.validate().is_ok());
196 assert!(!params.is_tool_call());
197 assert!(params.is_resource_read());
198 }
199
200 #[test]
201 fn validate_err_both() {
202 let params = InvokeParams {
203 mcp: Some("test".to_string()),
204 tool: Some("test_tool".to_string()),
205 params: None,
206 resource: Some("test://resource".to_string()),
207 timeout: None,
208 };
209 let result = params.validate();
210 assert!(result.is_err());
211 assert!(result
212 .unwrap_err()
213 .to_string()
214 .contains("mutually exclusive"));
215 }
216
217 #[test]
218 fn validate_err_neither() {
219 let params = InvokeParams {
220 mcp: Some("test".to_string()),
221 tool: None,
222 params: None,
223 resource: None,
224 timeout: None,
225 };
226 let result = params.validate();
227 assert!(result.is_err());
228 assert!(result
229 .unwrap_err()
230 .to_string()
231 .contains("must be specified"));
232 }
233
234 #[test]
239 fn validate_err_empty_mcp() {
240 let params = InvokeParams {
241 mcp: Some("".to_string()),
242 tool: Some("test_tool".to_string()),
243 params: None,
244 resource: None,
245 timeout: None,
246 };
247 let result = params.validate();
248 assert!(result.is_err());
249 assert!(result.unwrap_err().to_string().contains("mcp"));
250 }
251
252 #[test]
253 fn validate_err_whitespace_mcp() {
254 let params = InvokeParams {
255 mcp: Some(" ".to_string()),
256 tool: Some("test_tool".to_string()),
257 params: None,
258 resource: None,
259 timeout: None,
260 };
261 let result = params.validate();
262 assert!(result.is_err());
263 assert!(result.unwrap_err().to_string().contains("mcp"));
264 }
265
266 #[test]
267 fn validate_err_empty_tool() {
268 let params = InvokeParams {
269 mcp: Some("test".to_string()),
270 tool: Some("".to_string()),
271 params: None,
272 resource: None,
273 timeout: None,
274 };
275 let result = params.validate();
276 assert!(result.is_err());
277 assert!(result.unwrap_err().to_string().contains("tool"));
278 }
279
280 #[test]
281 fn validate_err_whitespace_tool() {
282 let params = InvokeParams {
283 mcp: Some("test".to_string()),
284 tool: Some(" \t ".to_string()),
285 params: None,
286 resource: None,
287 timeout: None,
288 };
289 let result = params.validate();
290 assert!(result.is_err());
291 assert!(result.unwrap_err().to_string().contains("tool"));
292 }
293
294 #[test]
295 fn validate_err_empty_resource() {
296 let params = InvokeParams {
297 mcp: Some("test".to_string()),
298 tool: None,
299 params: None,
300 resource: Some("".to_string()),
301 timeout: None,
302 };
303 let result = params.validate();
304 assert!(result.is_err());
305 assert!(result.unwrap_err().to_string().contains("resource"));
306 }
307
308 #[test]
309 fn validate_err_whitespace_resource() {
310 let params = InvokeParams {
311 mcp: Some("test".to_string()),
312 tool: None,
313 params: None,
314 resource: Some(" ".to_string()),
315 timeout: None,
316 };
317 let result = params.validate();
318 assert!(result.is_err());
319 assert!(result.unwrap_err().to_string().contains("resource"));
320 }
321
322 #[test]
327 fn validate_ok_builtin_tool_without_mcp() {
328 let params = InvokeParams {
330 mcp: None,
331 tool: Some("nika:sleep".to_string()),
332 params: Some(json!({"duration": "1s"})),
333 resource: None,
334 timeout: None,
335 };
336 assert!(params.validate().is_ok());
337 assert!(params.is_builtin_tool());
338 }
339
340 #[test]
341 fn validate_ok_builtin_tool_with_mcp() {
342 let params = InvokeParams {
344 mcp: Some("ignored".to_string()),
345 tool: Some("nika:log".to_string()),
346 params: Some(json!({"level": "info", "message": "test"})),
347 resource: None,
348 timeout: None,
349 };
350 assert!(params.validate().is_ok());
351 assert!(params.is_builtin_tool());
352 }
353
354 #[test]
355 fn validate_err_non_builtin_without_mcp() {
356 let params = InvokeParams {
358 mcp: None,
359 tool: Some("novanet_context".to_string()),
360 params: None,
361 resource: None,
362 timeout: None,
363 };
364 let result = params.validate();
365 assert!(result.is_err());
366 assert!(result.unwrap_err().to_string().contains("mcp"));
367 }
368
369 #[test]
370 fn is_builtin_tool_detects_nika_prefix() {
371 let params = InvokeParams {
372 mcp: None,
373 tool: Some("nika:sleep".to_string()),
374 params: None,
375 resource: None,
376 timeout: None,
377 };
378 assert!(params.is_builtin_tool());
379 }
380
381 #[test]
382 fn is_builtin_tool_rejects_non_nika() {
383 let params = InvokeParams {
384 mcp: Some("test".to_string()),
385 tool: Some("novanet_context".to_string()),
386 params: None,
387 resource: None,
388 timeout: None,
389 };
390 assert!(!params.is_builtin_tool());
391 }
392
393 #[test]
394 fn parse_builtin_tool_without_mcp() {
395 let yaml = r#"
397tool: nika:sleep
398params:
399 duration: "1s"
400"#;
401 let params: InvokeParams = serde_yaml::from_str(yaml).unwrap();
402 assert!(params.mcp.is_none());
403 assert_eq!(params.tool, Some("nika:sleep".to_string()));
404 assert!(params.validate().is_ok());
405 }
406
407 #[test]
412 fn parse_tool_call_with_timeout() {
413 let yaml = r#"
414mcp: novanet
415tool: novanet_context
416timeout: 60
417params:
418 entity: qr-code
419"#;
420 let params: InvokeParams = serde_yaml::from_str(yaml).unwrap();
421 assert_eq!(params.timeout, Some(60));
422 assert!(params.validate().is_ok());
423 }
424
425 #[test]
426 fn parse_tool_call_without_timeout() {
427 let yaml = r#"
428mcp: novanet
429tool: novanet_context
430"#;
431 let params: InvokeParams = serde_yaml::from_str(yaml).unwrap();
432 assert_eq!(params.timeout, None);
433 }
434}