1use std::collections::HashMap;
7use std::pin::Pin;
8use anyhow::Result;
9use async_trait::async_trait;
10use futures_util::Stream;
11
12use crate::skill_md::{ToolDocumentation, ParameterDoc, ParameterType, CodeExample};
13use super::llm_provider::{LlmProvider, LlmResponse, LlmChunk, CompletionRequest};
14
15pub fn kubernetes_apply_tool() -> ToolDocumentation {
21 ToolDocumentation {
22 name: "apply".to_string(),
23 description: "Apply a configuration to a resource by file name or stdin. The resource name must be specified.".to_string(),
24 usage: Some("skill run kubernetes:apply --file=<manifest.yaml> [--namespace=<ns>] [--dry-run]".to_string()),
25 parameters: vec![
26 ParameterDoc {
27 name: "file".to_string(),
28 param_type: ParameterType::String,
29 description: "Path to the file that contains the configuration to apply".to_string(),
30 required: true,
31 default: None,
32 allowed_values: vec![],
33 },
34 ParameterDoc {
35 name: "namespace".to_string(),
36 param_type: ParameterType::String,
37 description: "If present, the namespace scope for this CLI request".to_string(),
38 required: false,
39 default: Some("default".to_string()),
40 allowed_values: vec![],
41 },
42 ParameterDoc {
43 name: "dry-run".to_string(),
44 param_type: ParameterType::Boolean,
45 description: "Preview the object that would be sent without actually sending it".to_string(),
46 required: false,
47 default: None,
48 allowed_values: vec![],
49 },
50 ParameterDoc {
51 name: "output".to_string(),
52 param_type: ParameterType::String,
53 description: "Output format".to_string(),
54 required: false,
55 default: None,
56 allowed_values: vec!["json".to_string(), "yaml".to_string(), "wide".to_string()],
57 },
58 ParameterDoc {
59 name: "wait".to_string(),
60 param_type: ParameterType::Boolean,
61 description: "Wait for resources to be ready".to_string(),
62 required: false,
63 default: None,
64 allowed_values: vec![],
65 },
66 ParameterDoc {
67 name: "timeout".to_string(),
68 param_type: ParameterType::Integer,
69 description: "Timeout in seconds for the operation".to_string(),
70 required: false,
71 default: Some("300".to_string()),
72 allowed_values: vec![],
73 },
74 ],
75 examples: vec![
76 CodeExample {
77 language: Some("bash".to_string()),
78 code: "skill run kubernetes:apply --file=deployment.yaml".to_string(),
79 description: Some("Apply a deployment manifest".to_string()),
80 },
81 ],
82 }
83}
84
85pub fn simple_tool() -> ToolDocumentation {
87 ToolDocumentation {
88 name: "list".to_string(),
89 description: "List all resources of a given type".to_string(),
90 usage: None,
91 parameters: vec![
92 ParameterDoc {
93 name: "type".to_string(),
94 param_type: ParameterType::String,
95 description: "Resource type to list".to_string(),
96 required: true,
97 default: None,
98 allowed_values: vec![],
99 },
100 ],
101 examples: vec![],
102 }
103}
104
105pub fn tool_with_constraints() -> ToolDocumentation {
107 ToolDocumentation {
108 name: "get".to_string(),
109 description: "Display one or many resources".to_string(),
110 usage: None,
111 parameters: vec![
112 ParameterDoc {
113 name: "resource".to_string(),
114 param_type: ParameterType::String,
115 description: "Resource type".to_string(),
116 required: true,
117 default: None,
118 allowed_values: vec![
119 "pods".to_string(),
120 "deployments".to_string(),
121 "services".to_string(),
122 "configmaps".to_string(),
123 "secrets".to_string(),
124 ],
125 },
126 ParameterDoc {
127 name: "output".to_string(),
128 param_type: ParameterType::String,
129 description: "Output format".to_string(),
130 required: false,
131 default: None,
132 allowed_values: vec!["json".to_string(), "yaml".to_string(), "wide".to_string()],
133 },
134 ParameterDoc {
135 name: "all-namespaces".to_string(),
136 param_type: ParameterType::Boolean,
137 description: "List across all namespaces".to_string(),
138 required: false,
139 default: None,
140 allowed_values: vec![],
141 },
142 ],
143 examples: vec![],
144 }
145}
146
147pub fn aws_s3_tool() -> ToolDocumentation {
149 ToolDocumentation {
150 name: "s3-copy".to_string(),
151 description: "Copy files between S3 buckets or between local and S3".to_string(),
152 usage: None,
153 parameters: vec![
154 ParameterDoc {
155 name: "source".to_string(),
156 param_type: ParameterType::String,
157 description: "Source path (local path or s3://bucket/key)".to_string(),
158 required: true,
159 default: None,
160 allowed_values: vec![],
161 },
162 ParameterDoc {
163 name: "destination".to_string(),
164 param_type: ParameterType::String,
165 description: "Destination path (local path or s3://bucket/key)".to_string(),
166 required: true,
167 default: None,
168 allowed_values: vec![],
169 },
170 ParameterDoc {
171 name: "recursive".to_string(),
172 param_type: ParameterType::Boolean,
173 description: "Copy recursively".to_string(),
174 required: false,
175 default: None,
176 allowed_values: vec![],
177 },
178 ParameterDoc {
179 name: "region".to_string(),
180 param_type: ParameterType::String,
181 description: "AWS region".to_string(),
182 required: false,
183 default: Some("us-east-1".to_string()),
184 allowed_values: vec![],
185 },
186 ],
187 examples: vec![],
188 }
189}
190
191pub fn docker_build_tool() -> ToolDocumentation {
193 ToolDocumentation {
194 name: "build".to_string(),
195 description: "Build an image from a Dockerfile".to_string(),
196 usage: None,
197 parameters: vec![
198 ParameterDoc {
199 name: "context".to_string(),
200 param_type: ParameterType::String,
201 description: "Build context directory".to_string(),
202 required: true,
203 default: None,
204 allowed_values: vec![],
205 },
206 ParameterDoc {
207 name: "tag".to_string(),
208 param_type: ParameterType::String,
209 description: "Name and optionally a tag (name:tag)".to_string(),
210 required: false,
211 default: None,
212 allowed_values: vec![],
213 },
214 ParameterDoc {
215 name: "file".to_string(),
216 param_type: ParameterType::String,
217 description: "Name of the Dockerfile".to_string(),
218 required: false,
219 default: Some("Dockerfile".to_string()),
220 allowed_values: vec![],
221 },
222 ParameterDoc {
223 name: "no-cache".to_string(),
224 param_type: ParameterType::Boolean,
225 description: "Do not use cache when building".to_string(),
226 required: false,
227 default: None,
228 allowed_values: vec![],
229 },
230 ],
231 examples: vec![],
232 }
233}
234
235pub fn mock_response_for_tool(tool_name: &str) -> String {
241 match tool_name {
242 "apply" => r#"[
243 {"command": "skill run kubernetes:apply --file=deployment.yaml", "explanation": "Apply a deployment manifest to the cluster"},
244 {"command": "skill run kubernetes:apply --file=service.yaml --namespace=production", "explanation": "Apply a service in the production namespace"},
245 {"command": "skill run kubernetes:apply --file=configmap.yaml --dry-run=true", "explanation": "Preview applying a configmap without making changes"},
246 {"command": "skill run kubernetes:apply --file=app.yaml --namespace=staging --output=json", "explanation": "Apply manifest to staging and output result as JSON"},
247 {"command": "skill run kubernetes:apply --file=./manifests/full-stack.yaml --wait --timeout=120", "explanation": "Apply and wait up to 120 seconds for resources to be ready"}
248 ]"#.to_string(),
249 "list" => r#"[
250 {"command": "skill run tool:list --type=pods", "explanation": "List all pods"},
251 {"command": "skill run tool:list --type=services", "explanation": "List all services"},
252 {"command": "skill run tool:list --type=deployments", "explanation": "List all deployments"}
253 ]"#.to_string(),
254 "get" => r#"[
255 {"command": "skill run kubernetes:get --resource=pods", "explanation": "Get all pods in the default namespace"},
256 {"command": "skill run kubernetes:get --resource=deployments --output=json", "explanation": "Get deployments as JSON"},
257 {"command": "skill run kubernetes:get --resource=services --all-namespaces", "explanation": "Get services across all namespaces"},
258 {"command": "skill run kubernetes:get --resource=configmaps --output=yaml", "explanation": "Get configmaps in YAML format"},
259 {"command": "skill run kubernetes:get --resource=secrets --all-namespaces --output=wide", "explanation": "Get secrets with extended info"}
260 ]"#.to_string(),
261 "s3-copy" => r#"[
262 {"command": "skill run aws:s3-copy --source=./local-file.txt --destination=s3://my-bucket/file.txt", "explanation": "Upload local file to S3"},
263 {"command": "skill run aws:s3-copy --source=s3://bucket-a/data.json --destination=s3://bucket-b/data.json", "explanation": "Copy file between S3 buckets"},
264 {"command": "skill run aws:s3-copy --source=./data/ --destination=s3://backup-bucket/data/ --recursive", "explanation": "Upload entire directory to S3"},
265 {"command": "skill run aws:s3-copy --source=s3://bucket/file.csv --destination=./downloads/file.csv --region=eu-west-1", "explanation": "Download from EU region bucket"}
266 ]"#.to_string(),
267 "build" => r#"[
268 {"command": "skill run docker:build --context=. --tag=myapp:latest", "explanation": "Build image from current directory"},
269 {"command": "skill run docker:build --context=./app --tag=myapp:v1.0 --file=Dockerfile.prod", "explanation": "Build with custom Dockerfile"},
270 {"command": "skill run docker:build --context=. --tag=test:ci --no-cache", "explanation": "Build without cache for CI"},
271 {"command": "skill run docker:build --context=./backend --tag=api:latest", "explanation": "Build backend API image"}
272 ]"#.to_string(),
273 _ => r#"[
274 {"command": "skill run tool:command --param=value", "explanation": "Example command"}
275 ]"#.to_string(),
276 }
277}
278
279pub fn mock_response_with_errors(tool_name: &str) -> String {
281 match tool_name {
282 "apply" => r#"[
283 {"command": "skill run kubernetes:apply --file=valid.yaml", "explanation": "Valid example"},
284 {"command": "skill run kubernetes:apply --namespace=prod", "explanation": "Missing required file parameter"},
285 {"command": "skill run kubernetes:apply --file=test.yaml", "explanation": ""},
286 {"command": "skill run kubernetes:apply --file=good.yaml --output=json", "explanation": "Another valid example"}
287 ]"#.to_string(),
288 _ => mock_response_for_tool(tool_name),
289 }
290}
291
292pub struct DeterministicMockProvider {
298 responses: HashMap<String, String>,
300 default_response: String,
302 delay_ms: u64,
304 call_count: std::sync::atomic::AtomicUsize,
306}
307
308impl DeterministicMockProvider {
309 pub fn new() -> Self {
311 let mut responses = HashMap::new();
312 responses.insert("apply".to_string(), mock_response_for_tool("apply"));
313 responses.insert("list".to_string(), mock_response_for_tool("list"));
314 responses.insert("get".to_string(), mock_response_for_tool("get"));
315 responses.insert("s3-copy".to_string(), mock_response_for_tool("s3-copy"));
316 responses.insert("build".to_string(), mock_response_for_tool("build"));
317
318 Self {
319 responses,
320 default_response: r#"[{"command": "skill run tool --param=value", "explanation": "Generic example"}]"#.to_string(),
321 delay_ms: 0,
322 call_count: std::sync::atomic::AtomicUsize::new(0),
323 }
324 }
325
326 pub fn with_validation_errors() -> Self {
328 let mut provider = Self::new();
329 provider.responses.insert("apply".to_string(), mock_response_with_errors("apply"));
330 provider
331 }
332
333 pub fn with_delay(mut self, delay_ms: u64) -> Self {
335 self.delay_ms = delay_ms;
336 self
337 }
338
339 pub fn with_response(mut self, tool_name: &str, response: &str) -> Self {
341 self.responses.insert(tool_name.to_string(), response.to_string());
342 self
343 }
344
345 pub fn call_count(&self) -> usize {
347 self.call_count.load(std::sync::atomic::Ordering::SeqCst)
348 }
349
350 fn extract_tool_name(&self, prompt: &str) -> String {
352 for line in prompt.lines() {
354 if line.starts_with("- **Name**:") || line.starts_with("Name:") {
355 return line
356 .split(':')
357 .nth(1)
358 .map(|s| s.trim().to_string())
359 .unwrap_or_default();
360 }
361 }
362 "unknown".to_string()
363 }
364}
365
366impl Default for DeterministicMockProvider {
367 fn default() -> Self {
368 Self::new()
369 }
370}
371
372#[async_trait]
373impl LlmProvider for DeterministicMockProvider {
374 fn name(&self) -> &str {
375 "mock"
376 }
377
378 fn model(&self) -> &str {
379 "deterministic-test"
380 }
381
382 async fn complete(&self, request: &CompletionRequest) -> Result<LlmResponse> {
383 self.call_count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
384
385 if self.delay_ms > 0 {
387 tokio::time::sleep(tokio::time::Duration::from_millis(self.delay_ms)).await;
388 }
389
390 let empty = String::new();
392 let user_message = request.messages.iter()
393 .find(|m| m.role == "user")
394 .map(|m| &m.content)
395 .unwrap_or(&empty);
396
397 let tool_name = self.extract_tool_name(user_message);
398
399 let content = self.responses
400 .get(&tool_name)
401 .cloned()
402 .unwrap_or_else(|| self.default_response.clone());
403
404 Ok(LlmResponse {
405 content,
406 model: "deterministic-test".to_string(),
407 usage: None,
408 finish_reason: Some("stop".to_string()),
409 })
410 }
411
412 async fn complete_stream(
413 &self,
414 request: &CompletionRequest,
415 ) -> Result<Pin<Box<dyn Stream<Item = Result<LlmChunk>> + Send>>> {
416 let response = self.complete(request).await?;
418
419 let stream = async_stream::stream! {
420 yield Ok(LlmChunk {
421 delta: response.content,
422 is_final: true,
423 });
424 };
425
426 Ok(Box::pin(stream))
427 }
428}
429
430pub struct FailingMockProvider {
436 error_message: String,
437}
438
439impl FailingMockProvider {
440 pub fn new(message: &str) -> Self {
441 Self {
442 error_message: message.to_string(),
443 }
444 }
445}
446
447#[async_trait]
448impl LlmProvider for FailingMockProvider {
449 fn name(&self) -> &str {
450 "failing-mock"
451 }
452
453 fn model(&self) -> &str {
454 "error-test"
455 }
456
457 async fn complete(&self, _request: &CompletionRequest) -> Result<LlmResponse> {
458 anyhow::bail!("{}", self.error_message)
459 }
460
461 async fn complete_stream(
462 &self,
463 _request: &CompletionRequest,
464 ) -> Result<Pin<Box<dyn Stream<Item = Result<LlmChunk>> + Send>>> {
465 anyhow::bail!("{}", self.error_message)
466 }
467}
468
469#[cfg(test)]
474mod tests {
475 use super::*;
476
477 #[test]
478 fn test_kubernetes_tool_fixture() {
479 let tool = kubernetes_apply_tool();
480 assert_eq!(tool.name, "apply");
481 assert!(!tool.parameters.is_empty());
482
483 let file_param = tool.parameters.iter().find(|p| p.name == "file").unwrap();
485 assert!(file_param.required);
486 }
487
488 #[test]
489 fn test_tool_with_constraints() {
490 let tool = tool_with_constraints();
491 let resource_param = tool.parameters.iter().find(|p| p.name == "resource").unwrap();
492 assert!(!resource_param.allowed_values.is_empty());
493 assert!(resource_param.allowed_values.contains(&"pods".to_string()));
494 }
495
496 #[test]
497 fn test_mock_response_parsing() {
498 let response = mock_response_for_tool("apply");
499 let parsed: Vec<serde_json::Value> = serde_json::from_str(&response).unwrap();
500 assert_eq!(parsed.len(), 5);
501
502 for example in &parsed {
503 assert!(example.get("command").is_some());
504 assert!(example.get("explanation").is_some());
505 }
506 }
507
508 #[tokio::test]
509 async fn test_deterministic_mock_provider() {
510 let provider = DeterministicMockProvider::new();
511
512 let request = CompletionRequest::with_system(
513 "You are a CLI expert",
514 "Generate examples for:\n- **Name**: apply\n- **Description**: Apply manifest"
515 );
516
517 let response = provider.complete(&request).await.unwrap();
518 assert!(response.content.contains("deployment.yaml"));
519 assert_eq!(provider.call_count(), 1);
520 }
521
522 #[tokio::test]
523 async fn test_failing_provider() {
524 let provider = FailingMockProvider::new("Test error");
525 let request = CompletionRequest::new("test");
526
527 let result = provider.complete(&request).await;
528 assert!(result.is_err());
529 assert!(result.unwrap_err().to_string().contains("Test error"));
530 }
531}