1use clap::Subcommand;
4use colored::Colorize;
5
6use nika_engine::error::NikaError;
7
8#[derive(Subcommand)]
10pub enum SchemaAction {
11 Upgrade {
13 path: String,
15
16 #[arg(short, long)]
18 target: Option<String>,
19
20 #[arg(long)]
22 all: bool,
23
24 #[arg(long, default_value = "true")]
26 backup: bool,
27 },
28
29 Version {
31 path: Option<String>,
33 },
34
35 Validate {
37 path: String,
39
40 #[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 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 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 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 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 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 let result = nika_engine::ast::raw::parse(&content, nika_engine::source::FileId(0));
166 match result {
167 Ok(raw_workflow) => {
168 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 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 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 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 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 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 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
393fn 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 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
426fn 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 let current_schema = extract_schema_version(&content);
437 let current = if current_schema == "(not specified)" {
438 "0.1".to_string()
439 } else {
440 current_schema
442 .strip_prefix("nika/workflow@")
443 .unwrap_or("0.1")
444 .to_string()
445 };
446
447 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 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); }
466
467 if backup {
469 let backup_path = path.with_extension("nika.yaml.bak");
470 std::fs::copy(path, &backup_path)?;
471 }
472
473 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 let updated = if updated == content {
488 format!("{new_schema_line}\n{content}")
490 } else {
491 updated
492 };
493
494 std::fs::write(path, updated)?;
495
496 Ok(true)
497}