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 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 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 for (key, value) in params {
181 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, ¶ms);
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, ¶ms, 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(¶ms);
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(¶ms);
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(¶ms);
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(¶ms);
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(¶ms);
452 assert!(result.is_ok());
453 let map = result.unwrap();
454 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(¶ms);
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(¶ms);
480 assert!(result.is_ok());
481 let map = result.unwrap();
482 assert!(map.is_empty());
483 }
484}