Skip to main content

nika_cli/
schema.rs

1//! Schema management subcommand handler
2
3use clap::Subcommand;
4use colored::Colorize;
5
6use nika_engine::error::NikaError;
7
8/// Schema management actions
9#[derive(Subcommand)]
10pub enum SchemaAction {
11    /// Upgrade workflow to latest schema version
12    Upgrade {
13        /// Path to .nika.yaml file or directory
14        path: String,
15
16        /// Target schema version (default: latest)
17        #[arg(short, long)]
18        target: Option<String>,
19
20        /// Process all files in directory
21        #[arg(long)]
22        all: bool,
23
24        /// Create backup before upgrading (default: true)
25        #[arg(long, default_value = "true")]
26        backup: bool,
27    },
28
29    /// Show current schema version of a workflow (or list available versions)
30    Version {
31        /// Path to .nika.yaml file (optional - shows available versions if omitted)
32        path: Option<String>,
33    },
34
35    /// Validate workflow against schema
36    Validate {
37        /// Path to .nika.yaml file
38        path: String,
39
40        /// Schema version to validate against
41        #[arg(short, long)]
42        schema: Option<String>,
43    },
44}
45
46pub fn handle_schema_command(action: SchemaAction, quiet: bool) -> Result<(), NikaError> {
47    match action {
48        SchemaAction::Version { path } => {
49            match path {
50                Some(p) => {
51                    // Show version for a specific file
52                    let path = std::path::Path::new(&p);
53                    if !path.exists() {
54                        return Err(NikaError::WorkflowNotFound {
55                            path: path.to_string_lossy().to_string(),
56                        });
57                    }
58
59                    let content = std::fs::read_to_string(path)?;
60                    let schema = extract_schema_version(&content);
61
62                    if quiet {
63                        println!("{schema}");
64                    } else {
65                        println!("{} {}", "Schema version:".cyan(), schema.green());
66
67                        // Parse and show details if it's a valid nika schema
68                        if let Some(version) = schema.strip_prefix("nika/workflow@") {
69                            let latest = "0.12";
70                            if version == latest {
71                                println!("  {} You're on the latest schema version", "✓".green());
72                            } else {
73                                println!("  {} Latest version is @{}", "ℹ".yellow(), latest);
74                                println!(
75                                    "  {} Run 'nika schema upgrade {}' to upgrade",
76                                    "→".cyan(),
77                                    path.display()
78                                );
79                            }
80                        }
81                    }
82                }
83                None => {
84                    // List available schema versions
85                    if !quiet {
86                        println!("{}", "Available Nika schema versions:".cyan().bold());
87                        println!();
88                    }
89
90                    let versions = [
91                        ("0.1", "Basic: infer, exec, fetch verbs"),
92                        ("0.2", "+invoke, +agent verbs, +mcp config"),
93                        ("0.3", "+for_each parallelism, rig-core integration"),
94                        ("0.5", "+decompose, +lazy bindings, +spawn_agent"),
95                        ("0.6", "+multi-provider support (6 providers)"),
96                        ("0.7", "+full streaming for all providers"),
97                        ("0.8", "+Studio DX (edit history, sessions, themes)"),
98                        ("0.9", "+context: file loading, +include: DAG fusion"),
99                        ("0.10", "+two-phase AST, +analyzer validation"),
100                        ("0.11", "+native inference (provider: native, mistral.rs)"),
101                        ("0.12", "+with: bindings, +imports, +depends_on (current)"),
102                    ];
103
104                    for (version, desc) in &versions {
105                        if quiet {
106                            println!("nika/workflow@{version}");
107                        } else {
108                            let prefix = if *version == "0.12" {
109                                "→".green()
110                            } else {
111                                " ".normal()
112                            };
113                            println!("  {} nika/workflow@{:<4}  {}", prefix, version.cyan(), desc);
114                        }
115                    }
116
117                    if !quiet {
118                        println!();
119                        println!(
120                            "  {} Use 'nika schema version <file>' to check a workflow",
121                            "ℹ".yellow()
122                        );
123                    }
124                }
125            }
126            Ok(())
127        }
128
129        SchemaAction::Validate { path, schema } => {
130            let path = std::path::Path::new(&path);
131            if !path.exists() {
132                return Err(NikaError::WorkflowNotFound {
133                    path: path.to_string_lossy().to_string(),
134                });
135            }
136
137            // Helper function to validate a single file
138            fn validate_single_file(
139                file_path: &std::path::Path,
140                target_schema: Option<&str>,
141                quiet: bool,
142            ) -> Result<(), NikaError> {
143                let content = std::fs::read_to_string(file_path)?;
144
145                // Get schema version to validate against
146                let schema_version = target_schema.map(|s| s.to_string()).unwrap_or_else(|| {
147                    let extracted = extract_schema_version(&content);
148                    if extracted == "(not specified)" {
149                        "nika/workflow@0.12".to_string()
150                    } else {
151                        extracted
152                    }
153                });
154
155                if !quiet {
156                    println!(
157                        "{} Validating {} against {}",
158                        "→".cyan(),
159                        file_path.display(),
160                        schema_version.green()
161                    );
162                }
163
164                // Try to parse the workflow to validate it
165                let result = nika_engine::ast::raw::parse(&content, nika_engine::source::FileId(0));
166                match result {
167                    Ok(raw_workflow) => {
168                        // Run analyzer for semantic validation
169                        let analyze_result = nika_engine::ast::analyzer::analyze(raw_workflow);
170                        if analyze_result.is_ok() {
171                            if !quiet {
172                                println!("{} Workflow is valid", "✓".green());
173                            }
174                            Ok(())
175                        } else {
176                            let errors: Vec<String> = analyze_result
177                                .errors
178                                .iter()
179                                .map(|e| format!("  • {e}"))
180                                .collect();
181                            if !quiet {
182                                println!("{} Validation failed:", "✗".red());
183                                for error in &errors {
184                                    println!("{}", error.red());
185                                }
186                            }
187                            Err(NikaError::ValidationError {
188                                reason: format!(
189                                    "{} validation error(s) found",
190                                    analyze_result.errors.len()
191                                ),
192                            })
193                        }
194                    }
195                    Err(e) => {
196                        if !quiet {
197                            println!("{} Parse error: {}", "✗".red(), e);
198                        }
199                        Err(NikaError::ValidationError {
200                            reason: format!("Parse error: {e}"),
201                        })
202                    }
203                }
204            }
205
206            // Check if path is a directory
207            if path.is_dir() {
208                let files = find_nika_yaml_files(path)?;
209
210                if files.is_empty() {
211                    if !quiet {
212                        println!(
213                            "{} No .nika.yaml files found in {}",
214                            "⚠".yellow(),
215                            path.display()
216                        );
217                    }
218                    return Ok(());
219                }
220
221                if !quiet {
222                    println!(
223                        "{} Found {} workflow file(s) in {}",
224                        "→".cyan(),
225                        files.len(),
226                        path.display()
227                    );
228                    println!();
229                }
230
231                let mut passed = 0;
232                let mut failed = 0;
233
234                for file in &files {
235                    match validate_single_file(file, schema.as_deref(), quiet) {
236                        Ok(()) => passed += 1,
237                        Err(_) => failed += 1,
238                    }
239                    if !quiet {
240                        println!();
241                    }
242                }
243
244                if !quiet {
245                    println!("────────────────────────────────────────");
246                    println!(
247                        "{} Summary: {} passed, {} failed",
248                        "→".cyan(),
249                        passed.to_string().green(),
250                        if failed > 0 {
251                            failed.to_string().red()
252                        } else {
253                            failed.to_string().green()
254                        }
255                    );
256                }
257
258                if failed > 0 {
259                    Err(NikaError::ValidationError {
260                        reason: format!("{failed} file(s) failed validation"),
261                    })
262                } else {
263                    Ok(())
264                }
265            } else {
266                // Single file validation
267                validate_single_file(path, schema.as_deref(), quiet)
268            }
269        }
270
271        SchemaAction::Upgrade {
272            path,
273            target,
274            all,
275            backup,
276        } => {
277            let target_version = target.as_deref().unwrap_or("0.12");
278
279            if all {
280                // Upgrade all .nika.yaml files in directory
281                let dir = std::path::Path::new(&path);
282                if !dir.is_dir() {
283                    return Err(NikaError::ValidationError {
284                        reason: format!("{path} is not a directory"),
285                    });
286                }
287
288                // Recursively find all .nika.yaml files
289                let files = find_nika_yaml_files(dir)?;
290
291                if files.is_empty() {
292                    if !quiet {
293                        println!("{} No .nika.yaml files found in {}", "ℹ".yellow(), path);
294                    }
295                    return Ok(());
296                }
297
298                if !quiet {
299                    println!(
300                        "{} Upgrading {} workflow(s) to schema @{}",
301                        "→".cyan(),
302                        files.len(),
303                        target_version
304                    );
305                }
306
307                let mut upgraded = 0;
308                let mut skipped = 0;
309                let mut errors = 0;
310
311                for file in files {
312                    match upgrade_workflow_file(&file, target_version, backup) {
313                        Ok(true) => upgraded += 1,
314                        Ok(false) => skipped += 1,
315                        Err(e) => {
316                            if !quiet {
317                                println!("  {} {}: {}", "✗".red(), file.display(), e);
318                            }
319                            errors += 1;
320                        }
321                    }
322                }
323
324                if !quiet {
325                    println!();
326                    println!(
327                        "{} {} upgraded, {} skipped, {} errors",
328                        "Summary:".cyan(),
329                        upgraded.to_string().green(),
330                        skipped,
331                        errors
332                    );
333                }
334
335                if errors > 0 {
336                    Err(NikaError::ValidationError {
337                        reason: format!("{errors} file(s) failed to upgrade"),
338                    })
339                } else {
340                    Ok(())
341                }
342            } else {
343                // Upgrade single file
344                let file_path = std::path::Path::new(&path);
345                if !file_path.exists() {
346                    return Err(NikaError::WorkflowNotFound { path: path.clone() });
347                }
348
349                match upgrade_workflow_file(file_path, target_version, backup)? {
350                    true => {
351                        if !quiet {
352                            println!(
353                                "{} Upgraded {} to schema @{}",
354                                "✓".green(),
355                                path,
356                                target_version
357                            );
358                        }
359                        Ok(())
360                    }
361                    false => {
362                        if !quiet {
363                            println!(
364                                "{} {} is already at schema @{} or newer",
365                                "ℹ".yellow(),
366                                path,
367                                target_version
368                            );
369                        }
370                        Ok(())
371                    }
372                }
373            }
374        }
375    }
376}
377
378fn extract_schema_version(content: &str) -> String {
379    // Match schema: "nika/workflow@X.Y" or schema: 'nika/workflow@X.Y' or schema: nika/workflow@X.Y
380    let re = regex::Regex::new(r#"^schema:\s*["']?(nika/workflow@[0-9.]+)["']?"#).ok();
381    if let Some(regex) = re {
382        for line in content.lines() {
383            if let Some(captures) = regex.captures(line.trim()) {
384                if let Some(m) = captures.get(1) {
385                    return m.as_str().to_string();
386                }
387            }
388        }
389    }
390    "(not specified)".to_string()
391}
392
393/// Find all .nika.yaml files recursively in a directory
394fn find_nika_yaml_files(dir: &std::path::Path) -> Result<Vec<std::path::PathBuf>, NikaError> {
395    let mut files = Vec::new();
396
397    fn visit_dir(
398        dir: &std::path::Path,
399        files: &mut Vec<std::path::PathBuf>,
400    ) -> Result<(), NikaError> {
401        if dir.is_dir() {
402            for entry in std::fs::read_dir(dir)? {
403                let entry = entry?;
404                let path = entry.path();
405                if path.is_dir() {
406                    // Skip hidden directories and common ignore patterns
407                    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
408                    if !name.starts_with('.') && name != "node_modules" && name != "target" {
409                        visit_dir(&path, files)?;
410                    }
411                } else if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
412                    if name.ends_with(".nika.yaml") || name.ends_with(".nika.yml") {
413                        files.push(path);
414                    }
415                }
416            }
417        }
418        Ok(())
419    }
420
421    visit_dir(dir, &mut files)?;
422    files.sort();
423    Ok(files)
424}
425
426/// Upgrade a single workflow file to target schema version
427/// Returns Ok(true) if upgraded, Ok(false) if skipped (already at version)
428fn upgrade_workflow_file(
429    path: &std::path::Path,
430    target_version: &str,
431    backup: bool,
432) -> Result<bool, NikaError> {
433    let content = std::fs::read_to_string(path)?;
434
435    // Get current schema version using regex helper
436    let current_schema = extract_schema_version(&content);
437    let current = if current_schema == "(not specified)" {
438        "0.1".to_string()
439    } else {
440        // Extract version number from "nika/workflow@X.Y"
441        current_schema
442            .strip_prefix("nika/workflow@")
443            .unwrap_or("0.1")
444            .to_string()
445    };
446
447    // Parse versions for comparison
448    let current_parts: Vec<u32> = current
449        .split('.')
450        .filter_map(|s: &str| s.parse::<u32>().ok())
451        .collect();
452    let target_parts: Vec<u32> = target_version
453        .split('.')
454        .filter_map(|s: &str| s.parse::<u32>().ok())
455        .collect();
456
457    // Compare versions (simple comparison)
458    let current_num = current_parts.first().copied().unwrap_or(0) * 100
459        + current_parts.get(1).copied().unwrap_or(0);
460    let target_num = target_parts.first().copied().unwrap_or(0) * 100
461        + target_parts.get(1).copied().unwrap_or(0);
462
463    if current_num >= target_num {
464        return Ok(false); // Already at or above target version
465    }
466
467    // Create backup if requested
468    if backup {
469        let backup_path = path.with_extension("nika.yaml.bak");
470        std::fs::copy(path, &backup_path)?;
471    }
472
473    // Update schema version using regex replacement (preserves formatting)
474    let schema_regex =
475        regex::Regex::new(r#"schema:\s*["']?nika/workflow@[\d.]+["']?"#).map_err(|e| {
476            NikaError::ParseError {
477                details: format!("Regex error: {e}"),
478            }
479        })?;
480
481    let new_schema_line = format!(r#"schema: "nika/workflow@{target_version}""#);
482    let updated = schema_regex
483        .replace(&content, new_schema_line.as_str())
484        .to_string();
485
486    // If no replacement was made, the file might not have a schema line - add one
487    let updated = if updated == content {
488        // Prepend schema line at the beginning
489        format!("{new_schema_line}\n{content}")
490    } else {
491        updated
492    };
493
494    std::fs::write(path, updated)?;
495
496    Ok(true)
497}