use anyhow::{Context, Result}; use clap::Subcommand; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use crate::client::ApiClient; use crate::config::CliConfig; use crate::output::{self, OutputFormat}; #[derive(Subcommand)] pub enum WorkflowCommands { /// Upload a workflow action from local YAML files to an existing pack. /// /// Reads the action YAML file, finds the referenced workflow YAML file /// via its `workflow_file` field, and uploads both to the API. The pack /// is determined from the action ref (e.g. `mypack.deploy` → pack `mypack`). Upload { /// Path to the action YAML file (e.g. actions/deploy.yaml). /// Must contain a `workflow_file` field pointing to the workflow YAML. action_file: String, /// Force update if the workflow already exists #[arg(short, long)] force: bool, }, /// List workflows List { /// Filter by pack reference #[arg(long)] pack: Option, /// Filter by tag (comma-separated) #[arg(long)] tags: Option, /// Search term (matches label/description) #[arg(long)] search: Option, }, /// Show details of a specific workflow Show { /// Workflow reference (e.g. core.install_packs) workflow_ref: String, }, /// Delete a workflow Delete { /// Workflow reference (e.g. core.install_packs) workflow_ref: String, /// Skip confirmation prompt #[arg(long)] yes: bool, }, } // ── Local YAML models (for parsing action YAML files) ────────────────── /// Minimal representation of an action YAML file, capturing only the fields /// we need to build a `SaveWorkflowFileRequest`. #[derive(Debug, Deserialize)] struct ActionYaml { /// Full action ref, e.g. `python_example.timeline_demo` #[serde(rename = "ref")] action_ref: String, /// Human-readable label #[serde(default)] label: String, /// Description #[serde(default)] description: Option, /// Relative path to the workflow YAML from the `actions/` directory workflow_file: Option, /// Parameter schema (flat format) #[serde(default)] parameters: Option, /// Output schema (flat format) #[serde(default)] output: Option, /// Tags #[serde(default)] tags: Option>, /// Whether the action is enabled #[serde(default)] enabled: Option, } // ── API DTOs ──────────────────────────────────────────────────────────── /// Mirrors the API's `SaveWorkflowFileRequest`. #[derive(Debug, Serialize)] struct SaveWorkflowFileRequest { name: String, label: String, #[serde(skip_serializing_if = "Option::is_none")] description: Option, version: String, pack_ref: String, definition: serde_json::Value, #[serde(skip_serializing_if = "Option::is_none")] param_schema: Option, #[serde(skip_serializing_if = "Option::is_none")] out_schema: Option, #[serde(skip_serializing_if = "Option::is_none")] tags: Option>, #[serde(skip_serializing_if = "Option::is_none")] enabled: Option, } #[derive(Debug, Serialize, Deserialize)] struct WorkflowResponse { id: i64, #[serde(rename = "ref")] workflow_ref: String, pack: i64, pack_ref: String, label: String, description: Option, version: String, param_schema: Option, out_schema: Option, definition: serde_json::Value, tags: Vec, enabled: bool, created: String, updated: String, } #[derive(Debug, Serialize, Deserialize)] struct WorkflowSummary { id: i64, #[serde(rename = "ref")] workflow_ref: String, pack_ref: String, label: String, description: Option, version: String, tags: Vec, enabled: bool, created: String, updated: String, } // ── Command dispatch ──────────────────────────────────────────────────── pub async fn handle_workflow_command( profile: &Option, command: WorkflowCommands, api_url: &Option, output_format: OutputFormat, ) -> Result<()> { match command { WorkflowCommands::Upload { action_file, force } => { handle_upload(profile, action_file, force, api_url, output_format).await } WorkflowCommands::List { pack, tags, search } => { handle_list(profile, pack, tags, search, api_url, output_format).await } WorkflowCommands::Show { workflow_ref } => { handle_show(profile, workflow_ref, api_url, output_format).await } WorkflowCommands::Delete { workflow_ref, yes } => { handle_delete(profile, workflow_ref, yes, api_url, output_format).await } } } // ── Upload ────────────────────────────────────────────────────────────── async fn handle_upload( profile: &Option, action_file: String, force: bool, api_url: &Option, output_format: OutputFormat, ) -> Result<()> { let action_path = Path::new(&action_file); // ── 1. Validate & read the action YAML ────────────────────────────── if !action_path.exists() { anyhow::bail!("Action YAML file not found: {}", action_file); } if !action_path.is_file() { anyhow::bail!("Path is not a file: {}", action_file); } let action_yaml_content = std::fs::read_to_string(action_path).context("Failed to read action YAML file")?; let action: ActionYaml = serde_yaml_ng::from_str(&action_yaml_content) .context("Failed to parse action YAML file")?; // ── 2. Extract pack_ref and workflow name from the action ref ──────── let (pack_ref, workflow_name) = split_action_ref(&action.action_ref)?; // ── 3. Resolve the workflow_file path ─────────────────────────────── let workflow_file_rel = action.workflow_file.as_deref().ok_or_else(|| { anyhow::anyhow!( "Action YAML does not contain a 'workflow_file' field. \ This command requires a workflow action — regular actions should be \ uploaded as part of a pack." ) })?; // workflow_file is relative to the actions/ directory. The action YAML is // typically at `/actions/.yaml`, so the workflow file is // resolved relative to the action YAML's parent directory. let workflow_path = resolve_workflow_path(action_path, workflow_file_rel)?; if !workflow_path.exists() { anyhow::bail!( "Workflow file not found: {}\n \ (resolved from workflow_file: '{}' relative to '{}')", workflow_path.display(), workflow_file_rel, action_path.parent().unwrap_or(Path::new(".")).display() ); } // ── 4. Read and parse the workflow YAML ───────────────────────────── let workflow_yaml_content = std::fs::read_to_string(&workflow_path).context("Failed to read workflow YAML file")?; let workflow_definition: serde_json::Value = serde_yaml_ng::from_str(&workflow_yaml_content) .context(format!( "Failed to parse workflow YAML file: {}", workflow_path.display() ))?; // Extract version from the workflow definition, defaulting to "1.0.0" let version = workflow_definition .get("version") .and_then(|v| v.as_str()) .unwrap_or("1.0.0") .to_string(); // ── 5. Build the API request ──────────────────────────────────────── // // Merge the action-level fields from the workflow definition back into the // definition payload (the API's SaveWorkflowFileRequest.definition carries // the full blob; write_workflow_yaml on the server side strips the action- // level fields before writing the graph-only file). let mut definition_map: serde_json::Map = if let Some(obj) = workflow_definition.as_object() { obj.clone() } else { serde_json::Map::new() }; // Ensure action-level fields are present in the definition (the API and // web UI store the combined form in the database; the server splits them // into two files on disk). if let Some(params) = &action.parameters { definition_map .entry("parameters".to_string()) .or_insert_with(|| params.clone()); } if let Some(out) = &action.output { definition_map .entry("output".to_string()) .or_insert_with(|| out.clone()); } let request = SaveWorkflowFileRequest { name: workflow_name.clone(), label: if action.label.is_empty() { workflow_name.clone() } else { action.label.clone() }, description: action.description.clone(), version, pack_ref: pack_ref.clone(), definition: serde_json::Value::Object(definition_map), param_schema: action.parameters.clone(), out_schema: action.output.clone(), tags: action.tags.clone(), enabled: action.enabled, }; // ── 6. Print progress ─────────────────────────────────────────────── if output_format == OutputFormat::Table { output::print_info(&format!( "Uploading workflow action '{}.{}' to pack '{}'", pack_ref, workflow_name, pack_ref, )); output::print_info(&format!(" Action YAML: {}", action_path.display())); output::print_info(&format!(" Workflow YAML: {}", workflow_path.display())); } // ── 7. Send to API ────────────────────────────────────────────────── let config = CliConfig::load_with_profile(profile.as_deref())?; let mut client = ApiClient::from_config(&config, api_url); let workflow_ref = format!("{}.{}", pack_ref, workflow_name); // Try create first; if 409 Conflict and --force, fall back to update. let create_path = format!("/packs/{}/workflow-files", pack_ref); let result: Result = client.post(&create_path, &request).await; let response: WorkflowResponse = match result { Ok(resp) => resp, Err(err) => { let err_str = err.to_string(); if err_str.contains("409") || err_str.to_lowercase().contains("conflict") { if !force { anyhow::bail!( "Workflow '{}' already exists. Use --force to update it.", workflow_ref ); } if output_format == OutputFormat::Table { output::print_info("Workflow already exists, updating..."); } let update_path = format!("/workflows/{}/file", workflow_ref); client.put(&update_path, &request).await.context( "Failed to update existing workflow. \ Check that the pack exists and the workflow ref is correct.", )? } else { return Err(err).context("Failed to upload workflow"); } } }; // ── 8. Print result ───────────────────────────────────────────────── match output_format { OutputFormat::Json | OutputFormat::Yaml => { output::print_output(&response, output_format)?; } OutputFormat::Table => { println!(); output::print_success(&format!( "Workflow '{}' uploaded successfully", response.workflow_ref )); output::print_key_value_table(vec![ ("ID", response.id.to_string()), ("Reference", response.workflow_ref.clone()), ("Pack", response.pack_ref.clone()), ("Label", response.label.clone()), ("Version", response.version.clone()), ( "Tags", if response.tags.is_empty() { "none".to_string() } else { response.tags.join(", ") }, ), ("Enabled", output::format_bool(response.enabled)), ]); } } Ok(()) } // ── List ──────────────────────────────────────────────────────────────── async fn handle_list( profile: &Option, pack: Option, tags: Option, search: Option, api_url: &Option, output_format: OutputFormat, ) -> Result<()> { let config = CliConfig::load_with_profile(profile.as_deref())?; let mut client = ApiClient::from_config(&config, api_url); let path = if let Some(ref pack_ref) = pack { format!("/packs/{}/workflows", pack_ref) } else { let mut query_parts: Vec = Vec::new(); if let Some(ref t) = tags { query_parts.push(format!("tags={}", urlencoding::encode(t))); } if let Some(ref s) = search { query_parts.push(format!("search={}", urlencoding::encode(s))); } if query_parts.is_empty() { "/workflows".to_string() } else { format!("/workflows?{}", query_parts.join("&")) } }; let workflows: Vec = client.get(&path).await?; match output_format { OutputFormat::Json | OutputFormat::Yaml => { output::print_output(&workflows, output_format)?; } OutputFormat::Table => { if workflows.is_empty() { output::print_info("No workflows found"); } else { let mut table = output::create_table(); output::add_header( &mut table, vec![ "ID", "Reference", "Pack", "Label", "Version", "Enabled", "Tags", ], ); for wf in &workflows { table.add_row(vec![ wf.id.to_string(), wf.workflow_ref.clone(), wf.pack_ref.clone(), output::truncate(&wf.label, 30), wf.version.clone(), output::format_bool(wf.enabled), if wf.tags.is_empty() { "-".to_string() } else { output::truncate(&wf.tags.join(", "), 25) }, ]); } println!("{}", table); output::print_info(&format!("{} workflow(s) found", workflows.len())); } } } Ok(()) } // ── Show ──────────────────────────────────────────────────────────────── async fn handle_show( profile: &Option, workflow_ref: String, api_url: &Option, output_format: OutputFormat, ) -> Result<()> { let config = CliConfig::load_with_profile(profile.as_deref())?; let mut client = ApiClient::from_config(&config, api_url); let path = format!("/workflows/{}", workflow_ref); let workflow: WorkflowResponse = client.get(&path).await?; match output_format { OutputFormat::Json | OutputFormat::Yaml => { output::print_output(&workflow, output_format)?; } OutputFormat::Table => { output::print_section(&format!("Workflow: {}", workflow.workflow_ref)); output::print_key_value_table(vec![ ("ID", workflow.id.to_string()), ("Reference", workflow.workflow_ref.clone()), ("Pack", workflow.pack_ref.clone()), ("Pack ID", workflow.pack.to_string()), ("Label", workflow.label.clone()), ( "Description", workflow .description .clone() .unwrap_or_else(|| "-".to_string()), ), ("Version", workflow.version.clone()), ("Enabled", output::format_bool(workflow.enabled)), ( "Tags", if workflow.tags.is_empty() { "none".to_string() } else { workflow.tags.join(", ") }, ), ("Created", output::format_timestamp(&workflow.created)), ("Updated", output::format_timestamp(&workflow.updated)), ]); // Show parameter schema if present if let Some(ref params) = workflow.param_schema { if !params.is_null() && params.as_object().is_some_and(|o| !o.is_empty()) { output::print_section("Parameters"); let yaml = serde_yaml_ng::to_string(params)?; println!("{}", yaml); } } // Show output schema if present if let Some(ref out) = workflow.out_schema { if !out.is_null() && out.as_object().is_some_and(|o| !o.is_empty()) { output::print_section("Output Schema"); let yaml = serde_yaml_ng::to_string(out)?; println!("{}", yaml); } } // Show task summary from definition if let Some(tasks) = workflow.definition.get("tasks") { if let Some(arr) = tasks.as_array() { output::print_section("Tasks"); let mut table = output::create_table(); output::add_header(&mut table, vec!["#", "Name", "Action", "Transitions"]); for (i, task) in arr.iter().enumerate() { let name = task.get("name").and_then(|v| v.as_str()).unwrap_or("?"); let action = task.get("action").and_then(|v| v.as_str()).unwrap_or("-"); let transition_count = task .get("next") .and_then(|v| v.as_array()) .map(|a| { // Count total target tasks across all transitions a.iter() .filter_map(|t| { t.get("do").and_then(|d| d.as_array()).map(|d| d.len()) }) .sum::() }) .unwrap_or(0); let transitions_str = if transition_count == 0 { "terminal".to_string() } else { format!("{} target(s)", transition_count) }; table.add_row(vec![ (i + 1).to_string(), name.to_string(), output::truncate(action, 35), transitions_str, ]); } println!("{}", table); } } } } Ok(()) } // ── Delete ────────────────────────────────────────────────────────────── async fn handle_delete( profile: &Option, workflow_ref: String, yes: bool, api_url: &Option, output_format: OutputFormat, ) -> Result<()> { let config = CliConfig::load_with_profile(profile.as_deref())?; let mut client = ApiClient::from_config(&config, api_url); if !yes && output_format == OutputFormat::Table { let confirm = dialoguer::Confirm::new() .with_prompt(format!( "Are you sure you want to delete workflow '{}'?", workflow_ref )) .default(false) .interact()?; if !confirm { output::print_info("Delete cancelled"); return Ok(()); } } let path = format!("/workflows/{}", workflow_ref); client.delete_no_response(&path).await?; match output_format { OutputFormat::Json | OutputFormat::Yaml => { let msg = serde_json::json!({"message": format!("Workflow '{}' deleted", workflow_ref)}); output::print_output(&msg, output_format)?; } OutputFormat::Table => { output::print_success(&format!("Workflow '{}' deleted successfully", workflow_ref)); } } Ok(()) } // ── Helpers ───────────────────────────────────────────────────────────── /// Split an action ref like `pack_name.action_name` into `(pack_ref, name)`. /// /// Supports multi-segment pack refs: `org.pack.action` → `("org.pack", "action")`. /// The last dot-separated segment is the workflow/action name; everything before /// it is the pack ref. fn split_action_ref(action_ref: &str) -> Result<(String, String)> { let dot_pos = action_ref.rfind('.').ok_or_else(|| { anyhow::anyhow!( "Invalid action ref '{}': expected format 'pack_ref.name' (at least one dot)", action_ref ) })?; let pack_ref = &action_ref[..dot_pos]; let name = &action_ref[dot_pos + 1..]; if pack_ref.is_empty() || name.is_empty() { anyhow::bail!( "Invalid action ref '{}': both pack_ref and name must be non-empty", action_ref ); } Ok((pack_ref.to_string(), name.to_string())) } /// Resolve the workflow YAML path from the action YAML's location and the /// `workflow_file` value. /// /// `workflow_file` is relative to the `actions/` directory. Since the action /// YAML is typically at `/actions/.yaml`, the workflow path is /// resolved relative to the action YAML's parent directory. fn resolve_workflow_path(action_yaml_path: &Path, workflow_file: &str) -> Result { let action_dir = action_yaml_path.parent().unwrap_or(Path::new(".")); let resolved = action_dir.join(workflow_file); // Canonicalize if possible (for better error messages), but don't fail // if the file doesn't exist yet — we'll check existence later. Ok(resolved) } #[cfg(test)] mod tests { use super::*; #[test] fn test_split_action_ref_simple() { let (pack, name) = split_action_ref("core.echo").unwrap(); assert_eq!(pack, "core"); assert_eq!(name, "echo"); } #[test] fn test_split_action_ref_multi_segment_pack() { let (pack, name) = split_action_ref("org.infra.deploy").unwrap(); assert_eq!(pack, "org.infra"); assert_eq!(name, "deploy"); } #[test] fn test_split_action_ref_no_dot() { assert!(split_action_ref("nodot").is_err()); } #[test] fn test_split_action_ref_empty_parts() { assert!(split_action_ref(".name").is_err()); assert!(split_action_ref("pack.").is_err()); } #[test] fn test_resolve_workflow_path() { let action_path = Path::new("/packs/mypack/actions/deploy.yaml"); let resolved = resolve_workflow_path(action_path, "workflows/deploy.workflow.yaml").unwrap(); assert_eq!( resolved, PathBuf::from("/packs/mypack/actions/workflows/deploy.workflow.yaml") ); } #[test] fn test_resolve_workflow_path_relative() { let action_path = Path::new("actions/deploy.yaml"); let resolved = resolve_workflow_path(action_path, "workflows/deploy.workflow.yaml").unwrap(); assert_eq!( resolved, PathBuf::from("actions/workflows/deploy.workflow.yaml") ); } }