1use std::path::{Path, PathBuf};
2
3use serde::Serialize;
4
5use crate::error::ZigError;
6use crate::run::resolve_workflow_path;
7use crate::workflow::model::Workflow;
8use crate::workflow::parser;
9
10#[derive(Debug, Clone, Serialize)]
12pub struct WorkflowInfo {
13 pub name: String,
14 pub description: String,
15 pub step_count: usize,
16 pub path: String,
17 #[serde(skip_serializing_if = "std::ops::Not::not")]
19 pub is_local: bool,
20}
21
22pub fn get_workflow_list() -> Result<Vec<WorkflowInfo>, ZigError> {
31 let mut local_entries: Vec<PathBuf> = Vec::new();
32
33 if let Some(local_dir) = crate::paths::cwd_workflows_dir() {
34 collect_workflow_files(&local_dir, &mut local_entries);
35 local_entries.sort();
36 }
37
38 let local_filenames: Vec<_> = local_entries
40 .iter()
41 .filter_map(|p| p.file_name().map(|n| n.to_os_string()))
42 .collect();
43
44 let mut global_entries: Vec<PathBuf> = Vec::new();
45 let mut overridden_filenames: Vec<std::ffi::OsString> = Vec::new();
46
47 if let Some(global_dir) = crate::paths::global_workflows_dir() {
48 let mut global_all = Vec::new();
49 collect_workflow_files(&global_dir, &mut global_all);
50 for f in global_all {
51 if local_filenames
52 .iter()
53 .any(|ln| Some(ln.as_os_str()) == f.file_name())
54 {
55 overridden_filenames.push(f.file_name().unwrap().to_os_string());
56 } else {
57 global_entries.push(f);
58 }
59 }
60 global_entries.sort();
61 }
62
63 let mut infos = Vec::new();
64
65 for path in &local_entries {
66 let display = crate::paths::collapse_home(&path.display().to_string());
67 let is_override = path
68 .file_name()
69 .is_some_and(|n| overridden_filenames.iter().any(|o| o == n));
70 match parser::parse_file(path) {
71 Ok(wf) => {
72 infos.push(WorkflowInfo {
73 name: wf.workflow.name,
74 description: wf.workflow.description,
75 step_count: wf.steps.len(),
76 path: display,
77 is_local: is_override,
78 });
79 }
80 Err(_) => {
81 infos.push(WorkflowInfo {
82 name: "(parse error)".to_string(),
83 description: String::new(),
84 step_count: 0,
85 path: display,
86 is_local: is_override,
87 });
88 }
89 }
90 }
91
92 for path in &global_entries {
93 let display = crate::paths::collapse_home(&path.display().to_string());
94 match parser::parse_file(path) {
95 Ok(wf) => {
96 infos.push(WorkflowInfo {
97 name: wf.workflow.name,
98 description: wf.workflow.description,
99 step_count: wf.steps.len(),
100 path: display,
101 is_local: false,
102 });
103 }
104 Err(_) => {
105 infos.push(WorkflowInfo {
106 name: "(parse error)".to_string(),
107 description: String::new(),
108 step_count: 0,
109 path: display,
110 is_local: false,
111 });
112 }
113 }
114 }
115
116 Ok(infos)
117}
118
119pub fn get_workflow_detail(workflow: &str) -> Result<Workflow, ZigError> {
121 let path = resolve_workflow_path(workflow)?;
122 parser::parse_file(&path)
123}
124
125pub fn list_workflows() -> Result<(), ZigError> {
128 let infos = get_workflow_list()?;
129
130 if infos.is_empty() {
131 println!("No workflows found.");
132 println!("Hint: create one with `zig workflow create <name>`");
133 return Ok(());
134 }
135
136 let term_width = terminal_width().unwrap_or(100);
138
139 let name_w = infos
140 .iter()
141 .map(|r| {
142 if r.is_local {
143 r.name.len() + 2
144 } else {
145 r.name.len()
146 }
147 })
148 .max()
149 .unwrap_or(0)
150 .max(4);
151 let steps_w = infos
152 .iter()
153 .map(|r| format_steps(r.step_count).len())
154 .max()
155 .unwrap_or(0)
156 .max(5);
157
158 let fixed = name_w + steps_w + 8; let desc_w = if term_width > fixed + 20 {
162 term_width - fixed - 20
163 } else {
164 30
165 };
166 let desc_w = desc_w.max(11);
167
168 println!(
169 "\x1b[1m{:<name_w$}\x1b[0m {:<desc_w$} {:<steps_w$} PATH",
170 "NAME", "DESCRIPTION", "STEPS"
171 );
172 println!(
173 "{} {} {} {}",
174 "─".repeat(name_w),
175 "─".repeat(desc_w),
176 "─".repeat(steps_w),
177 "─".repeat(4)
178 );
179 let has_overrides = infos.iter().any(|i| i.is_local);
180
181 for info in &infos {
182 let desc = truncate(&info.description, desc_w);
183 let steps = format_steps(info.step_count);
184 let name_display = if info.is_local {
185 format!("{} *", info.name)
186 } else {
187 info.name.clone()
188 };
189 println!(
190 "\x1b[1m{:<name_w$}\x1b[0m {:<desc_w$} {:<steps_w$} {}",
191 name_display, desc, steps, info.path
192 );
193 }
194
195 if has_overrides {
196 println!("\n* local override");
197 }
198
199 Ok(())
200}
201
202pub fn show_workflow(workflow: &str) -> Result<(), ZigError> {
204 let path = resolve_workflow_path(workflow)?;
205 let wf = parser::parse_file(&path)?;
206
207 println!("Name: {}", wf.workflow.name);
208 println!("Path: {}", path.display());
209 if !wf.workflow.description.is_empty() {
210 println!("Description: {}", wf.workflow.description);
211 }
212 if !wf.workflow.tags.is_empty() {
213 println!("Tags: {}", wf.workflow.tags.join(", "));
214 }
215 if let Some(ref version) = wf.workflow.version {
216 println!("Version: {version}");
217 }
218 if let Some(ref provider) = wf.workflow.provider {
219 print!("Provider: {provider}");
220 if let Some(ref model) = wf.workflow.model {
221 print!(" / {model}");
222 }
223 println!();
224 } else if let Some(ref model) = wf.workflow.model {
225 println!("Model: {model}");
226 }
227
228 if !wf.vars.is_empty() {
229 println!("\nVariables:");
230 let mut vars: Vec<_> = wf.vars.iter().collect();
231 vars.sort_by_key(|(name, _)| (*name).clone());
232 for (name, var) in &vars {
233 let default = match &var.default {
234 Some(v) => format!(" = {v}"),
235 None => String::new(),
236 };
237 println!(" {name}: {}{default}", var.var_type);
238 if !var.description.is_empty() {
239 println!(" {}", var.description);
240 }
241 }
242 }
243
244 if !wf.steps.is_empty() {
245 println!("\nSteps ({}):", wf.steps.len());
246 for (i, step) in wf.steps.iter().enumerate() {
247 print!(" {}. {}", i + 1, step.name);
248 if !step.depends_on.is_empty() {
249 print!(" (depends on: {})", step.depends_on.join(", "));
250 }
251 println!();
252 if !step.description.is_empty() {
253 println!(" {}", step.description);
254 }
255 if let Some(condition) = &step.condition {
256 println!(" condition: {condition}");
257 }
258 if let Some(provider) = &step.provider {
259 print!(" provider: {provider}");
260 if let Some(model) = &step.model {
261 print!(" / {model}");
262 }
263 println!();
264 } else if let Some(model) = &step.model {
265 println!(" model: {model}");
266 }
267 }
268 }
269
270 Ok(())
271}
272
273pub fn delete_workflow(workflow: &str) -> Result<(), ZigError> {
275 let path = resolve_workflow_path(workflow)?;
276 std::fs::remove_file(&path)
277 .map_err(|e| ZigError::Io(format!("failed to delete {}: {e}", path.display())))?;
278 println!("deleted {}", path.display());
279 Ok(())
280}
281
282fn truncate(s: &str, max: usize) -> String {
284 if s.len() <= max {
285 s.to_string()
286 } else if max <= 1 {
287 "…".to_string()
288 } else {
289 format!("{}…", &s[..max - 1])
290 }
291}
292
293fn format_steps(count: usize) -> String {
295 if count == 1 {
296 "1 step".to_string()
297 } else {
298 format!("{count} steps")
299 }
300}
301
302fn terminal_width() -> Option<usize> {
304 std::env::var("COLUMNS").ok().and_then(|v| v.parse().ok())
305}
306
307#[cfg(test)]
310fn discover_workflow_files(base: &Path) -> Vec<PathBuf> {
311 let mut files = Vec::new();
312
313 collect_workflow_files(base, &mut files);
314 collect_workflow_files(&base.join("workflows"), &mut files);
315
316 files.sort();
317 files
318}
319
320fn collect_workflow_files(dir: &Path, out: &mut Vec<PathBuf>) {
322 if let Ok(entries) = std::fs::read_dir(dir) {
323 for entry in entries.flatten() {
324 let path = entry.path();
325 if path
326 .extension()
327 .is_some_and(|ext| ext == "zwf" || ext == "zwfz")
328 && path.is_file()
329 {
330 out.push(path);
331 }
332 }
333 }
334}
335
336#[cfg(test)]
337#[path = "manage_tests.rs"]
338mod tests;