Skip to main content

nika_engine/new/
mod.rs

1//! nika new - Workflow scaffolding module
2//!
3//! Provides three modes for creating new workflows:
4//! 1. **Wizard Mode** - Interactive TUI with ratatui dialogs
5//! 2. **Flag Mode** - CLI flags for power users
6//! 3. **Template Mode** - Pre-built templates for common use cases
7//!
8//! ## Templates
9//!
10//! | Template | Description |
11//! |----------|-------------|
12//! | simple-infer | Basic LLM text generation |
13//! | simple-exec | Shell command execution |
14//! | simple-fetch | HTTP request example |
15//! | api-pipeline | Multi-step API workflow |
16//! | blog-generator | Blog content pipeline |
17//! | code-review | Code review assistant |
18//! | agent-research | Research agent with MCP |
19//! | agent-browser | Browser automation agent |
20//! | mcp-integration | MCP server integration |
21//! | multi-provider | Multiple LLM providers |
22//!
23//! ## Usage
24//!
25//! ```bash
26//! # Interactive wizard
27//! nika new
28//! nika new --wizard
29//!
30//! # Template mode
31//! nika new my-workflow --template blog-generator
32//!
33//! # Flag mode
34//! nika new my-workflow --verb infer --provider claude --output json
35//! ```
36
37pub mod templates;
38// Note: wizard module lives in the `nika` binary crate (cli/new_wizard/)
39// because it depends on TUI crates (ratatui, crossterm).
40
41use crate::error::NikaError;
42use std::fs;
43use std::path::PathBuf;
44
45/// Template categories for organization
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum TemplateCategory {
48    /// Basic single-verb workflows
49    Simple,
50    /// Multi-step pipelines
51    Pipeline,
52    /// Agent-based workflows
53    Agent,
54    /// MCP integration examples
55    Mcp,
56    /// Multi-provider configurations
57    Advanced,
58}
59
60impl TemplateCategory {
61    pub fn display_name(&self) -> &'static str {
62        match self {
63            Self::Simple => "Simple",
64            Self::Pipeline => "Pipeline",
65            Self::Agent => "Agent",
66            Self::Mcp => "MCP Integration",
67            Self::Advanced => "Advanced",
68        }
69    }
70}
71
72/// Available workflow templates
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum Template {
75    /// Basic LLM text generation
76    SimpleInfer,
77    /// Shell command execution
78    SimpleExec,
79    /// HTTP request example
80    SimpleFetch,
81    /// API data processing pipeline
82    ApiPipeline,
83    /// Blog content generation
84    BlogGenerator,
85    /// Code review assistant
86    CodeReview,
87    /// Research agent with web search
88    AgentResearch,
89    /// Browser automation agent
90    AgentBrowser,
91    /// MCP server integration example
92    McpIntegration,
93    /// Multi-provider workflow
94    MultiProvider,
95    /// Data pipeline with ETL pattern
96    DataPipeline,
97    /// Morning briefing digest
98    MorningBriefing,
99    /// Git changelog generator
100    GitChangelog,
101    /// Parallel translation workflow
102    ParallelTranslation,
103    /// QA testing agent
104    AgentQaTester,
105}
106
107impl Template {
108    /// All available templates
109    pub const ALL: &'static [Template] = &[
110        Template::SimpleInfer,
111        Template::SimpleExec,
112        Template::SimpleFetch,
113        Template::ApiPipeline,
114        Template::BlogGenerator,
115        Template::CodeReview,
116        Template::AgentResearch,
117        Template::AgentBrowser,
118        Template::McpIntegration,
119        Template::MultiProvider,
120        Template::DataPipeline,
121        Template::MorningBriefing,
122        Template::GitChangelog,
123        Template::ParallelTranslation,
124        Template::AgentQaTester,
125    ];
126
127    /// Template name (for CLI)
128    pub fn name(&self) -> &'static str {
129        match self {
130            Self::SimpleInfer => "simple-infer",
131            Self::SimpleExec => "simple-exec",
132            Self::SimpleFetch => "simple-fetch",
133            Self::ApiPipeline => "api-pipeline",
134            Self::BlogGenerator => "blog-generator",
135            Self::CodeReview => "code-review",
136            Self::AgentResearch => "agent-research",
137            Self::AgentBrowser => "agent-browser",
138            Self::McpIntegration => "mcp-integration",
139            Self::MultiProvider => "multi-provider",
140            Self::DataPipeline => "data-pipeline",
141            Self::MorningBriefing => "morning-briefing",
142            Self::GitChangelog => "git-changelog",
143            Self::ParallelTranslation => "parallel-translation",
144            Self::AgentQaTester => "agent-qa-tester",
145        }
146    }
147
148    /// Human-readable description
149    pub fn description(&self) -> &'static str {
150        match self {
151            Self::SimpleInfer => "Basic LLM text generation with infer verb",
152            Self::SimpleExec => "Shell command execution with exec verb",
153            Self::SimpleFetch => "HTTP request with fetch verb",
154            Self::ApiPipeline => "Multi-step API data processing pipeline",
155            Self::BlogGenerator => "Blog content generation with research and writing",
156            Self::CodeReview => "Code review assistant with file analysis",
157            Self::AgentResearch => "Research agent with MCP web search",
158            Self::AgentBrowser => "Browser automation agent with Playwright",
159            Self::McpIntegration => "MCP server integration example",
160            Self::MultiProvider => "Multi-provider workflow (Claude + OpenAI)",
161            Self::DataPipeline => "ETL data pipeline with fetch, transform, and load",
162            Self::MorningBriefing => "Daily digest with news, weather, and calendar",
163            Self::GitChangelog => "Git commit analysis and changelog generation",
164            Self::ParallelTranslation => "Multi-language translation with for_each",
165            Self::AgentQaTester => "QA testing agent with test generation",
166        }
167    }
168
169    /// Template category
170    pub fn category(&self) -> TemplateCategory {
171        match self {
172            Self::SimpleInfer | Self::SimpleExec | Self::SimpleFetch => TemplateCategory::Simple,
173            Self::ApiPipeline
174            | Self::BlogGenerator
175            | Self::CodeReview
176            | Self::DataPipeline
177            | Self::MorningBriefing
178            | Self::GitChangelog
179            | Self::ParallelTranslation => TemplateCategory::Pipeline,
180            Self::AgentResearch | Self::AgentBrowser | Self::AgentQaTester => {
181                TemplateCategory::Agent
182            }
183            Self::McpIntegration => TemplateCategory::Mcp,
184            Self::MultiProvider => TemplateCategory::Advanced,
185        }
186    }
187
188    /// Parse template name from string
189    pub fn from_name(name: &str) -> Option<Self> {
190        Self::ALL.iter().find(|t| t.name() == name).copied()
191    }
192
193    /// Get the template content
194    pub fn content(&self, workflow_name: &str) -> String {
195        templates::generate_template(*self, workflow_name)
196    }
197}
198
199/// Primary verb for a workflow
200#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
201pub enum Verb {
202    /// LLM text generation
203    #[default]
204    Infer,
205    /// Shell command execution
206    Exec,
207    /// HTTP requests
208    Fetch,
209    /// MCP tool invocation
210    Invoke,
211    /// Multi-turn agentic loop
212    Agent,
213}
214
215impl Verb {
216    pub fn name(&self) -> &'static str {
217        match self {
218            Self::Infer => "infer",
219            Self::Exec => "exec",
220            Self::Fetch => "fetch",
221            Self::Invoke => "invoke",
222            Self::Agent => "agent",
223        }
224    }
225
226    pub fn description(&self) -> &'static str {
227        match self {
228            Self::Infer => "LLM text generation (Claude, OpenAI, etc.)",
229            Self::Exec => "Shell command execution",
230            Self::Fetch => "HTTP requests (GET, POST, etc.)",
231            Self::Invoke => "MCP tool invocation",
232            Self::Agent => "Multi-turn agentic loop with tools",
233        }
234    }
235
236    pub fn from_name(name: &str) -> Option<Self> {
237        match name.to_lowercase().as_str() {
238            "infer" => Some(Self::Infer),
239            "exec" => Some(Self::Exec),
240            "fetch" => Some(Self::Fetch),
241            "invoke" => Some(Self::Invoke),
242            "agent" => Some(Self::Agent),
243            _ => None,
244        }
245    }
246}
247
248/// LLM provider
249#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
250pub enum Provider {
251    #[default]
252    Claude,
253    OpenAI,
254    Mistral,
255    Groq,
256    DeepSeek,
257    Gemini,
258    Native,
259}
260
261impl Provider {
262    pub fn name(&self) -> &'static str {
263        match self {
264            Self::Claude => "claude",
265            Self::OpenAI => "openai",
266            Self::Mistral => "mistral",
267            Self::Groq => "groq",
268            Self::DeepSeek => "deepseek",
269            Self::Gemini => "gemini",
270            Self::Native => "native",
271        }
272    }
273
274    pub fn default_model(&self) -> &'static str {
275        match self {
276            Self::Claude => "claude-sonnet-4-6",
277            Self::OpenAI => "gpt-4o",
278            Self::Mistral => "mistral-large-latest",
279            Self::Groq => "llama-3.3-70b-versatile",
280            Self::DeepSeek => "deepseek-chat",
281            Self::Gemini => "gemini-2.0-flash",
282            Self::Native => "llama3.2-1b-q4",
283        }
284    }
285
286    pub fn env_var(&self) -> &'static str {
287        // Delegate to core::find_provider for the canonical env var name
288        crate::core::find_provider(self.name())
289            .map(|p| p.env_var)
290            .unwrap_or("ANTHROPIC_API_KEY")
291    }
292
293    pub fn from_name(name: &str) -> Option<Self> {
294        let provider = crate::core::find_provider(name)?;
295        match provider.id {
296            "anthropic" => Some(Self::Claude),
297            "openai" => Some(Self::OpenAI),
298            "mistral" => Some(Self::Mistral),
299            "groq" => Some(Self::Groq),
300            "deepseek" => Some(Self::DeepSeek),
301            "gemini" => Some(Self::Gemini),
302            "native" => Some(Self::Native),
303            _ => None,
304        }
305    }
306}
307
308/// Output format
309#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
310pub enum OutputFormat {
311    #[default]
312    Text,
313    Json,
314    Yaml,
315    File,
316}
317
318impl OutputFormat {
319    pub fn name(&self) -> &'static str {
320        match self {
321            Self::Text => "text",
322            Self::Json => "json",
323            Self::Yaml => "yaml",
324            Self::File => "file",
325        }
326    }
327
328    pub fn from_name(name: &str) -> Option<Self> {
329        match name.to_lowercase().as_str() {
330            "text" => Some(Self::Text),
331            "json" => Some(Self::Json),
332            "yaml" => Some(Self::Yaml),
333            "file" => Some(Self::File),
334            _ => None,
335        }
336    }
337}
338
339/// Configuration for generating a new workflow
340#[derive(Debug, Clone)]
341pub struct NewWorkflowConfig {
342    /// Workflow name (also used for filename)
343    pub name: String,
344    /// Optional description
345    pub description: Option<String>,
346    /// Primary verb
347    pub verb: Verb,
348    /// LLM provider
349    pub provider: Provider,
350    /// Model name (defaults to provider's default)
351    pub model: Option<String>,
352    /// Output format
353    pub output_format: OutputFormat,
354    /// Include MCP configuration
355    pub with_mcp: bool,
356    /// Include subworkflow example
357    pub with_include: bool,
358    /// Include artifact output configuration
359    pub with_artifacts: bool,
360    /// Output directory (defaults to current dir)
361    pub output_dir: PathBuf,
362}
363
364impl Default for NewWorkflowConfig {
365    fn default() -> Self {
366        Self {
367            name: "my-workflow".to_string(),
368            description: None,
369            verb: Verb::default(),
370            provider: Provider::default(),
371            model: None,
372            output_format: OutputFormat::default(),
373            with_mcp: false,
374            with_include: false,
375            with_artifacts: false,
376            output_dir: PathBuf::from("."),
377        }
378    }
379}
380
381impl NewWorkflowConfig {
382    /// Generate the workflow YAML content
383    pub fn generate(&self) -> String {
384        let model = self
385            .model
386            .as_deref()
387            .unwrap_or_else(|| self.provider.default_model());
388
389        let description = self
390            .description
391            .as_deref()
392            .unwrap_or("Generated by nika new");
393
394        let mut yaml = String::new();
395
396        // Header comment
397        yaml.push_str(&format!(
398            "# {}\n#\n# Generated by `nika new`\n#\n",
399            self.name
400        ));
401        yaml.push_str(&format!("# Usage:\n#   nika {}.nika.yaml\n#\n", self.name));
402        yaml.push_str(&format!(
403            "# Requirements:\n#   - {} environment variable\n\n",
404            self.provider.env_var()
405        ));
406
407        // Schema and metadata
408        yaml.push_str("schema: \"nika/workflow@0.12\"\n");
409        yaml.push_str(&format!("workflow: {}\n", self.name));
410        yaml.push_str(&format!("description: \"{}\"\n\n", description));
411
412        // Provider and model
413        yaml.push_str(&format!("provider: {}\n", self.provider.name()));
414        yaml.push_str(&format!("model: {}\n\n", model));
415
416        // Optional: artifacts configuration
417        if self.with_artifacts {
418            yaml.push_str("artifacts:\n");
419            yaml.push_str("  dir: ./output/{{workflow_name}}\n");
420            yaml.push_str("  format: json\n");
421            yaml.push_str("  manifest: true\n\n");
422        }
423
424        // Optional: MCP configuration
425        if self.with_mcp {
426            yaml.push_str("mcp:\n");
427            yaml.push_str("  perplexity:\n");
428            yaml.push_str("    command: npx\n");
429            yaml.push_str("    args: [\"-y\", \"@perplexity-ai/mcp-server\"]\n\n");
430        }
431
432        // Optional: include configuration
433        if self.with_include {
434            yaml.push_str("# Uncomment to include a subworkflow\n");
435            yaml.push_str("# include:\n");
436            yaml.push_str("#   - path: ./utils/common-tasks.nika.yaml\n");
437            yaml.push_str("#     prefix: common\n\n");
438        }
439
440        // Tasks section
441        yaml.push_str("tasks:\n");
442        yaml.push_str(&self.generate_primary_task());
443
444        yaml
445    }
446
447    /// Generate the primary task based on verb
448    fn generate_primary_task(&self) -> String {
449        match self.verb {
450            Verb::Infer => self.generate_infer_task(),
451            Verb::Exec => self.generate_exec_task(),
452            Verb::Fetch => self.generate_fetch_task(),
453            Verb::Invoke => self.generate_invoke_task(),
454            Verb::Agent => self.generate_agent_task(),
455        }
456    }
457
458    fn generate_infer_task(&self) -> String {
459        let mut task = String::new();
460        task.push_str("  - id: generate\n");
461        task.push_str("    description: \"Generate text with LLM\"\n");
462        task.push_str("    infer:\n");
463        task.push_str("      prompt: |\n");
464        task.push_str("        Write a brief summary about AI workflows.\n");
465        task.push_str("        Be concise and informative.\n");
466        task.push_str("    output:\n");
467        task.push_str(&format!("      format: {}\n", self.output_format.name()));
468
469        if self.output_format == OutputFormat::Json {
470            task.push_str("      schema:\n");
471            task.push_str("        type: object\n");
472            task.push_str("        required: [summary]\n");
473            task.push_str("        properties:\n");
474            task.push_str("          summary:\n");
475            task.push_str("            type: string\n");
476        }
477
478        task
479    }
480
481    fn generate_exec_task(&self) -> String {
482        let mut task = String::new();
483        task.push_str("  - id: run_command\n");
484        task.push_str("    description: \"Execute shell command\"\n");
485        task.push_str("    exec: |\n");
486        task.push_str("      echo \"Hello from Nika!\"\n");
487        task.push_str("      date\n");
488        task.push_str("    output:\n");
489        task.push_str("      format: text\n");
490        task
491    }
492
493    fn generate_fetch_task(&self) -> String {
494        let mut task = String::new();
495        task.push_str("  - id: fetch_data\n");
496        task.push_str("    description: \"Fetch data from API\"\n");
497        task.push_str("    fetch:\n");
498        task.push_str("      url: \"https://api.github.com/zen\"\n");
499        task.push_str("      method: GET\n");
500        task.push_str("      headers:\n");
501        task.push_str("        Accept: application/json\n");
502        task.push_str("    output:\n");
503        task.push_str("      format: text\n");
504        task
505    }
506
507    fn generate_invoke_task(&self) -> String {
508        let mut task = String::new();
509        task.push_str("  - id: invoke_tool\n");
510        task.push_str("    description: \"Invoke MCP tool\"\n");
511
512        if self.with_mcp {
513            task.push_str("    invoke:\n");
514            task.push_str("      server: perplexity\n");
515            task.push_str("      tool: perplexity_search\n");
516            task.push_str("      params:\n");
517            task.push_str("        query: \"latest AI news\"\n");
518        } else {
519            task.push_str("    # NOTE: Add MCP configuration above or use --with-mcp flag\n");
520            task.push_str("    invoke:\n");
521            task.push_str("      server: your-server\n");
522            task.push_str("      tool: your-tool\n");
523            task.push_str("      params:\n");
524            task.push_str("        key: value\n");
525        }
526
527        task.push_str("    output:\n");
528        task.push_str("      format: json\n");
529        task
530    }
531
532    fn generate_agent_task(&self) -> String {
533        let mut task = String::new();
534        task.push_str("  - id: agent_task\n");
535        task.push_str("    description: \"Multi-turn agent loop\"\n");
536        task.push_str("    agent:\n");
537        task.push_str("      prompt: |\n");
538        task.push_str("        You are a helpful assistant.\n");
539        task.push_str("        Answer the user's question concisely.\n\n");
540        task.push_str("        Question: What are the benefits of workflow automation?\n");
541        task.push_str("      max_turns: 5\n");
542
543        if self.with_mcp {
544            task.push_str("      mcp: [perplexity]\n");
545        }
546
547        task.push_str("    output:\n");
548        task.push_str(&format!("      format: {}\n", self.output_format.name()));
549
550        if self.output_format == OutputFormat::Json {
551            task.push_str("      schema:\n");
552            task.push_str("        type: object\n");
553            task.push_str("        required: [answer]\n");
554            task.push_str("        properties:\n");
555            task.push_str("          answer:\n");
556            task.push_str("            type: string\n");
557        }
558
559        task
560    }
561
562    /// Write the workflow to a file
563    pub fn write(&self) -> Result<PathBuf, NikaError> {
564        let filename = format!("{}.nika.yaml", self.name);
565        let path = self.output_dir.join(&filename);
566
567        // Check if file already exists
568        if path.exists() {
569            return Err(NikaError::ValidationError {
570                reason: format!(
571                    "File already exists: {}. Use a different name or delete the existing file.",
572                    path.display()
573                ),
574            });
575        }
576
577        // Generate and write content
578        let content = self.generate();
579        fs::write(&path, content)?;
580
581        Ok(path)
582    }
583}
584
585/// Create a new workflow from a template
586pub fn create_from_template(
587    name: &str,
588    template: Template,
589    output_dir: &std::path::Path,
590) -> Result<PathBuf, NikaError> {
591    let filename = format!("{}.nika.yaml", name);
592    let path = output_dir.join(&filename);
593
594    // Check if file already exists
595    if path.exists() {
596        return Err(NikaError::ValidationError {
597            reason: format!(
598                "File already exists: {}. Use a different name or delete the existing file.",
599                path.display()
600            ),
601        });
602    }
603
604    // Generate content from template
605    let content = template.content(name);
606    fs::write(&path, content)?;
607
608    Ok(path)
609}
610
611/// List all available templates with descriptions
612pub fn list_templates() -> Vec<(&'static str, &'static str, &'static str)> {
613    Template::ALL
614        .iter()
615        .map(|t| (t.name(), t.description(), t.category().display_name()))
616        .collect()
617}
618
619#[cfg(test)]
620mod tests {
621    use super::*;
622    use tempfile::TempDir;
623
624    #[test]
625    fn test_template_names() {
626        assert_eq!(Template::SimpleInfer.name(), "simple-infer");
627        assert_eq!(Template::BlogGenerator.name(), "blog-generator");
628    }
629
630    #[test]
631    fn test_template_from_name() {
632        assert_eq!(
633            Template::from_name("simple-infer"),
634            Some(Template::SimpleInfer)
635        );
636        assert_eq!(
637            Template::from_name("blog-generator"),
638            Some(Template::BlogGenerator)
639        );
640        assert_eq!(Template::from_name("invalid"), None);
641    }
642
643    #[test]
644    fn test_verb_from_name() {
645        assert_eq!(Verb::from_name("infer"), Some(Verb::Infer));
646        assert_eq!(Verb::from_name("EXEC"), Some(Verb::Exec));
647        assert_eq!(Verb::from_name("Agent"), Some(Verb::Agent));
648        assert_eq!(Verb::from_name("invalid"), None);
649    }
650
651    #[test]
652    fn test_provider_from_name() {
653        assert_eq!(Provider::from_name("claude"), Some(Provider::Claude));
654        assert_eq!(Provider::from_name("anthropic"), Some(Provider::Claude));
655        assert_eq!(Provider::from_name("GPT"), Some(Provider::OpenAI));
656        assert_eq!(Provider::from_name("invalid"), None);
657    }
658
659    #[test]
660    fn test_default_config() {
661        let config = NewWorkflowConfig::default();
662        assert_eq!(config.name, "my-workflow");
663        assert_eq!(config.verb, Verb::Infer);
664        assert_eq!(config.provider, Provider::Claude);
665        assert!(!config.with_mcp);
666    }
667
668    #[test]
669    fn test_generate_basic_workflow() {
670        let config = NewWorkflowConfig::default();
671        let yaml = config.generate();
672
673        assert!(yaml.contains("schema: \"nika/workflow@0.12\""));
674        assert!(yaml.contains("workflow: my-workflow"));
675        assert!(yaml.contains("provider: claude"));
676        assert!(yaml.contains("infer:"));
677    }
678
679    #[test]
680    fn test_generate_with_mcp() {
681        let config = NewWorkflowConfig {
682            with_mcp: true,
683            ..Default::default()
684        };
685        let yaml = config.generate();
686
687        assert!(yaml.contains("mcp:"));
688        assert!(yaml.contains("perplexity:"));
689    }
690
691    #[test]
692    fn test_generate_with_artifacts() {
693        let config = NewWorkflowConfig {
694            with_artifacts: true,
695            ..Default::default()
696        };
697        let yaml = config.generate();
698
699        assert!(yaml.contains("artifacts:"));
700        assert!(yaml.contains("dir:"));
701    }
702
703    #[test]
704    fn test_generate_exec_task() {
705        let config = NewWorkflowConfig {
706            verb: Verb::Exec,
707            ..Default::default()
708        };
709        let yaml = config.generate();
710
711        assert!(yaml.contains("exec: |"));
712        assert!(yaml.contains("echo \"Hello from Nika!\""));
713    }
714
715    #[test]
716    fn test_generate_fetch_task() {
717        let config = NewWorkflowConfig {
718            verb: Verb::Fetch,
719            ..Default::default()
720        };
721        let yaml = config.generate();
722
723        assert!(yaml.contains("fetch:"));
724        assert!(yaml.contains("url:"));
725        assert!(yaml.contains("method: GET"));
726    }
727
728    #[test]
729    fn test_generate_agent_task() {
730        let config = NewWorkflowConfig {
731            verb: Verb::Agent,
732            ..Default::default()
733        };
734        let yaml = config.generate();
735
736        assert!(yaml.contains("agent:"));
737        assert!(yaml.contains("max_turns:"));
738    }
739
740    #[test]
741    fn test_generate_json_output() {
742        let config = NewWorkflowConfig {
743            output_format: OutputFormat::Json,
744            ..Default::default()
745        };
746        let yaml = config.generate();
747
748        assert!(yaml.contains("format: json"));
749        assert!(yaml.contains("schema:"));
750    }
751
752    #[test]
753    fn test_write_workflow() {
754        let temp_dir = TempDir::new().unwrap();
755        let config = NewWorkflowConfig {
756            name: "test-workflow".to_string(),
757            output_dir: temp_dir.path().to_path_buf(),
758            ..Default::default()
759        };
760
761        let path = config.write().unwrap();
762        assert!(path.exists());
763        assert!(path.ends_with("test-workflow.nika.yaml"));
764
765        // Verify content
766        let content = std::fs::read_to_string(&path).unwrap();
767        assert!(content.contains("workflow: test-workflow"));
768    }
769
770    #[test]
771    fn test_write_workflow_already_exists() {
772        let temp_dir = TempDir::new().unwrap();
773        let config = NewWorkflowConfig {
774            name: "existing".to_string(),
775            output_dir: temp_dir.path().to_path_buf(),
776            ..Default::default()
777        };
778
779        // Create file first
780        let path = temp_dir.path().join("existing.nika.yaml");
781        std::fs::write(&path, "existing content").unwrap();
782
783        // Should fail
784        let result = config.write();
785        assert!(result.is_err());
786    }
787
788    #[test]
789    fn test_create_from_template() {
790        let temp_dir = TempDir::new().unwrap();
791        let path =
792            create_from_template("my-simple", Template::SimpleInfer, temp_dir.path()).unwrap();
793
794        assert!(path.exists());
795        let content = std::fs::read_to_string(&path).unwrap();
796        assert!(content.contains("workflow: my-simple"));
797    }
798
799    #[test]
800    fn test_list_templates() {
801        let templates = list_templates();
802        assert!(templates.len() >= 10);
803
804        // Check format
805        for (name, description, category) in &templates {
806            assert!(!name.is_empty());
807            assert!(!description.is_empty());
808            assert!(!category.is_empty());
809        }
810    }
811
812    #[test]
813    fn test_all_templates_generate() {
814        for template in Template::ALL {
815            let content = template.content("test-workflow");
816            assert!(content.contains("schema: \"nika/workflow@0.12\""));
817            assert!(content.contains("workflow: test-workflow"));
818        }
819    }
820}