1use std::path::{Path, PathBuf};
8
9use clap::Subcommand;
10use colored::Colorize;
11
12use nika_engine::error::NikaError;
13use nika_engine::init::course::showcase::ShowcaseWorkflow;
14use nika_engine::init::course::showcase_builtin::SHOWCASE_BUILTIN;
15use nika_engine::init::course::showcase_exec::SHOWCASE_EXEC;
16use nika_engine::init::course::showcase_llm::SHOWCASE_LLM;
17use nika_engine::init::WorkflowTemplate;
18
19#[derive(Subcommand)]
21pub enum ShowcaseAction {
22 List {
24 #[arg(short, long)]
26 category: Option<String>,
27 },
28 Extract {
30 name: Option<String>,
32
33 #[arg(long)]
35 all: bool,
36
37 #[arg(short, long)]
39 output: Option<PathBuf>,
40 },
41}
42
43pub fn handle_showcase_command(action: ShowcaseAction, quiet: bool) -> Result<(), NikaError> {
45 match action {
46 ShowcaseAction::List { category } => cmd_list(category.as_deref(), quiet),
47 ShowcaseAction::Extract { name, all, output } => {
48 if all {
49 let dir = output.unwrap_or_else(|| PathBuf::from("nika-showcase"));
50 cmd_extract_all(&dir, quiet)
51 } else if let Some(name) = name {
52 let dir = output.unwrap_or_else(|| PathBuf::from("."));
53 cmd_extract(&name, &dir, quiet)
54 } else {
55 Err(NikaError::ValidationError {
56 reason: "Provide a workflow name or use --all. Try: nika showcase list"
57 .to_string(),
58 })
59 }
60 }
61 }
62}
63
64struct ShowcaseEntry {
68 name: &'static str,
69 description: &'static str,
70 category: &'static str,
71 content: &'static str,
72 requires_llm: bool,
73 source: &'static str,
75}
76
77fn all_showcases() -> Vec<ShowcaseEntry> {
79 let mut entries = Vec::with_capacity(120);
80
81 for w in SHOWCASE_BUILTIN {
83 entries.push(from_showcase(w, "course/builtin"));
84 }
85 for w in SHOWCASE_LLM {
86 entries.push(from_showcase(w, "course/llm"));
87 }
88 for w in SHOWCASE_EXEC {
89 entries.push(from_showcase(w, "course/exec"));
90 }
91
92 let init_workflows = nika_engine::init::get_all_workflows();
94 for w in &init_workflows {
95 if w.tier_dir == "minimal" {
97 continue;
98 }
99 entries.push(from_template(w));
100 }
101
102 entries
103}
104
105fn from_showcase(w: &'static ShowcaseWorkflow, source: &'static str) -> ShowcaseEntry {
106 ShowcaseEntry {
107 name: w.name,
108 description: w.description,
109 category: w.category,
110 content: w.content,
111 requires_llm: w.requires_llm,
112 source,
113 }
114}
115
116fn from_template(w: &WorkflowTemplate) -> ShowcaseEntry {
117 let name_str = w.filename.trim_end_matches(".nika.yaml");
119
120 let requires_llm = w.content.contains("infer:") || w.content.contains("agent:");
122
123 ShowcaseEntry {
124 name: leak_str(name_str),
125 description: leak_str(&format!("{} workflow", w.tier_dir)),
126 category: leak_str(w.tier_dir),
127 content: w.content,
128 requires_llm,
129 source: leak_str(&format!("init/{}", w.tier_dir)),
130 }
131}
132
133fn leak_str(s: &str) -> &'static str {
136 Box::leak(s.to_string().into_boxed_str())
137}
138
139fn cmd_list(category: Option<&str>, _quiet: bool) -> Result<(), NikaError> {
142 let entries = all_showcases();
143
144 let filtered: Vec<&ShowcaseEntry> = if let Some(cat) = category {
145 let cat_lower = cat.to_lowercase();
146 entries
147 .iter()
148 .filter(|e| {
149 e.category.to_lowercase().contains(&cat_lower)
150 || e.source.to_lowercase().contains(&cat_lower)
151 })
152 .collect()
153 } else {
154 entries.iter().collect()
155 };
156
157 if filtered.is_empty() {
158 println!(
159 "\n {} No showcase workflows found{}.\n",
160 "!".yellow(),
161 category
162 .map(|c| format!(" for category '{}'", c))
163 .unwrap_or_default()
164 );
165 return Ok(());
166 }
167
168 println!();
169 println!(
170 " {} ({} workflows)",
171 "Nika Showcase Workflows".cyan().bold(),
172 filtered.len()
173 );
174 println!();
175
176 let mut current_source = "";
178 for entry in &filtered {
179 if entry.source != current_source {
180 current_source = entry.source;
181 println!(" {}", format!("-- {} --", current_source).dimmed());
182 }
183
184 let llm_badge = if entry.requires_llm {
185 " [LLM]".yellow().to_string()
186 } else {
187 String::new()
188 };
189
190 println!(
191 " {:<32} {}{}",
192 entry.name.bold(),
193 entry.description.dimmed(),
194 llm_badge
195 );
196 }
197
198 println!();
199 println!(" {}", "Extract: nika showcase extract <name>".dimmed());
200 println!(" {}", "Extract all: nika showcase extract --all".dimmed());
201 println!();
202
203 Ok(())
204}
205
206fn substitute_placeholders(content: &str) -> String {
210 let (provider, model) = nika_engine::init::course::generator::detect_provider();
211 content
212 .replace("{{PROVIDER}}", provider)
213 .replace("{{MODEL}}", model)
214}
215
216fn cmd_extract(name: &str, output_dir: &Path, quiet: bool) -> Result<(), NikaError> {
217 let entries = all_showcases();
218
219 let entry = entries.iter().find(|e| e.name == name).ok_or_else(|| {
220 let suggestions: Vec<&str> = entries
222 .iter()
223 .filter(|e| e.name.contains(name) || name.contains(e.name))
224 .map(|e| e.name)
225 .take(5)
226 .collect();
227
228 let hint = if suggestions.is_empty() {
229 "Try: nika showcase list".to_string()
230 } else {
231 format!("Did you mean: {}?", suggestions.join(", "))
232 };
233
234 NikaError::ValidationError {
235 reason: format!("Showcase workflow '{}' not found. {}", name, hint),
236 }
237 })?;
238
239 let filename = format!("{}.nika.yaml", entry.name);
240 let dest = output_dir.join(&filename);
241
242 if let Some(parent) = dest.parent() {
244 std::fs::create_dir_all(parent).map_err(NikaError::IoError)?;
245 }
246
247 let content = substitute_placeholders(entry.content);
248 std::fs::write(&dest, content).map_err(NikaError::IoError)?;
249
250 if !quiet {
251 println!(
252 "\n {} Extracted: {}\n",
253 "OK".green().bold(),
254 dest.display()
255 );
256 }
257
258 Ok(())
259}
260
261fn cmd_extract_all(output_dir: &Path, quiet: bool) -> Result<(), NikaError> {
262 let entries = all_showcases();
263
264 std::fs::create_dir_all(output_dir).map_err(NikaError::IoError)?;
265
266 let mut count = 0;
267 let mut by_category: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
268
269 for entry in &entries {
270 let cat_dir = output_dir.join(entry.category);
272 std::fs::create_dir_all(&cat_dir).map_err(NikaError::IoError)?;
273
274 let filename = format!("{}.nika.yaml", entry.name);
275 let dest = cat_dir.join(&filename);
276
277 let content = substitute_placeholders(entry.content);
278 std::fs::write(&dest, content).map_err(NikaError::IoError)?;
279
280 count += 1;
281 *by_category.entry(entry.category).or_insert(0) += 1;
282 }
283
284 if !quiet {
285 println!();
286 println!(
287 " {} Extracted {} workflows to {}",
288 "OK".green().bold(),
289 count,
290 output_dir.display()
291 );
292 let mut cats: Vec<_> = by_category.iter().collect();
293 cats.sort_by_key(|(name, _)| *name);
294 for (cat, n) in cats {
295 println!(" {}: {} workflows", cat, n);
296 }
297 println!();
298 }
299
300 Ok(())
301}
302
303#[cfg(test)]
306mod tests {
307 use super::*;
308
309 #[test]
310 fn test_all_showcases_not_empty() {
311 let entries = all_showcases();
312 assert!(
313 entries.len() > 100,
314 "Should have 100+ showcase workflows, got {}",
315 entries.len()
316 );
317 }
318
319 #[test]
320 fn test_all_showcases_have_content() {
321 for entry in all_showcases() {
322 assert!(
323 !entry.content.is_empty(),
324 "Showcase '{}' should have content",
325 entry.name
326 );
327 }
328 }
329
330 #[test]
331 fn test_all_showcases_have_name() {
332 for entry in all_showcases() {
333 assert!(!entry.name.is_empty(), "Every showcase should have a name");
334 }
335 }
336
337 #[test]
338 fn test_course_showcases_have_unique_names() {
339 let course_entries: Vec<_> = all_showcases()
340 .into_iter()
341 .filter(|e| e.source.starts_with("course/"))
342 .collect();
343 let mut names: Vec<&str> = course_entries.iter().map(|e| e.name).collect();
344 let n = names.len();
345 names.sort();
346 names.dedup();
347 assert_eq!(names.len(), n, "Course showcase names should be unique");
348 }
349
350 #[test]
351 fn test_filter_by_category() {
352 let entries = all_showcases();
353 let content: Vec<_> = entries.iter().filter(|e| e.category == "content").collect();
354 assert!(
355 !content.is_empty(),
356 "Should have content-category showcases"
357 );
358 }
359
360 #[test]
361 fn test_extract_to_tempdir() {
362 let dir = tempfile::tempdir().unwrap();
363 let result = cmd_extract("progress-tracker", dir.path(), true);
364 assert!(result.is_ok());
365 assert!(dir.path().join("progress-tracker.nika.yaml").exists());
366 }
367
368 #[test]
369 fn test_extract_unknown_name() {
370 let dir = tempfile::tempdir().unwrap();
371 let result = cmd_extract("nonexistent-workflow-xyz", dir.path(), true);
372 assert!(result.is_err());
373 }
374
375 #[test]
376 fn test_substitute_placeholders() {
377 let content = "provider: \"{{PROVIDER}}\"\nmodel: \"{{MODEL}}\"\n";
378 let result = substitute_placeholders(content);
379 assert!(
380 !result.contains("{{PROVIDER}}"),
381 "{{{{PROVIDER}}}} should be substituted"
382 );
383 assert!(
384 !result.contains("{{MODEL}}"),
385 "{{{{MODEL}}}} should be substituted"
386 );
387 assert!(result.contains("provider:"));
389 assert!(result.contains("model:"));
390 }
391
392 #[test]
393 fn test_extract_substitutes_placeholders() {
394 let dir = tempfile::tempdir().unwrap();
395 let result = cmd_extract("progress-tracker", dir.path(), true);
396 assert!(result.is_ok());
397 let content =
398 std::fs::read_to_string(dir.path().join("progress-tracker.nika.yaml")).unwrap();
399 assert!(
401 !content.contains("{{PROVIDER}}"),
402 "Extracted file should not contain raw {{{{PROVIDER}}}} placeholder"
403 );
404 assert!(
405 !content.contains("{{MODEL}}"),
406 "Extracted file should not contain raw {{{{MODEL}}}} placeholder"
407 );
408 }
409}