001/* 002 * Copyright 2025 devteam@scivics-lab.com 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, 011 * software distributed under the License is distributed on an 012 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, 013 * either express or implied. See the License for the 014 * specific language governing permissions and limitations 015 * under the License. 016 */ 017 018package com.scivicslab.actoriac.cli; 019 020import java.io.File; 021import java.io.FileInputStream; 022import java.io.InputStream; 023import java.nio.file.Files; 024import java.nio.file.Path; 025import java.util.HashMap; 026import java.util.List; 027import java.util.Map; 028import java.util.concurrent.Callable; 029import java.util.stream.Stream; 030 031import com.scivicslab.pojoactor.workflow.kustomize.WorkflowKustomizer; 032 033import picocli.CommandLine.Command; 034import picocli.CommandLine.Option; 035 036/** 037 * CLI subcommand for displaying workflow descriptions. 038 * 039 * <p>Usage examples:</p> 040 * <pre> 041 * # Show workflow description only 042 * actor-iac describe -d ./workflows -w my-workflow 043 * 044 * # Show workflow description with step descriptions 045 * actor-iac describe -d ./workflows -w my-workflow --steps 046 * 047 * # With overlay 048 * actor-iac describe -d ./workflows -w my-workflow -o ./overlays/env --steps 049 * </pre> 050 * 051 * @author devteam@scivics-lab.com 052 * @since 2.10.0 053 */ 054@Command( 055 name = "describe", 056 mixinStandardHelpOptions = true, 057 version = "actor-IaC describe 2.10.0", 058 description = "Display workflow and step descriptions." 059) 060public class DescribeCLI implements Callable<Integer> { 061 062 @Option( 063 names = {"-d", "--dir"}, 064 description = "Directory containing workflow files (searched recursively)", 065 required = true 066 ) 067 private File workflowDir; 068 069 @Option( 070 names = {"-w", "--workflow"}, 071 description = "Name of the workflow to describe (with or without extension)", 072 required = true 073 ) 074 private String workflowName; 075 076 @Option( 077 names = {"-o", "--overlay"}, 078 description = "Overlay directory containing overlay-conf.yaml" 079 ) 080 private File overlayDir; 081 082 @Option( 083 names = {"--steps"}, 084 description = "Also display descriptions of each step" 085 ) 086 private boolean showSteps; 087 088 /** Cache of discovered workflow files: name -> File */ 089 private final Map<String, File> workflowCache = new HashMap<>(); 090 091 @Override 092 public Integer call() { 093 // Scan workflow directory 094 scanWorkflowDirectory(workflowDir); 095 096 // Find the workflow file 097 File workflowFile = findWorkflowFile(workflowName); 098 if (workflowFile == null) { 099 System.err.println("Workflow not found: " + workflowName); 100 System.err.println("Available workflows: " + workflowCache.keySet()); 101 return 1; 102 } 103 104 // Load YAML (with overlay if specified) 105 Map<String, Object> yaml; 106 if (overlayDir != null) { 107 yaml = loadYamlWithOverlay(workflowFile); 108 } else { 109 yaml = loadYamlFile(workflowFile); 110 } 111 112 if (yaml == null) { 113 System.err.println("Failed to load workflow: " + workflowFile); 114 return 1; 115 } 116 117 // Print workflow description 118 printWorkflowDescription(workflowFile, yaml); 119 120 return 0; 121 } 122 123 /** 124 * Prints workflow description. 125 */ 126 @SuppressWarnings("unchecked") 127 private void printWorkflowDescription(File file, Map<String, Object> yaml) { 128 String name = (String) yaml.getOrDefault("name", "(unnamed)"); 129 String description = (String) yaml.get("description"); 130 131 System.out.println("Workflow: " + name); 132 System.out.println("File: " + file.getAbsolutePath()); 133 if (overlayDir != null) { 134 System.out.println("Overlay: " + overlayDir.getAbsolutePath()); 135 } 136 System.out.println(); 137 138 // Workflow-level description 139 System.out.println("Description:"); 140 if (description != null && !description.isBlank()) { 141 for (String line : description.split("\n")) { 142 System.out.println(" " + line); 143 } 144 } else { 145 System.out.println(" (no description)"); 146 } 147 148 // Step descriptions (if --steps flag is set) 149 if (showSteps) { 150 System.out.println(); 151 System.out.println("Steps:"); 152 153 List<Map<String, Object>> steps = (List<Map<String, Object>>) yaml.get("steps"); 154 if (steps == null || steps.isEmpty()) { 155 System.out.println(" (no steps defined)"); 156 return; 157 } 158 159 for (Map<String, Object> step : steps) { 160 List<String> states = (List<String>) step.get("states"); 161 String vertexName = (String) step.get("vertexName"); 162 String stepDescription = (String) step.get("description"); 163 164 String stateTransition = (states != null && states.size() >= 2) 165 ? states.get(0) + " -> " + states.get(1) 166 : "?"; 167 168 String displayName = (vertexName != null) ? vertexName : "(unnamed)"; 169 170 System.out.println(); 171 System.out.println(" [" + stateTransition + "] " + displayName); 172 if (stepDescription != null && !stepDescription.isBlank()) { 173 for (String line : stepDescription.split("\n")) { 174 System.out.println(" " + line); 175 } 176 } else { 177 System.out.println(" (no description)"); 178 } 179 } 180 } 181 } 182 183 /** 184 * Scans the workflow directory recursively for workflow files. 185 */ 186 private void scanWorkflowDirectory(File directory) { 187 if (directory == null || !directory.exists()) { 188 return; 189 } 190 191 try (Stream<Path> paths = Files.walk(directory.toPath())) { 192 paths.filter(Files::isRegularFile) 193 .filter(path -> { 194 String name = path.getFileName().toString().toLowerCase(); 195 return name.endsWith(".yaml") || name.endsWith(".yml") 196 || name.endsWith(".json") || name.endsWith(".xml"); 197 }) 198 .forEach(path -> { 199 File file = path.toFile(); 200 String baseName = getBaseName(file.getName()); 201 workflowCache.putIfAbsent(baseName, file); 202 workflowCache.putIfAbsent(file.getName(), file); 203 }); 204 } catch (Exception e) { 205 System.err.println("Warning: Failed to scan directory: " + e.getMessage()); 206 } 207 } 208 209 /** 210 * Finds a workflow file by name. 211 */ 212 private File findWorkflowFile(String name) { 213 // Try exact match first 214 File file = workflowCache.get(name); 215 if (file != null) { 216 return file; 217 } 218 219 // Try with extensions 220 for (String ext : new String[]{".yaml", ".yml", ".json", ".xml"}) { 221 file = workflowCache.get(name + ext); 222 if (file != null) { 223 return file; 224 } 225 } 226 227 return null; 228 } 229 230 /** 231 * Loads a YAML file. 232 */ 233 private Map<String, Object> loadYamlFile(File file) { 234 try (InputStream is = new FileInputStream(file)) { 235 org.yaml.snakeyaml.Yaml yaml = new org.yaml.snakeyaml.Yaml(); 236 return yaml.load(is); 237 } catch (Exception e) { 238 System.err.println("Failed to load YAML file: " + file + " - " + e.getMessage()); 239 return null; 240 } 241 } 242 243 /** 244 * Loads a YAML file with overlay applied. 245 */ 246 private Map<String, Object> loadYamlWithOverlay(File workflowFile) { 247 try { 248 WorkflowKustomizer kustomizer = new WorkflowKustomizer(); 249 Map<String, Map<String, Object>> workflows = kustomizer.build(overlayDir.toPath()); 250 251 String targetName = workflowFile.getName(); 252 for (Map.Entry<String, Map<String, Object>> entry : workflows.entrySet()) { 253 if (entry.getKey().equals(targetName)) { 254 return entry.getValue(); 255 } 256 } 257 258 // Fall back to raw file 259 return loadYamlFile(workflowFile); 260 } catch (Exception e) { 261 System.err.println("Failed to apply overlay, loading raw file: " + e.getMessage()); 262 return loadYamlFile(workflowFile); 263 } 264 } 265 266 /** 267 * Gets the base name of a file (without extension). 268 */ 269 private static String getBaseName(String fileName) { 270 int dotIndex = fileName.lastIndexOf('.'); 271 if (dotIndex > 0) { 272 return fileName.substring(0, dotIndex); 273 } 274 return fileName; 275 } 276}