1use clap::Subcommand;
4use std::path::PathBuf;
5
6use colored::Colorize;
7
8use nika_engine::ast::{parse_analyzed, parse_workflow};
9use nika_engine::error::NikaError;
10
11#[derive(Subcommand)]
13pub enum WorkflowAction {
14 Edit {
16 file: PathBuf,
18 },
19
20 AddTask {
22 file: PathBuf,
24
25 #[arg(long)]
27 id: Option<String>,
28
29 #[arg(long, value_name = "VERB")]
31 verb: Option<String>,
32
33 #[arg(long)]
35 after: Option<String>,
36 },
37
38 Graph {
40 file: PathBuf,
42
43 #[arg(short, long, default_value = "ascii")]
45 format: String,
46
47 #[arg(short, long)]
49 output: Option<PathBuf>,
50 },
51
52 Check {
54 file: PathBuf,
56
57 #[arg(long)]
59 suggest: bool,
60
61 #[arg(long, default_value = "text")]
63 format: String,
64 },
65}
66
67pub async fn handle_workflow_command(action: WorkflowAction, quiet: bool) -> Result<(), NikaError> {
68 match action {
69 WorkflowAction::Edit { file } => {
70 let _ = (file, quiet);
73 Err(NikaError::ConfigError {
74 reason: "TUI feature not enabled. Rebuild with `--features tui`".to_string(),
75 })
76 }
77
78 WorkflowAction::AddTask {
79 file,
80 id,
81 verb,
82 after,
83 } => {
84 if !file.exists() {
86 return Err(NikaError::WorkflowNotFound {
87 path: file.to_string_lossy().to_string(),
88 });
89 }
90
91 let content = std::fs::read_to_string(&file)?;
93
94 let task_id = id.unwrap_or_else(|| {
96 format!("task_{}", chrono::Utc::now().timestamp_millis() % 10000)
97 });
98
99 let task_verb = verb.unwrap_or_else(|| "infer".to_string());
101
102 let new_task = match task_verb.as_str() {
104 "infer" => format!(
105 r#" - id: {task_id}
106 infer: "TODO: Add your prompt here"
107"#
108 ),
109 "exec" => format!(
110 r#" - id: {task_id}
111 exec: "echo 'TODO: Add your command here'"
112"#
113 ),
114 "fetch" => format!(
115 r#" - id: {task_id}
116 fetch:
117 url: "https://example.com/api"
118 method: GET
119"#
120 ),
121 "invoke" => format!(
122 r#" - id: {task_id}
123 invoke:
124 mcp: novanet
125 tool: novanet_context
126 params: {{}}
127"#
128 ),
129 "agent" => format!(
130 r#" - id: {task_id}
131 agent:
132 prompt: "TODO: Add your agent prompt here"
133 max_turns: 5
134"#
135 ),
136 _ => {
137 return Err(NikaError::ValidationError {
138 reason: format!(
139 "Unknown verb '{task_verb}'. Valid: infer, exec, fetch, invoke, agent"
140 ),
141 });
142 }
143 };
144
145 let mut lines: Vec<&str> = content.lines().collect();
147 let mut insert_index = None;
148
149 let mut in_tasks = false;
151 let mut after_task_end = None;
152
153 for (i, line) in lines.iter().enumerate() {
154 if line.trim() == "tasks:" {
155 in_tasks = true;
156 continue;
157 }
158 if in_tasks {
159 if line.trim().starts_with("- id:") {
161 if let Some(ref after_id) = after {
163 if line.contains(after_id) {
164 after_task_end = Some(i);
166 } else if after_task_end.is_some() {
167 insert_index = Some(i);
169 break;
170 }
171 }
172 }
173 if !line.starts_with(' ') && !line.starts_with('-') && line.contains(':') {
175 insert_index = Some(i);
176 break;
177 }
178 }
179 }
180
181 let insert_at = insert_index.unwrap_or(lines.len());
183
184 let new_task_lines: Vec<&str> = new_task.lines().collect();
186 for (j, task_line) in new_task_lines.iter().enumerate() {
187 lines.insert(insert_at + j, task_line);
188 }
189
190 let new_content = lines.join("\n");
192 std::fs::write(&file, new_content)?;
193
194 if !quiet {
195 println!(
196 "{} Added task '{}' ({}) to {}",
197 "✓".green(),
198 task_id.cyan(),
199 task_verb.yellow(),
200 file.display()
201 );
202 if let Some(after_id) = after {
203 println!(" {} Inserted after task '{}'", "→".cyan(), after_id);
204 }
205 }
206
207 Ok(())
208 }
209
210 WorkflowAction::Graph {
211 file,
212 format,
213 output,
214 } => {
215 if !file.exists() {
217 return Err(NikaError::WorkflowNotFound {
218 path: file.to_string_lossy().to_string(),
219 });
220 }
221
222 let content = std::fs::read_to_string(&file)?;
224 let workflow = parse_workflow(&content)?;
225
226 let graph_output = match format.as_str() {
228 "ascii" => generate_ascii_dag(&workflow),
229 "dot" => generate_dot_dag(&workflow),
230 "mermaid" => generate_mermaid_dag(&workflow),
231 _ => {
232 return Err(NikaError::ValidationError {
233 reason: format!("Unknown format '{format}'. Valid: ascii, dot, mermaid"),
234 });
235 }
236 };
237
238 match output {
240 Some(path) => {
241 std::fs::write(&path, &graph_output)?;
242 if !quiet {
243 println!(
244 "{} DAG written to {} (format: {})",
245 "✓".green(),
246 path.display(),
247 format.cyan()
248 );
249 }
250 }
251 None => {
252 println!("{graph_output}");
253 }
254 }
255
256 Ok(())
257 }
258
259 WorkflowAction::Check {
260 file,
261 suggest,
262 format,
263 } => {
264 if !file.exists() {
266 if format == "json" {
267 let error_json = serde_json::json!({
268 "valid": false,
269 "file": file.to_string_lossy(),
270 "error": format!("Workflow not found: {}", file.display()),
271 });
272 println!("{}", serde_json::to_string_pretty(&error_json)?);
273 }
274 return Err(NikaError::WorkflowNotFound {
275 path: file.to_string_lossy().to_string(),
276 });
277 }
278
279 let content = std::fs::read_to_string(&file)?;
281 let workflow = match parse_workflow(&content) {
282 Ok(w) => w,
283 Err(e) => {
284 if format == "json" {
285 let error_json = serde_json::json!({
286 "valid": false,
287 "file": file.to_string_lossy(),
288 "error": e.to_string(),
289 });
290 println!("{}", serde_json::to_string_pretty(&error_json)?);
291 }
292 return Err(e);
293 }
294 };
295
296 let mut issues: Vec<(String, String, String)> = Vec::new(); let mut suggestions: Vec<String> = Vec::new();
299
300 let schema = workflow.schema.clone();
302 if !schema.starts_with("nika/workflow@") {
303 issues.push((
304 "error".to_string(),
305 "NIKA-001".to_string(),
306 "Missing or invalid schema version".to_string(),
307 ));
308 } else if let Some(version) = schema.strip_prefix("nika/workflow@") {
309 if version != "0.12" && suggest {
310 suggestions.push(format!(
311 "Consider upgrading from @{version} to @0.12 for latest features"
312 ));
313 }
314 }
315
316 if workflow.tasks.is_empty() {
318 issues.push((
319 "error".to_string(),
320 "NIKA-010".to_string(),
321 "Workflow has no tasks".to_string(),
322 ));
323 }
324
325 let mut seen_ids = std::collections::HashSet::new();
327 for task in &workflow.tasks {
328 if !seen_ids.insert(&task.id) {
329 issues.push((
330 "error".to_string(),
331 "NIKA-141".to_string(),
332 format!("Duplicate task ID: '{}'", task.id),
333 ));
334 }
335 }
336
337 {
340 let mut providers_used: std::collections::HashSet<String> =
341 std::collections::HashSet::new();
342
343 providers_used.insert(workflow.provider.clone());
345
346 if let Ok(analyzed) = parse_analyzed(&content) {
348 for task in &analyzed.tasks {
349 if let Some(ref p) = task.provider {
350 providers_used.insert(p.clone());
351 }
352 }
353 }
354
355 for provider_name in &providers_used {
357 if let Some(provider) = nika_engine::core::find_provider(provider_name) {
358 if provider.requires_key && !provider.has_env_key() {
359 issues.push((
360 "warn".to_string(),
361 "NIKA-032".to_string(),
362 format!(
363 "{} not set (provider '{}' used in workflow)",
364 provider.env_var, provider_name
365 ),
366 ));
367 }
368 }
369 }
370 }
371
372 if suggest {
374 let mut referenced: std::collections::HashSet<&str> =
375 std::collections::HashSet::new();
376 for (source, target) in workflow.edges() {
377 referenced.insert(source);
378 referenced.insert(target);
379 }
380 for task in &workflow.tasks {
381 if let Some(ref with_spec) = task.with_spec {
382 for entry in with_spec.values() {
383 if let Some(task_ref) = entry.task_id() {
384 referenced.insert(task_ref);
385 }
386 }
387 }
388 }
389 for task in &workflow.tasks {
390 if !referenced.contains(task.id.as_str()) && workflow.tasks.len() > 1 {
391 if workflow.tasks.first().map(|t| &t.id) != Some(&task.id) {
393 suggestions.push(format!(
394 "Task '{}' is not referenced by any other task",
395 task.id
396 ));
397 }
398 }
399 }
400 }
401
402 let has_errors = issues.iter().any(|(level, _, _)| level == "error");
404 match format.as_str() {
405 "json" => {
406 let result = serde_json::json!({
407 "file": file.to_string_lossy(),
408 "valid": !has_errors,
409 "issues": issues.iter().map(|(level, code, msg)| {
410 serde_json::json!({
411 "level": level,
412 "code": code,
413 "message": msg
414 })
415 }).collect::<Vec<_>>(),
416 "suggestions": suggestions
417 });
418 println!("{}", serde_json::to_string_pretty(&result)?);
419
420 if has_errors {
422 let error_count = issues
423 .iter()
424 .filter(|(level, _, _)| level == "error")
425 .count();
426 return Err(NikaError::ValidationError {
427 reason: format!("{} validation error(s) found", error_count),
428 });
429 }
430 }
431 _ => {
432 if issues.is_empty() {
434 if !quiet {
435 println!("{} {} is valid", "✓".green(), file.display());
436 }
437 } else {
438 for (level, code, msg) in &issues {
439 let prefix = if level == "error" {
440 "✗".red()
441 } else {
442 "⚠".yellow()
443 };
444 println!("{} [{}] {}", prefix, code.cyan(), msg);
445 }
446 }
447
448 if suggest && !suggestions.is_empty() {
449 println!();
450 println!("{}", "Suggestions:".cyan().bold());
451 for suggestion in &suggestions {
452 println!(" {} {}", "→".cyan(), suggestion);
453 }
454 }
455
456 if has_errors {
457 let error_count = issues
458 .iter()
459 .filter(|(level, _, _)| level == "error")
460 .count();
461 return Err(NikaError::ValidationError {
462 reason: format!("{} validation error(s) found", error_count),
463 });
464 }
465 }
466 }
467
468 Ok(())
469 }
470 }
471}
472
473fn generate_ascii_dag(workflow: &nika_engine::ast::Workflow) -> String {
474 let mut output = String::new();
475 let name = "(unnamed)";
476 output.push_str("┌─────────────────────────────────────────┐\n");
477 output.push_str(&format!("│ DAG: {name}"));
478 let padding = 40usize.saturating_sub(name.len() + 6);
479 output.push_str(&" ".repeat(padding));
480 output.push_str("│\n");
481 output.push_str("├─────────────────────────────────────────┤\n");
482
483 for task in &workflow.tasks {
485 let verb_icon = match &task.action {
486 nika_engine::ast::TaskAction::Infer { .. } => "⚡",
487 nika_engine::ast::TaskAction::Exec { .. } => "📟",
488 nika_engine::ast::TaskAction::Fetch { .. } => "🛰️",
489 nika_engine::ast::TaskAction::Invoke { .. } => "🔌",
490 nika_engine::ast::TaskAction::Agent { .. } => "🐔",
491 };
492 let line = format!("│ {} {}", verb_icon, task.id);
493 let line_padding = 40usize.saturating_sub(task.id.len() + 4);
494 output.push_str(&format!("{}{}│\n", line, " ".repeat(line_padding)));
495 }
496
497 let edges = workflow.edges();
499 if !edges.is_empty() {
500 output.push_str("├─────────────────────────────────────────┤\n");
501 output.push_str("│ Edges: │\n");
502 for (source, target) in &edges {
503 let flow_str = format!(" {source} → {target}");
504 let flow_padding = 39usize.saturating_sub(flow_str.len());
505 output.push_str(&format!("│{}{}│\n", flow_str, " ".repeat(flow_padding)));
506 }
507 }
508
509 output.push_str("└─────────────────────────────────────────┘\n");
510 output
511}
512
513fn generate_dot_dag(workflow: &nika_engine::ast::Workflow) -> String {
515 let mut output = String::new();
516 let name = "workflow";
517 output.push_str(&format!("digraph {name} {{\n"));
518 output.push_str(" rankdir=LR;\n");
519 output.push_str(" node [shape=box, style=rounded];\n\n");
520
521 for task in &workflow.tasks {
523 let color = match &task.action {
524 nika_engine::ast::TaskAction::Infer { .. } => "lightblue",
525 nika_engine::ast::TaskAction::Exec { .. } => "lightgreen",
526 nika_engine::ast::TaskAction::Fetch { .. } => "lightyellow",
527 nika_engine::ast::TaskAction::Invoke { .. } => "lightpink",
528 nika_engine::ast::TaskAction::Agent { .. } => "plum",
529 };
530 output.push_str(&format!(
531 " {} [label=\"{}\", fillcolor={}, style=\"rounded,filled\"];\n",
532 task.id.replace('-', "_"),
533 task.id,
534 color
535 ));
536 }
537
538 output.push('\n');
540 for (source, target) in workflow.edges() {
541 output.push_str(&format!(
542 " {} -> {};\n",
543 source.replace('-', "_"),
544 target.replace('-', "_")
545 ));
546 }
547
548 output.push_str("}\n");
549 output
550}
551
552fn generate_mermaid_dag(workflow: &nika_engine::ast::Workflow) -> String {
554 let mut output = String::new();
555 output.push_str("```mermaid\ngraph LR\n");
556
557 for task in &workflow.tasks {
559 let shape = match &task.action {
560 nika_engine::ast::TaskAction::Infer { .. } => ("([", "])"), nika_engine::ast::TaskAction::Exec { .. } => ("[", "]"), nika_engine::ast::TaskAction::Fetch { .. } => ("{{", "}}"), nika_engine::ast::TaskAction::Invoke { .. } => ("[[", "]]"), nika_engine::ast::TaskAction::Agent { .. } => ("((", "))"), };
566 let verb = match &task.action {
567 nika_engine::ast::TaskAction::Infer { .. } => "infer",
568 nika_engine::ast::TaskAction::Exec { .. } => "exec",
569 nika_engine::ast::TaskAction::Fetch { .. } => "fetch",
570 nika_engine::ast::TaskAction::Invoke { .. } => "invoke",
571 nika_engine::ast::TaskAction::Agent { .. } => "agent",
572 };
573 output.push_str(&format!(
574 " {}{}{} : {}{}\n",
575 task.id.replace('-', "_"),
576 shape.0,
577 task.id,
578 verb,
579 shape.1
580 ));
581 }
582
583 output.push('\n');
585 for (source, target) in workflow.edges() {
586 output.push_str(&format!(
587 " {} --> {}\n",
588 source.replace('-', "_"),
589 target.replace('-', "_")
590 ));
591 }
592
593 output.push_str("```\n");
594 output
595}