Skip to main content

runkernel_cli/
lib.rs

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}