1use clap::{Parser, Subcommand, ValueEnum};
2use runkernel::{CacheManager, GraphEdge, PipelineGraph};
3use runkernel_cli_support::{graph_to_text, ExplainResponse, ListResponse, MetadataResponse};
4use serde::Deserialize;
5use std::collections::BTreeMap;
6use std::path::{Path, PathBuf};
7use std::process::{Command as ProcessCommand, ExitStatus};
8
9const CONFIG_FILE: &str = "runkernel.toml";
10const EXPECTED_PROTOCOL_VERSION: u32 = 1;
11
12#[derive(Debug, Parser)]
13#[command(name = "runkernel")]
14#[command(about = "Code-native Rust workflow runner")]
15pub struct Cli {
16 #[arg(short, long, global = true)]
17 pub config: Option<PathBuf>,
18 #[arg(short, long, global = true)]
19 pub workflow: Option<String>,
20 #[arg(long, global = true)]
21 pub verbose: bool,
22 #[arg(long, global = true)]
23 pub release: bool,
24 #[arg(long, global = true)]
25 pub features: Option<String>,
26 #[arg(long, global = true)]
27 pub all_features: bool,
28 #[arg(long, global = true)]
29 pub no_default_features: bool,
30 #[command(subcommand)]
31 pub command: Command,
32}
33
34#[derive(Debug, Subcommand)]
35pub enum Command {
36 Run {
37 task: Option<String>,
38 #[arg(last = true)]
39 args: Vec<String>,
40 },
41 List,
42 Graph {
43 #[arg(long, value_enum, default_value_t = GraphFormat::Text)]
44 format: GraphFormat,
45 },
46 Explain {
47 task: String,
48 },
49 Init,
50 Workflows,
51 Cache {
52 #[command(subcommand)]
53 command: CacheCommand,
54 },
55}
56
57#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
58pub enum GraphFormat {
59 Text,
60 Json,
61 Dot,
62 Mermaid,
63}
64
65#[derive(Debug, Subcommand)]
66pub enum CacheCommand {
67 Clean,
68 Status,
69}
70
71#[derive(Clone, Debug, Deserialize)]
72pub struct RunkernelManifest {
73 pub workflow: BTreeMap<String, WorkflowConfig>,
74}
75
76#[derive(Clone, Debug, Deserialize)]
77pub struct WorkflowConfig {
78 pub package: Option<String>,
79 pub bin: Option<String>,
80 pub manifest_path: Option<PathBuf>,
81 pub working_dir: Option<PathBuf>,
82 pub default_task: Option<String>,
83 pub description: Option<String>,
84}
85
86#[derive(Clone, Debug)]
87pub struct ResolvedWorkflow {
88 pub name: String,
89 pub package: Option<String>,
90 pub bin: Option<String>,
91 pub manifest_path: PathBuf,
92 pub working_dir: PathBuf,
93 pub default_task: Option<String>,
94 pub description: Option<String>,
95}
96
97#[derive(Clone, Debug, PartialEq, Eq)]
98pub struct CliOutcome {
99 pub output: String,
100 pub exit_code: i32,
101}
102
103impl Cli {
104 pub fn parse_args() -> Self {
105 Self::parse()
106 }
107}
108
109pub fn run_cli(cli: Cli) -> anyhow::Result<CliOutcome> {
110 match &cli.command {
111 Command::Init => init_config(),
112 Command::Workflows => {
113 let (manifest, config_path) = load_manifest(cli.config.as_deref())?;
114 let config_dir = config_path.parent().unwrap_or_else(|| Path::new("."));
115 validate_manifest(&manifest, config_dir)?;
116 Ok(CliOutcome::success(format_workflows(&manifest)))
117 }
118 Command::Cache { command } => run_cache_command(command),
119 Command::Run { task, args } => {
120 let workflow = selected_workflow(&cli)?;
121 let task = task.clone().or(workflow.default_task.clone());
122 let mut protocol_args = vec!["__runkernel".to_string(), "run".to_string()];
123 if let Some(task) = task {
124 protocol_args.push(task);
125 }
126 if !args.is_empty() {
127 protocol_args.push("--".to_string());
128 protocol_args.extend(args.clone());
129 }
130 let status = run_protocol_status(&workflow, &protocol_args, &cli)?;
131 Ok(CliOutcome {
132 output: String::new(),
133 exit_code: exit_code(status),
134 })
135 }
136 Command::List => {
137 let workflow = selected_workflow(&cli)?;
138 validate_protocol(&workflow, ProtocolCapability::List, &cli)?;
139 let response: ListResponse = run_protocol_json(
140 &workflow,
141 &["__runkernel", "list", "--format", "json"],
142 &cli,
143 )?;
144 Ok(CliOutcome::success(format_list(&workflow.name, &response)))
145 }
146 Command::Graph { format } => {
147 let workflow = selected_workflow(&cli)?;
148 validate_protocol(&workflow, ProtocolCapability::Graph, &cli)?;
149 let graph: PipelineGraph = run_protocol_json(
150 &workflow,
151 &["__runkernel", "graph", "--format", "json"],
152 &cli,
153 )?;
154 Ok(CliOutcome::success(format_graph(
155 &workflow.name,
156 &graph,
157 *format,
158 )?))
159 }
160 Command::Explain { task } => {
161 let workflow = selected_workflow(&cli)?;
162 validate_protocol(&workflow, ProtocolCapability::Explain, &cli)?;
163 let response: ExplainResponse = run_protocol_json(
164 &workflow,
165 &["__runkernel", "explain", task, "--format", "json"],
166 &cli,
167 )?;
168 Ok(CliOutcome::success(format_explain(&response)))
169 }
170 }
171}
172
173#[derive(Clone, Copy, Debug)]
174enum ProtocolCapability {
175 List,
176 Graph,
177 Explain,
178}
179
180impl ProtocolCapability {
181 fn name(self) -> &'static str {
182 match self {
183 ProtocolCapability::List => "list",
184 ProtocolCapability::Graph => "graph",
185 ProtocolCapability::Explain => "explain",
186 }
187 }
188
189 fn supported_by(self, metadata: &MetadataResponse) -> bool {
190 match self {
191 ProtocolCapability::List => metadata.supports.list,
192 ProtocolCapability::Graph => metadata.supports.graph,
193 ProtocolCapability::Explain => metadata.supports.explain,
194 }
195 }
196}
197
198impl CliOutcome {
199 fn success(output: impl Into<String>) -> Self {
200 Self {
201 output: output.into(),
202 exit_code: 0,
203 }
204 }
205}
206
207fn init_config() -> anyhow::Result<CliOutcome> {
208 let path = PathBuf::from(CONFIG_FILE);
209 if path.exists() {
210 anyhow::bail!("{} already exists; refusing to overwrite it.", CONFIG_FILE);
211 }
212 let content = r#"[workflow.default]
213package = "ops"
214bin = "ops"
215manifest_path = "examples/ops/Cargo.toml"
216working_dir = "."
217default_task = "deploy-edge"
218description = "Example ops workflow"
219"#;
220 std::fs::write(&path, content)?;
221 Ok(CliOutcome::success(format!("Created {}", path.display())))
222}
223
224fn run_cache_command(command: &CacheCommand) -> anyhow::Result<CliOutcome> {
225 let manager = CacheManager::new();
226 match command {
227 CacheCommand::Clean => {
228 let result = manager.clean_all()?;
229 Ok(CliOutcome::success(format!(
230 "{} cache path: {}",
231 if result.removed {
232 "Removed"
233 } else {
234 "No cache found at"
235 },
236 result.path.display()
237 )))
238 }
239 CacheCommand::Status => Ok(CliOutcome::success(format!(
240 "Cache root: {}\nExists: {}",
241 manager.cache_root().display(),
242 manager.cache_root().exists()
243 ))),
244 }
245}
246
247fn selected_workflow(cli: &Cli) -> anyhow::Result<ResolvedWorkflow> {
248 let (manifest, config_path) = load_manifest(cli.config.as_deref())?;
249 let config_dir = config_path.parent().unwrap_or_else(|| Path::new("."));
250 validate_manifest(&manifest, config_dir)?;
251 let selected = select_workflow(&manifest, cli.workflow.as_deref())?;
252 resolve_workflow(config_dir, selected.0, selected.1)
253}
254
255fn load_manifest(config: Option<&Path>) -> anyhow::Result<(RunkernelManifest, PathBuf)> {
256 let path = match config {
257 Some(path) => path.to_path_buf(),
258 None => discover_manifest(std::env::current_dir()?)?,
259 };
260 let content = std::fs::read_to_string(&path)
261 .map_err(|e| anyhow::anyhow!("Failed to read {}: {}", path.display(), e))?;
262 let manifest = toml::from_str::<RunkernelManifest>(&content)
263 .map_err(|e| anyhow::anyhow!("Failed to parse {}: {}", path.display(), e))?;
264 Ok((manifest, path))
265}
266
267fn discover_manifest(start: PathBuf) -> anyhow::Result<PathBuf> {
268 for dir in start.ancestors() {
269 let candidate = dir.join(CONFIG_FILE);
270 if candidate.exists() {
271 return Ok(candidate);
272 }
273 }
274 anyhow::bail!(
275 "No runkernel.toml found.\n\nSearched from:\n {}\n\nCreate one with:\n runkernel init",
276 start.display()
277 )
278}
279
280fn validate_manifest(manifest: &RunkernelManifest, config_dir: &Path) -> anyhow::Result<()> {
281 if manifest.workflow.is_empty() {
282 anyhow::bail!("runkernel.toml must define at least one [workflow.<name>] entry");
283 }
284 for (name, workflow) in &manifest.workflow {
285 if name.trim().is_empty() {
286 anyhow::bail!("Workflow names must be non-empty");
287 }
288 if matches!(&workflow.package, Some(value) if value.trim().is_empty()) {
289 anyhow::bail!("Workflow '{}' has an empty package field", name);
290 }
291 if matches!(&workflow.bin, Some(value) if value.trim().is_empty()) {
292 anyhow::bail!("Workflow '{}' has an empty bin field", name);
293 }
294 if let Some(path) = &workflow.manifest_path {
295 let path = config_dir.join(path);
296 if !path.exists() {
297 anyhow::bail!(
298 "Workflow '{}' references manifest_path '{}', but that file does not exist.",
299 name,
300 workflow.manifest_path.as_ref().unwrap().display()
301 );
302 }
303 }
304 if let Some(path) = &workflow.working_dir {
305 let path = config_dir.join(path);
306 if !path.exists() {
307 anyhow::bail!(
308 "Workflow '{}' references working_dir '{}', but that directory does not exist.",
309 name,
310 workflow.working_dir.as_ref().unwrap().display()
311 );
312 }
313 }
314 }
315 Ok(())
316}
317
318fn select_workflow<'a>(
319 manifest: &'a RunkernelManifest,
320 requested: Option<&str>,
321) -> anyhow::Result<(&'a str, &'a WorkflowConfig)> {
322 if let Some(name) = requested {
323 return manifest
324 .workflow
325 .get_key_value(name)
326 .map(|(name, workflow)| (name.as_str(), workflow))
327 .ok_or_else(|| {
328 anyhow::anyhow!(
329 "Unknown workflow '{}'.\n\nAvailable workflows:\n {}",
330 name,
331 available_workflows(manifest)
332 )
333 });
334 }
335 if manifest.workflow.len() == 1 {
336 let (name, workflow) = manifest.workflow.iter().next().unwrap();
337 return Ok((name, workflow));
338 }
339 if let Some(workflow) = manifest.workflow.get("default") {
340 return Ok(("default", workflow));
341 }
342 anyhow::bail!(
343 "No workflow selected.\n\nThis project defines multiple workflows:\n {}\n\nUse:\n runkernel run --workflow <name> <task>",
344 available_workflows(manifest)
345 )
346}
347
348fn available_workflows(manifest: &RunkernelManifest) -> String {
349 manifest
350 .workflow
351 .keys()
352 .cloned()
353 .collect::<Vec<_>>()
354 .join("\n ")
355}
356
357fn resolve_workflow(
358 config_dir: &Path,
359 name: &str,
360 workflow: &WorkflowConfig,
361) -> anyhow::Result<ResolvedWorkflow> {
362 let manifest_path = workflow
363 .manifest_path
364 .clone()
365 .unwrap_or_else(|| PathBuf::from("Cargo.toml"));
366 let manifest_path = config_dir.join(manifest_path);
367 let working_dir = workflow
368 .working_dir
369 .clone()
370 .map(|path| config_dir.join(path))
371 .unwrap_or_else(|| config_dir.to_path_buf());
372 Ok(ResolvedWorkflow {
373 name: name.to_string(),
374 package: workflow.package.clone(),
375 bin: workflow.bin.clone(),
376 manifest_path,
377 working_dir,
378 default_task: workflow.default_task.clone(),
379 description: workflow.description.clone(),
380 })
381}
382
383fn build_cargo_command(
384 workflow: &ResolvedWorkflow,
385 protocol_args: &[String],
386 cli: &Cli,
387) -> ProcessCommand {
388 let mut command = ProcessCommand::new("cargo");
389 command.current_dir(&workflow.working_dir);
390 command.arg("run");
391 if !cli.verbose {
392 command.arg("--quiet");
393 }
394 if cli.release {
395 command.arg("--release");
396 }
397 if let Some(features) = &cli.features {
398 command.arg("--features").arg(features);
399 }
400 if cli.all_features {
401 command.arg("--all-features");
402 }
403 if cli.no_default_features {
404 command.arg("--no-default-features");
405 }
406 command.arg("--manifest-path").arg(&workflow.manifest_path);
407 if let Some(package) = &workflow.package {
408 command.arg("--package").arg(package);
409 }
410 if let Some(bin) = &workflow.bin {
411 command.arg("--bin").arg(bin);
412 }
413 command.arg("--");
414 command.args(protocol_args);
415 command
416}
417
418#[cfg(test)]
419fn command_args(command: &ProcessCommand) -> Vec<String> {
420 command
421 .get_args()
422 .map(|arg| arg.to_string_lossy().to_string())
423 .collect()
424}
425
426fn run_protocol_status(
427 workflow: &ResolvedWorkflow,
428 protocol_args: &[String],
429 cli: &Cli,
430) -> anyhow::Result<ExitStatus> {
431 let mut command = build_cargo_command(workflow, protocol_args, cli);
432 command.status().map_err(|e| {
433 anyhow::anyhow!(
434 "Failed to run workflow '{}' through Cargo: {}",
435 workflow.name,
436 e
437 )
438 })
439}
440
441fn run_protocol_json<T>(
442 workflow: &ResolvedWorkflow,
443 protocol_args: &[&str],
444 cli: &Cli,
445) -> anyhow::Result<T>
446where
447 T: serde::de::DeserializeOwned,
448{
449 let protocol_args: Vec<_> = protocol_args.iter().map(|arg| arg.to_string()).collect();
450 let metadata_request = is_metadata_request(&protocol_args);
451 let mut command = build_cargo_command(workflow, &protocol_args, cli);
452 let output = command.output().map_err(|e| {
453 anyhow::anyhow!(
454 "Failed to run workflow '{}' through Cargo: {}",
455 workflow.name,
456 e
457 )
458 })?;
459 if !output.status.success() {
460 if metadata_request {
461 anyhow::bail!(
462 "Failed to run workflow '{}' through Cargo while checking the runkernel protocol.\n\n{}\n\nWorkflow exited with status {}.\n{}",
463 workflow.name,
464 protocol_not_supported_message(),
465 output.status,
466 String::from_utf8_lossy(&output.stderr)
467 );
468 }
469 anyhow::bail!(
470 "Failed to run workflow '{}' through Cargo.\n{}",
471 workflow.name,
472 String::from_utf8_lossy(&output.stderr)
473 );
474 }
475 serde_json::from_slice(&output.stdout).map_err(|e| {
476 if metadata_request {
477 return anyhow::anyhow!(
478 "{}\n\nInvalid metadata JSON from workflow '{}': {}\n{}",
479 protocol_not_supported_message(),
480 workflow.name,
481 e,
482 String::from_utf8_lossy(&output.stdout)
483 );
484 }
485 anyhow::anyhow!(
486 "Workflow '{}' returned invalid protocol JSON: {}\n{}",
487 workflow.name,
488 e,
489 String::from_utf8_lossy(&output.stdout)
490 )
491 })
492}
493
494fn validate_protocol(
495 workflow: &ResolvedWorkflow,
496 capability: ProtocolCapability,
497 cli: &Cli,
498) -> anyhow::Result<()> {
499 let metadata: MetadataResponse = run_protocol_json(
500 workflow,
501 &["__runkernel", "metadata", "--format", "json"],
502 cli,
503 )?;
504 validate_metadata_response(&workflow.name, &metadata, capability)
505}
506
507fn validate_metadata_response(
508 workflow_name: &str,
509 metadata: &MetadataResponse,
510 capability: ProtocolCapability,
511) -> anyhow::Result<()> {
512 if metadata.protocol_version != EXPECTED_PROTOCOL_VERSION {
513 anyhow::bail!(
514 "Unsupported runkernel protocol version for workflow '{}'. Expected {}, got {}.",
515 workflow_name,
516 EXPECTED_PROTOCOL_VERSION,
517 metadata.protocol_version
518 );
519 }
520 if !capability.supported_by(metadata) {
521 anyhow::bail!(
522 "Workflow '{}' is missing required runkernel protocol capability '{}'.",
523 workflow_name,
524 capability.name()
525 );
526 }
527 Ok(())
528}
529
530fn is_metadata_request(args: &[String]) -> bool {
531 args.len() == 4
532 && args[0] == "__runkernel"
533 && args[1] == "metadata"
534 && args[2] == "--format"
535 && args[3] == "json"
536}
537
538fn protocol_not_supported_message() -> &'static str {
539 "Workflow binary does not appear to support the runkernel protocol.\n\nExpected:\n __runkernel metadata --format json\n\nCheck that the binary uses:\n runkernel_cli_support::RunkernelApp"
540}
541
542fn exit_code(status: ExitStatus) -> i32 {
543 status.code().unwrap_or(1)
544}
545
546fn format_workflows(manifest: &RunkernelManifest) -> String {
547 let mut output = String::from("Workflows:");
548 for (name, workflow) in &manifest.workflow {
549 output.push_str("\n ");
550 output.push_str(name);
551 if let Some(description) = &workflow.description {
552 output.push_str(" ");
553 output.push_str(description);
554 }
555 }
556 output
557}
558
559fn format_list(workflow: &str, response: &ListResponse) -> String {
560 let mut output = format!("Workflow: {workflow}\n\nTasks:");
561 for task in &response.tasks {
562 output.push_str("\n ");
563 output.push_str(&task.name);
564 if let Some(description) = &task.description {
565 output.push_str(" ");
566 output.push_str(description);
567 }
568 }
569 output
570}
571
572fn format_graph(
573 workflow: &str,
574 graph: &PipelineGraph,
575 format: GraphFormat,
576) -> anyhow::Result<String> {
577 match format {
578 GraphFormat::Text => Ok(graph_to_text(graph)),
579 GraphFormat::Json => Ok(serde_json::to_string_pretty(graph)?),
580 GraphFormat::Dot => graph_to_dot(workflow, graph),
581 GraphFormat::Mermaid => Ok(graph_to_mermaid(graph)),
582 }
583}
584
585fn graph_to_dot(workflow: &str, graph: &PipelineGraph) -> anyhow::Result<String> {
586 let mut output = format!("digraph \"{}\" {{\n rankdir=LR;\n", dot_escape(workflow));
587 for node in &graph.nodes {
588 output.push_str(" \"");
589 output.push_str(&dot_escape(&node.id));
590 output.push_str("\";\n");
591 }
592 for GraphEdge { from, to } in &graph.edges {
593 output.push_str(" \"");
594 output.push_str(&dot_escape(from));
595 output.push_str("\" -> \"");
596 output.push_str(&dot_escape(to));
597 output.push_str("\";\n");
598 }
599 output.push_str("}\n");
600 Ok(output)
601}
602
603fn graph_to_mermaid(graph: &PipelineGraph) -> String {
604 let mut output = String::from("graph TD");
605 for node in &graph.nodes {
606 output.push_str("\n ");
607 output.push_str(&mermaid_id(&node.id));
608 output.push_str("[\"");
609 output.push_str(&mermaid_label(&node.label));
610 output.push_str("\"]");
611 }
612 for edge in &graph.edges {
613 output.push_str("\n ");
614 output.push_str(&mermaid_id(&edge.from));
615 output.push_str(" --> ");
616 output.push_str(&mermaid_id(&edge.to));
617 }
618 output
619}
620
621fn dot_escape(value: &str) -> String {
622 value.replace('\\', "\\\\").replace('"', "\\\"")
623}
624
625fn mermaid_id(value: &str) -> String {
626 if value
627 .chars()
628 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
629 && !value.is_empty()
630 {
631 return value.to_string();
632 }
633
634 let mut id = String::from("task_");
635 for byte in value.as_bytes() {
636 id.push_str(&format!("{byte:02x}"));
637 }
638 id
639}
640
641fn mermaid_label(value: &str) -> String {
642 value.replace('\\', "\\\\").replace('"', "\\\"")
643}
644
645fn format_explain(response: &ExplainResponse) -> String {
646 let task = &response.task;
647 format!(
648 "Task: {}\n\nDescription:\n {}\n\nDependencies:\n {}\n\nDependents:\n {}\n\nCache:\n {}\n\nRollback:\n {}\n\nInputs:\n {}\n\nEnvironment:\n {}",
649 task.name,
650 task.description.as_deref().unwrap_or("none"),
651 join_or_none(&task.dependencies),
652 join_or_none(&task.dependents),
653 if task.cacheable { "enabled" } else { "disabled" },
654 if task.has_rollback { "enabled" } else { "disabled" },
655 join_or_none(&task.inputs),
656 join_or_none(&task.env_vars),
657 )
658}
659
660fn join_or_none(values: &[String]) -> String {
661 if values.is_empty() {
662 "none".to_string()
663 } else {
664 values.join("\n ")
665 }
666}
667
668#[cfg(test)]
669mod tests {
670 use super::*;
671 use clap::Parser;
672 use runkernel::{GraphNode, TaskExplanation};
673 use runkernel_cli_support::{ExplainResponse, ProtocolSupport, TaskListItem};
674 use std::time::{SystemTime, UNIX_EPOCH};
675
676 fn temp_dir(name: &str) -> PathBuf {
677 let dir = std::env::temp_dir().join(format!(
678 "runkernel-cli-{name}-{}",
679 SystemTime::now()
680 .duration_since(UNIX_EPOCH)
681 .unwrap_or_default()
682 .as_nanos()
683 ));
684 std::fs::create_dir_all(&dir).unwrap();
685 dir
686 }
687
688 fn write_manifest(root: &Path, content: &str) -> PathBuf {
689 let path = root.join(CONFIG_FILE);
690 std::fs::write(&path, content).unwrap();
691 path
692 }
693
694 fn manifest_content() -> &'static str {
695 r#"[workflow.default]
696package = "ops"
697bin = "ops"
698manifest_path = "Cargo.toml"
699working_dir = "."
700default_task = "deploy"
701description = "Ops workflow"
702"#
703 }
704
705 #[test]
706 fn test_parse_run_command_with_forwarded_args() {
707 let cli = Cli::try_parse_from([
708 "runkernel",
709 "run",
710 "deploy",
711 "--release",
712 "--",
713 "--target",
714 "prod",
715 ])
716 .unwrap();
717 assert!(cli.release);
718 let Command::Run { task, args } = cli.command else {
719 panic!("expected run command");
720 };
721 assert_eq!(task.as_deref(), Some("deploy"));
722 assert_eq!(args, vec!["--target", "prod"]);
723 }
724
725 #[test]
726 fn test_discover_manifest_from_nested_directory() {
727 let root = temp_dir("discover");
728 std::fs::write(
729 root.join("Cargo.toml"),
730 "[package]\nname='x'\nversion='0.1.0'\nedition='2021'\n",
731 )
732 .unwrap();
733 let manifest = write_manifest(&root, manifest_content());
734 let nested = root.join("a").join("b");
735 std::fs::create_dir_all(&nested).unwrap();
736
737 assert_eq!(discover_manifest(nested).unwrap(), manifest);
738 std::fs::remove_dir_all(root).ok();
739 }
740
741 #[test]
742 fn test_select_default_workflow() {
743 let manifest = toml::from_str::<RunkernelManifest>(
744 r#"[workflow.ops]
745package = "ops"
746[workflow.default]
747package = "default"
748"#,
749 )
750 .unwrap();
751 let (name, workflow) = select_workflow(&manifest, None).unwrap();
752 assert_eq!(name, "default");
753 assert_eq!(workflow.package.as_deref(), Some("default"));
754 }
755
756 #[test]
757 fn test_unknown_workflow_lists_available() {
758 let manifest = toml::from_str::<RunkernelManifest>(
759 r#"[workflow.ops]
760package = "ops"
761"#,
762 )
763 .unwrap();
764 let err = select_workflow(&manifest, Some("release")).unwrap_err();
765 assert!(err.to_string().contains("Unknown workflow 'release'"));
766 assert!(err.to_string().contains("ops"));
767 }
768
769 #[test]
770 fn test_build_cargo_command_ordering() {
771 let root = PathBuf::from("/tmp/project");
772 let workflow = ResolvedWorkflow {
773 name: "ops".to_string(),
774 package: Some("ops".to_string()),
775 bin: Some("ops".to_string()),
776 manifest_path: root.join("examples/ops/Cargo.toml"),
777 working_dir: root.clone(),
778 default_task: Some("deploy".to_string()),
779 description: None,
780 };
781 let cli = Cli {
782 config: None,
783 workflow: Some("ops".to_string()),
784 verbose: false,
785 release: true,
786 features: Some("cloud,edge".to_string()),
787 all_features: false,
788 no_default_features: true,
789 command: Command::Run {
790 task: Some("deploy".to_string()),
791 args: Vec::new(),
792 },
793 };
794 let args = command_args(&build_cargo_command(
795 &workflow,
796 &[
797 "__runkernel".to_string(),
798 "run".to_string(),
799 "deploy".to_string(),
800 "--".to_string(),
801 "--target".to_string(),
802 "prod".to_string(),
803 ],
804 &cli,
805 ));
806 assert_eq!(
807 args,
808 vec![
809 "run",
810 "--quiet",
811 "--release",
812 "--features",
813 "cloud,edge",
814 "--no-default-features",
815 "--manifest-path",
816 "/tmp/project/examples/ops/Cargo.toml",
817 "--package",
818 "ops",
819 "--bin",
820 "ops",
821 "--",
822 "__runkernel",
823 "run",
824 "deploy",
825 "--",
826 "--target",
827 "prod"
828 ]
829 );
830 }
831
832 #[test]
833 fn test_format_list() {
834 let output = format_list(
835 "ops",
836 &ListResponse {
837 tasks: vec![TaskListItem {
838 name: "deploy".to_string(),
839 description: Some("Deploy".to_string()),
840 dependencies: vec!["build".to_string()],
841 cacheable: false,
842 }],
843 },
844 );
845 assert!(output.contains("Workflow: ops"));
846 assert!(output.contains("deploy Deploy"));
847 }
848
849 #[test]
850 fn test_format_graph_variants() {
851 let graph = PipelineGraph {
852 nodes: vec![
853 GraphNode {
854 id: "lint".to_string(),
855 label: "lint".to_string(),
856 },
857 GraphNode {
858 id: "test".to_string(),
859 label: "test".to_string(),
860 },
861 ],
862 edges: vec![GraphEdge {
863 from: "lint".to_string(),
864 to: "test".to_string(),
865 }],
866 };
867 assert_eq!(
868 format_graph("ops", &graph, GraphFormat::Text).unwrap(),
869 "Tasks:\n lint\n test\n\nEdges:\n lint -> test"
870 );
871 assert!(format_graph("ops", &graph, GraphFormat::Dot)
872 .unwrap()
873 .contains("\"lint\" -> \"test\""));
874 assert!(format_graph("ops", &graph, GraphFormat::Mermaid)
875 .unwrap()
876 .contains("lint --> test"));
877 }
878
879 #[test]
880 fn test_format_graph_includes_isolated_nodes() {
881 let graph = PipelineGraph {
882 nodes: vec![GraphNode {
883 id: "build".to_string(),
884 label: "build".to_string(),
885 }],
886 edges: Vec::new(),
887 };
888
889 assert_eq!(
890 format_graph("ops", &graph, GraphFormat::Text).unwrap(),
891 "Tasks:\n build"
892 );
893 assert!(format_graph("ops", &graph, GraphFormat::Dot)
894 .unwrap()
895 .contains("\"build\";"));
896 assert_eq!(
897 format_graph("ops", &graph, GraphFormat::Mermaid).unwrap(),
898 "graph TD\n build[\"build\"]"
899 );
900 }
901
902 #[test]
903 fn test_graph_dot_escapes_quotes_and_backslashes() {
904 let graph = PipelineGraph {
905 nodes: vec![GraphNode {
906 id: "build\\\"quoted".to_string(),
907 label: "build".to_string(),
908 }],
909 edges: Vec::new(),
910 };
911
912 let dot = format_graph("ops\\\"workflow", &graph, GraphFormat::Dot).unwrap();
913
914 assert!(dot.contains("digraph \"ops\\\\\\\"workflow\""));
915 assert!(dot.contains("\"build\\\\\\\"quoted\";"));
916 }
917
918 fn metadata_with_support(protocol_version: u32, support: ProtocolSupport) -> MetadataResponse {
919 MetadataResponse {
920 protocol_version,
921 workflow_name: "ops".to_string(),
922 description: None,
923 runkernel_version: "0.1.0".to_string(),
924 supports: support,
925 }
926 }
927
928 fn all_support() -> ProtocolSupport {
929 ProtocolSupport {
930 list: true,
931 graph: true,
932 explain: true,
933 run_task: true,
934 run_all: true,
935 }
936 }
937
938 #[test]
939 fn test_validate_metadata_rejects_unsupported_protocol_version() {
940 let err = validate_metadata_response(
941 "ops",
942 &metadata_with_support(2, all_support()),
943 ProtocolCapability::List,
944 )
945 .unwrap_err();
946
947 assert!(err.to_string().contains("Expected 1, got 2"));
948 }
949
950 #[test]
951 fn test_validate_metadata_rejects_missing_capability() {
952 let mut support = all_support();
953 support.graph = false;
954 let err = validate_metadata_response(
955 "ops",
956 &metadata_with_support(1, support),
957 ProtocolCapability::Graph,
958 )
959 .unwrap_err();
960
961 assert!(err.to_string().contains("capability 'graph'"));
962 }
963
964 #[test]
965 fn test_format_explain() {
966 let output = format_explain(&ExplainResponse {
967 task: TaskExplanation {
968 name: "deploy".to_string(),
969 description: Some("Deploy app".to_string()),
970 dependencies: vec!["build".to_string()],
971 dependents: Vec::new(),
972 cacheable: false,
973 action: "shell".to_string(),
974 cache_mode: "disabled".to_string(),
975 inputs: Vec::new(),
976 env_vars: vec!["TARGET".to_string()],
977 shell: Some("Sh".to_string()),
978 has_rollback: true,
979 },
980 });
981 assert!(output.contains("Task: deploy"));
982 assert!(output.contains("Deploy app"));
983 assert!(output.contains("TARGET"));
984 }
985
986 #[test]
987 fn test_init_refuses_overwrite() {
988 let root = temp_dir("init");
989 let original = std::env::current_dir().unwrap();
990 std::env::set_current_dir(&root).unwrap();
991 write_manifest(&root, manifest_content());
992 let err = init_config().unwrap_err();
993 std::env::set_current_dir(original).unwrap();
994 assert!(err.to_string().contains("already exists"));
995 std::fs::remove_dir_all(root).ok();
996 }
997}