Skip to main content

aster_cli/commands/
recipe.rs

1use anyhow::Result;
2use aster::recipe::validate_recipe::validate_recipe_template_from_file;
3use console::style;
4use std::collections::HashMap;
5
6use crate::recipes::github_recipe::RecipeSource;
7use crate::recipes::search_recipe::{list_available_recipes, load_recipe_file};
8use aster::recipe_deeplink;
9
10pub fn handle_validate(recipe_name: &str) -> Result<()> {
11    // Load and validate the recipe file
12    let recipe_file = load_recipe_file(recipe_name)?;
13    validate_recipe_template_from_file(&recipe_file).map_err(|err| {
14        anyhow::anyhow!(
15            "{} recipe file is invalid: {}",
16            style("✗").red().bold(),
17            err
18        )
19    })?;
20    println!("{} recipe file is valid", style("✓").green().bold());
21    Ok(())
22}
23
24pub fn handle_deeplink(recipe_name: &str, params: &[String]) -> Result<String> {
25    let params_map = parse_params(params)?;
26    match generate_deeplink(recipe_name, params_map) {
27        Ok((deeplink_url, recipe)) => {
28            println!(
29                "{} Generated deeplink for: {}",
30                style("✓").green().bold(),
31                recipe.title
32            );
33            println!("{}", deeplink_url);
34            Ok(deeplink_url)
35        }
36        Err(err) => {
37            println!(
38                "{} Failed to encode recipe: {}",
39                style("✗").red().bold(),
40                err
41            );
42            Err(err)
43        }
44    }
45}
46
47pub fn handle_open(recipe_name: &str, params: &[String]) -> Result<()> {
48    handle_open_with(
49        recipe_name,
50        params,
51        |url| open::that(url),
52        &mut std::io::stdout(),
53    )
54}
55
56fn handle_open_with<F, W>(
57    recipe_name: &str,
58    params: &[String],
59    opener: F,
60    out: &mut W,
61) -> Result<()>
62where
63    F: FnOnce(&str) -> std::io::Result<()>,
64    W: std::io::Write,
65{
66    let params_map = parse_params(params)?;
67    match generate_deeplink(recipe_name, params_map) {
68        Ok((deeplink_url, recipe)) => match opener(&deeplink_url) {
69            Ok(_) => {
70                writeln!(
71                    out,
72                    "{} Opened recipe '{}' in Aster Desktop",
73                    style("✓").green().bold(),
74                    recipe.title
75                )?;
76                Ok(())
77            }
78            Err(err) => {
79                writeln!(
80                    out,
81                    "{} Failed to open recipe in Aster Desktop: {}",
82                    style("✗").red().bold(),
83                    err
84                )?;
85                writeln!(out, "Generated deeplink: {}", deeplink_url)?;
86                writeln!(out, "You can manually copy and open the URL above, or ensure Aster Desktop is installed.")?;
87                Err(anyhow::anyhow!("Failed to open recipe: {}", err))
88            }
89        },
90        Err(err) => {
91            writeln!(
92                out,
93                "{} Failed to encode recipe: {}",
94                style("✗").red().bold(),
95                err
96            )?;
97            Err(err)
98        }
99    }
100}
101
102pub fn handle_list(format: &str, verbose: bool) -> Result<()> {
103    let recipes = match list_available_recipes() {
104        Ok(recipes) => recipes,
105        Err(e) => {
106            return Err(anyhow::anyhow!("Failed to list recipes: {}", e));
107        }
108    };
109
110    match format {
111        "json" => {
112            println!("{}", serde_json::to_string(&recipes)?);
113        }
114        _ => {
115            if recipes.is_empty() {
116                println!("No recipes found");
117                return Ok(());
118            } else {
119                println!("Available recipes:");
120                for recipe in recipes {
121                    let source_info = match recipe.source {
122                        RecipeSource::Local => format!("local: {}", recipe.path),
123                        RecipeSource::GitHub => format!("github: {}", recipe.path),
124                    };
125
126                    let description = if let Some(desc) = &recipe.description {
127                        if desc.is_empty() {
128                            "(none)"
129                        } else {
130                            desc
131                        }
132                    } else {
133                        "(none)"
134                    };
135
136                    let output = format!("{} - {} - {}", recipe.name, description, source_info);
137                    if verbose {
138                        println!("  {}", output);
139                        if let Some(title) = &recipe.title {
140                            println!("    Title: {}", title);
141                        }
142                        println!("    Path: {}", recipe.path);
143                    } else {
144                        println!("{}", output);
145                    }
146                }
147            }
148        }
149    }
150    Ok(())
151}
152
153fn parse_params(params: &[String]) -> Result<HashMap<String, String>> {
154    let mut params_map = HashMap::new();
155    for param in params {
156        let parts: Vec<&str> = param.splitn(2, '=').collect();
157        if parts.len() != 2 {
158            return Err(anyhow::anyhow!(
159                "Invalid parameter format: '{}'. Expected format: key=value",
160                param
161            ));
162        }
163        params_map.insert(parts[0].to_string(), parts[1].to_string());
164    }
165    Ok(params_map)
166}
167
168fn generate_deeplink(
169    recipe_name: &str,
170    params: HashMap<String, String>,
171) -> Result<(String, aster::recipe::Recipe)> {
172    let recipe_file = load_recipe_file(recipe_name)?;
173    // Load the recipe file first to validate it
174    let recipe = validate_recipe_template_from_file(&recipe_file)?;
175    match recipe_deeplink::encode(&recipe) {
176        Ok(encoded) => {
177            let mut full_url = format!("aster://recipe?config={}", encoded);
178
179            // Append parameters as additional query parameters
180            for (key, value) in params {
181                // URL-encode the parameter keys and values
182                let encoded_key = urlencoding::encode(&key);
183                let encoded_value = urlencoding::encode(&value);
184                full_url.push_str(&format!("&{}={}", encoded_key, encoded_value));
185            }
186
187            Ok((full_url, recipe))
188        }
189        Err(err) => Err(anyhow::anyhow!("Failed to encode recipe: {}", err)),
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use std::fs;
197    use tempfile::TempDir;
198
199    fn create_test_recipe_file(dir: &TempDir, filename: &str, content: &str) -> String {
200        let file_path = dir.path().join(filename);
201        fs::write(&file_path, content).expect("Failed to write test recipe file");
202        file_path.to_string_lossy().into_owned()
203    }
204
205    const VALID_RECIPE_CONTENT: &str = r#"
206title: "Test Recipe with Valid JSON Schema"
207description: "A test recipe with valid JSON schema"
208prompt: "Test prompt content"
209instructions: "Test instructions"
210response:
211  json_schema:
212    type: object
213    properties:
214      result:
215        type: string
216        description: "The result"
217      count:
218        type: number
219        description: "A count value"
220    required:
221      - result
222"#;
223
224    const INVALID_RECIPE_CONTENT: &str = r#"
225title: "Test Recipe"
226description: "A test recipe for deeplink generation"
227prompt: "Test prompt content {{ name }}"
228instructions: "Test instructions"
229"#;
230
231    #[test]
232    fn test_handle_deeplink_valid_recipe() {
233        let temp_dir = TempDir::new().expect("Failed to create temp directory");
234        let recipe_path =
235            create_test_recipe_file(&temp_dir, "test_recipe.yaml", VALID_RECIPE_CONTENT);
236
237        let result = handle_deeplink(&recipe_path, &[]);
238        assert!(result.is_ok());
239        let url = result.unwrap();
240        assert!(url.starts_with("aster://recipe?config="));
241        let encoded_part = url.strip_prefix("aster://recipe?config=").unwrap();
242        assert!(!encoded_part.is_empty());
243    }
244
245    #[test]
246    fn test_handle_deeplink_with_parameters() {
247        let temp_dir = TempDir::new().expect("Failed to create temp directory");
248        let recipe_path =
249            create_test_recipe_file(&temp_dir, "test_recipe.yaml", VALID_RECIPE_CONTENT);
250
251        let params = vec!["name=John".to_string(), "age=30".to_string()];
252        let result = handle_deeplink(&recipe_path, &params);
253        assert!(result.is_ok());
254        let url = result.unwrap();
255        assert!(url.starts_with("aster://recipe?config="));
256        assert!(url.contains("&name=John"));
257        assert!(url.contains("&age=30"));
258    }
259
260    #[test]
261    fn test_handle_deeplink_invalid_recipe() {
262        let temp_dir = TempDir::new().expect("Failed to create temp directory");
263        let recipe_path =
264            create_test_recipe_file(&temp_dir, "test_recipe.yaml", INVALID_RECIPE_CONTENT);
265        let result = handle_deeplink(&recipe_path, &[]);
266        assert!(result.is_err());
267    }
268
269    fn run_handle_open(
270        recipe_path: &str,
271        params: &[String],
272        opener_result: std::io::Result<()>,
273    ) -> (Result<()>, String, String) {
274        let captured_url = std::cell::RefCell::new(String::new());
275        let mut out = Vec::new();
276        let result = handle_open_with(
277            recipe_path,
278            params,
279            |url| {
280                *captured_url.borrow_mut() = url.to_string();
281                opener_result
282            },
283            &mut out,
284        );
285        let output = String::from_utf8(out).unwrap();
286        (result, captured_url.into_inner(), output)
287    }
288
289    #[test]
290    fn test_handle_open_recipe() {
291        let temp_dir = TempDir::new().unwrap();
292        let recipe_path =
293            create_test_recipe_file(&temp_dir, "test_recipe.yaml", VALID_RECIPE_CONTENT);
294
295        let (expected_url, _) = generate_deeplink(&recipe_path, HashMap::new()).unwrap();
296        let (result, captured_url, _) = run_handle_open(&recipe_path, &[], Ok(()));
297
298        assert!(result.is_ok());
299        assert_eq!(captured_url, expected_url);
300    }
301
302    #[test]
303    fn test_handle_open_with_parameters() {
304        let temp_dir = TempDir::new().unwrap();
305        let recipe_path =
306            create_test_recipe_file(&temp_dir, "test_recipe.yaml", VALID_RECIPE_CONTENT);
307
308        let (base_url, _) = generate_deeplink(&recipe_path, HashMap::new()).unwrap();
309
310        let params = vec!["name=Alice".to_string(), "role=developer".to_string()];
311        let (result, captured_url, _) = run_handle_open(&recipe_path, &params, Ok(()));
312
313        assert!(result.is_ok());
314        assert!(captured_url.starts_with(&base_url));
315        assert!(captured_url.contains("&name=Alice"));
316        assert!(captured_url.contains("&role=developer"));
317    }
318
319    #[test]
320    fn test_handle_open_opener_fails() {
321        let temp_dir = TempDir::new().unwrap();
322        let recipe_path =
323            create_test_recipe_file(&temp_dir, "test_recipe.yaml", VALID_RECIPE_CONTENT);
324
325        let (expected_url, _) = generate_deeplink(&recipe_path, HashMap::new()).unwrap();
326        let opener_err = std::io::Error::new(std::io::ErrorKind::NotFound, "desktop not found");
327        let (result, _, output) = run_handle_open(&recipe_path, &[], Err(opener_err));
328
329        assert!(result.is_err());
330        assert!(output.contains("Failed to open recipe in Aster Desktop"));
331        assert!(output.contains("desktop not found"));
332        assert!(output.contains(&expected_url));
333    }
334
335    #[test]
336    fn test_handle_open_invalid_recipe() {
337        let temp_dir = TempDir::new().unwrap();
338        let recipe_path =
339            create_test_recipe_file(&temp_dir, "invalid.yaml", INVALID_RECIPE_CONTENT);
340
341        let (result, _, output) = run_handle_open(&recipe_path, &[], Ok(()));
342
343        assert!(result.is_err());
344        assert!(output.contains("Failed to encode recipe"));
345    }
346
347    #[test]
348    fn test_handle_validation_valid_recipe() {
349        let temp_dir = TempDir::new().expect("Failed to create temp directory");
350        let recipe_path =
351            create_test_recipe_file(&temp_dir, "test_recipe.yaml", VALID_RECIPE_CONTENT);
352
353        let result = handle_validate(&recipe_path);
354        assert!(result.is_ok());
355    }
356
357    #[test]
358    fn test_handle_validation_invalid_recipe() {
359        let temp_dir = TempDir::new().expect("Failed to create temp directory");
360        let recipe_path =
361            create_test_recipe_file(&temp_dir, "test_recipe.yaml", INVALID_RECIPE_CONTENT);
362        let result = handle_validate(&recipe_path);
363        assert!(result.is_err());
364    }
365
366    #[test]
367    fn test_generate_deeplink_valid_recipe() {
368        let temp_dir = TempDir::new().expect("Failed to create temp directory");
369        let recipe_path =
370            create_test_recipe_file(&temp_dir, "test_recipe.yaml", VALID_RECIPE_CONTENT);
371
372        let result = generate_deeplink(&recipe_path, HashMap::new());
373        assert!(result.is_ok());
374        let (url, recipe) = result.unwrap();
375        assert!(url.starts_with("aster://recipe?config="));
376        assert_eq!(recipe.title, "Test Recipe with Valid JSON Schema");
377        assert_eq!(recipe.description, "A test recipe with valid JSON schema");
378        let encoded_part = url.strip_prefix("aster://recipe?config=").unwrap();
379        assert!(!encoded_part.is_empty());
380    }
381
382    #[test]
383    fn test_generate_deeplink_with_parameters() {
384        let temp_dir = TempDir::new().expect("Failed to create temp directory");
385        let recipe_path =
386            create_test_recipe_file(&temp_dir, "test_recipe.yaml", VALID_RECIPE_CONTENT);
387
388        let mut params = HashMap::new();
389        params.insert("name".to_string(), "Alice".to_string());
390        params.insert("role".to_string(), "developer".to_string());
391
392        let result = generate_deeplink(&recipe_path, params);
393        assert!(result.is_ok());
394        let (url, recipe) = result.unwrap();
395        assert!(url.starts_with("aster://recipe?config="));
396        assert!(url.contains("&name=Alice"));
397        assert!(url.contains("&role=developer"));
398        assert_eq!(recipe.title, "Test Recipe with Valid JSON Schema");
399    }
400
401    #[test]
402    fn test_generate_deeplink_invalid_recipe() {
403        let temp_dir = TempDir::new().expect("Failed to create temp directory");
404        let recipe_path =
405            create_test_recipe_file(&temp_dir, "test_recipe.yaml", INVALID_RECIPE_CONTENT);
406
407        let result = generate_deeplink(&recipe_path, HashMap::new());
408        assert!(result.is_err());
409    }
410
411    #[test]
412    fn test_parse_params_basic() {
413        let params = vec!["name=John".to_string(), "age=30".to_string()];
414        let result = parse_params(&params);
415        assert!(result.is_ok());
416        let map = result.unwrap();
417        assert_eq!(map.get("name"), Some(&"John".to_string()));
418        assert_eq!(map.get("age"), Some(&"30".to_string()));
419    }
420
421    #[test]
422    fn test_parse_params_with_equals_in_value() {
423        let params = vec!["key=value=with=equals".to_string()];
424        let result = parse_params(&params);
425        assert!(result.is_ok());
426        let map = result.unwrap();
427        assert_eq!(map.get("key"), Some(&"value=with=equals".to_string()));
428    }
429
430    #[test]
431    fn test_parse_params_empty_value() {
432        let params = vec!["key=".to_string()];
433        let result = parse_params(&params);
434        assert!(result.is_ok());
435        let map = result.unwrap();
436        assert_eq!(map.get("key"), Some(&"".to_string()));
437    }
438
439    #[test]
440    fn test_parse_params_no_equals() {
441        let params = vec!["invalid".to_string()];
442        let result = parse_params(&params);
443        assert!(result.is_err());
444        let error_msg = result.unwrap_err().to_string();
445        assert!(error_msg.contains("Invalid parameter format"));
446    }
447
448    #[test]
449    fn test_parse_params_empty_key() {
450        let params = vec!["=value".to_string()];
451        let result = parse_params(&params);
452        assert!(result.is_ok());
453        let map = result.unwrap();
454        // Empty key is technically valid according to current implementation
455        assert_eq!(map.get(""), Some(&"value".to_string()));
456    }
457
458    #[test]
459    fn test_parse_params_special_characters() {
460        let params = vec![
461            "url=https://example.com/path?query=test".to_string(),
462            "message=Hello World!".to_string(),
463            "email=user@example.com".to_string(),
464        ];
465        let result = parse_params(&params);
466        assert!(result.is_ok());
467        let map = result.unwrap();
468        assert_eq!(
469            map.get("url"),
470            Some(&"https://example.com/path?query=test".to_string())
471        );
472        assert_eq!(map.get("message"), Some(&"Hello World!".to_string()));
473        assert_eq!(map.get("email"), Some(&"user@example.com".to_string()));
474    }
475
476    #[test]
477    fn test_parse_params_empty_list() {
478        let params: Vec<String> = vec![];
479        let result = parse_params(&params);
480        assert!(result.is_ok());
481        let map = result.unwrap();
482        assert!(map.is_empty());
483    }
484}