Skip to main content

nika_cli/
showcase.rs

1//! Showcase subcommand handler — list and extract showcase workflows
2//!
3//! Combines all showcase workflow sources:
4//! - Course showcases: builtin (15), llm (20), exec (20) — `ShowcaseWorkflow`
5//! - Init showcases: patterns (15), advanced (15), infra (15), fetch (15) — `WorkflowTemplate`
6
7use 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/// Showcase subcommand actions
20#[derive(Subcommand)]
21pub enum ShowcaseAction {
22    /// List all showcase workflows (default)
23    List {
24        /// Filter by category (e.g., llm, builtin, exec, content, system, core, file, media)
25        #[arg(short, long)]
26        category: Option<String>,
27    },
28    /// Extract a showcase workflow to the current directory
29    Extract {
30        /// Workflow name (e.g., "blog-post-generator") or --all
31        name: Option<String>,
32
33        /// Extract all showcase workflows to ./nika-showcase/
34        #[arg(long)]
35        all: bool,
36
37        /// Output directory (default: current directory, or ./nika-showcase/ with --all)
38        #[arg(short, long)]
39        output: Option<PathBuf>,
40    },
41}
42
43/// Entry point for `nika showcase <action>`
44pub 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
64// ── Unified entry ───────────────────────────────────────────────────────────
65
66/// A unified view over both showcase types for display and extraction.
67struct ShowcaseEntry {
68    name: &'static str,
69    description: &'static str,
70    category: &'static str,
71    content: &'static str,
72    requires_llm: bool,
73    /// Source group for display (e.g., "course/builtin", "init/patterns")
74    source: &'static str,
75}
76
77/// Collect all showcase workflows from every source.
78fn all_showcases() -> Vec<ShowcaseEntry> {
79    let mut entries = Vec::with_capacity(120);
80
81    // Course showcase workflows (ShowcaseWorkflow type)
82    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    // Init showcase workflows (WorkflowTemplate type)
93    let init_workflows = nika_engine::init::get_all_workflows();
94    for w in &init_workflows {
95        // Skip minimal starters — they are not really showcases
96        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    // Derive name from filename: "01-exec.nika.yaml" -> "01-exec"
118    let name_str = w.filename.trim_end_matches(".nika.yaml");
119
120    // Detect LLM requirement from content
121    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
133/// Leak a string for the static lifetime required by ShowcaseEntry.
134/// This is fine — we only call it once at list time, not in a loop.
135fn leak_str(s: &str) -> &'static str {
136    Box::leak(s.to_string().into_boxed_str())
137}
138
139// ── List ────────────────────────────────────────────────────────────────────
140
141fn 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    // Group by source for clean display
177    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
206// ── Extract ─────────────────────────────────────────────────────────────────
207
208/// Substitute `{{PROVIDER}}` and `{{MODEL}}` placeholders with auto-detected values.
209fn 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        // Suggest close matches
221        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    // Ensure parent directory exists
243    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        // Organize into subdirectories by category
271        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// ── Tests ───────────────────────────────────────────────────────────────────
304
305#[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        // Should contain actual provider/model values
388        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        // Extracted content should not contain raw placeholders
400        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}