Skip to main content

zig_core/
manage.rs

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/// Summary information about a discovered workflow file.
11#[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    /// `true` when this workflow is a local override of a same-named global workflow.
18    #[serde(skip_serializing_if = "std::ops::Not::not")]
19    pub is_local: bool,
20}
21
22/// Return structured data about all discovered workflows.
23///
24/// Discovery order:
25/// 1. Local project `.zig/workflows/` (walk up to git root)
26/// 2. Global `~/.zig/workflows/`
27///
28/// When a local workflow has the same filename as a global one, the local
29/// workflow takes precedence and is marked with `is_local = true`.
30pub 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    // Track which filenames are provided locally so we can detect overrides.
39    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 = 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 = 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
119/// Return the parsed workflow for a given workflow name or path.
120pub fn get_workflow_detail(workflow: &str) -> Result<Workflow, ZigError> {
121    let path = resolve_workflow_path(workflow)?;
122    parser::parse_file(&path)
123}
124
125/// List all `.zwf`/`.zwfz` workflow files found in the current directory,
126/// `./workflows/`, and the global `~/.zig/workflows/` directory.
127pub 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    // Determine terminal width, default to 100 if unavailable.
137    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    // Reserve space for name, steps, separators, and a minimum path column,
159    // then give the rest to description.
160    let fixed = name_w + steps_w + 8; // 3 x 2-char gaps + 2 for padding
161    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
202/// Show detailed information about a workflow.
203pub 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
273/// Delete a workflow file.
274pub 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
282/// Truncate a string to `max` characters, appending "…" if truncated.
283fn 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
293/// Format step count concisely.
294fn format_steps(count: usize) -> String {
295    if count == 1 {
296        "1 step".to_string()
297    } else {
298        format!("{count} steps")
299    }
300}
301
302/// Try to detect terminal width from the COLUMNS environment variable.
303fn terminal_width() -> Option<usize> {
304    std::env::var("COLUMNS").ok().and_then(|v| v.parse().ok())
305}
306
307/// Discover all `.zwf`/`.zwfz` files in a base directory and its `workflows/`
308/// subdirectory.
309#[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
320/// Collect `.zwf` and `.zwfz` workflow files from a single directory into `out`.
321fn 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;