Skip to main content

zig_core/workflow/
parser.rs

1use std::io::Read;
2use std::path::{Path, PathBuf};
3
4use crate::error::ZigError;
5use crate::workflow::model::Workflow;
6
7/// Parse a workflow from a TOML string.
8pub fn parse(content: &str) -> Result<Workflow, ZigError> {
9    let workflow: Workflow = toml::from_str(content).map_err(|e| ZigError::Parse(e.to_string()))?;
10    Ok(workflow)
11}
12
13/// Parse a plain `.zwf` workflow file from disk.
14///
15/// This does not handle `.zwfz` zip archives — use [`parse_workflow`] for
16/// that. If the file is a zip archive, it is extracted to a temp directory
17/// and the TOML workflow inside is parsed. The returned `WorkflowSource`
18/// must be kept alive for the duration of execution — dropping it cleans
19/// up any temp directory.
20pub fn parse_file(path: &Path) -> Result<Workflow, ZigError> {
21    let content = std::fs::read_to_string(path)
22        .map_err(|e| ZigError::Io(format!("failed to read {}: {e}", path.display())))?;
23    parse(&content)
24}
25
26/// Parse a workflow file, handling both plain `.zwf` and zipped `.zwfz`.
27///
28/// Returns the parsed `Workflow` and a `WorkflowSource` that tracks the
29/// effective directory for resolving relative file paths. The source must
30/// be kept alive during execution.
31pub fn parse_workflow(path: &Path) -> Result<(Workflow, WorkflowSource), ZigError> {
32    if is_zip_archive(path)? {
33        parse_zip(path)
34    } else {
35        let content = std::fs::read_to_string(path)
36            .map_err(|e| ZigError::Io(format!("failed to read {}: {e}", path.display())))?;
37        let wf = parse(&content)?;
38        let dir = path
39            .parent()
40            .unwrap_or_else(|| Path::new("."))
41            .to_path_buf();
42        Ok((wf, WorkflowSource::Directory(dir)))
43    }
44}
45
46/// Tracks where a workflow's associated files live.
47///
48/// For plain `.zwf` files, this is the parent directory. For `.zwfz` zip
49/// archives, this is a temp directory containing the extracted contents.
50/// Dropping the `Zip` variant cleans up the temp directory.
51#[derive(Debug)]
52pub enum WorkflowSource {
53    /// Plain TOML file on disk — resolve paths relative to this directory.
54    Directory(PathBuf),
55    /// Extracted zip archive — temp dir is cleaned up on drop.
56    Zip {
57        _temp_dir: tempfile::TempDir,
58        extract_dir: PathBuf,
59    },
60}
61
62impl WorkflowSource {
63    /// Get the effective directory for resolving relative file paths.
64    pub fn dir(&self) -> &Path {
65        match self {
66            WorkflowSource::Directory(dir) => dir,
67            WorkflowSource::Zip { extract_dir, .. } => extract_dir,
68        }
69    }
70}
71
72/// Check if a file is a zip archive by reading its magic bytes.
73fn is_zip_archive(path: &Path) -> Result<bool, ZigError> {
74    let mut file = std::fs::File::open(path)
75        .map_err(|e| ZigError::Io(format!("failed to open {}: {e}", path.display())))?;
76    let mut magic = [0u8; 4];
77    match file.read_exact(&mut magic) {
78        Ok(()) => Ok(&magic == b"PK\x03\x04"),
79        Err(_) => Ok(false), // File too short to be a zip
80    }
81}
82
83/// Extract a zip archive into a destination directory.
84///
85/// Used by both [`parse_zip`] (into a temp directory) and
86/// `update::run_update` (into a staging directory for in-place editing).
87/// Returns an error if any entry has an invalid path or cannot be written.
88pub fn extract_zip(archive_path: &Path, dest: &Path) -> Result<(), ZigError> {
89    let file = std::fs::File::open(archive_path)
90        .map_err(|e| ZigError::Io(format!("failed to open {}: {e}", archive_path.display())))?;
91    let mut archive = zip::ZipArchive::new(file)
92        .map_err(|e| ZigError::Parse(format!("failed to read zip archive: {e}")))?;
93
94    for i in 0..archive.len() {
95        let mut entry = archive
96            .by_index(i)
97            .map_err(|e| ZigError::Parse(format!("failed to read zip entry: {e}")))?;
98
99        let out_path = dest.join(
100            entry
101                .enclosed_name()
102                .ok_or_else(|| ZigError::Parse("zip entry has invalid path".into()))?,
103        );
104
105        if entry.is_dir() {
106            std::fs::create_dir_all(&out_path).map_err(|e| {
107                ZigError::Io(format!(
108                    "failed to create directory {}: {e}",
109                    out_path.display()
110                ))
111            })?;
112        } else {
113            if let Some(parent) = out_path.parent() {
114                std::fs::create_dir_all(parent).map_err(|e| {
115                    ZigError::Io(format!(
116                        "failed to create directory {}: {e}",
117                        parent.display()
118                    ))
119                })?;
120            }
121            let mut outfile = std::fs::File::create(&out_path).map_err(|e| {
122                ZigError::Io(format!("failed to create file {}: {e}", out_path.display()))
123            })?;
124            std::io::copy(&mut entry, &mut outfile).map_err(|e| {
125                ZigError::Io(format!("failed to extract {}: {e}", out_path.display()))
126            })?;
127        }
128    }
129
130    Ok(())
131}
132
133/// Parse a `.zwfz` zip archive.
134///
135/// Extracts the archive to a temp directory, finds the single TOML workflow
136/// file inside, and parses it.
137fn parse_zip(path: &Path) -> Result<(Workflow, WorkflowSource), ZigError> {
138    let temp_dir = tempfile::TempDir::new()
139        .map_err(|e| ZigError::Io(format!("failed to create temp directory: {e}")))?;
140
141    extract_zip(path, temp_dir.path())?;
142
143    // Find the single TOML workflow file
144    let toml_files: Vec<PathBuf> = find_workflow_files(temp_dir.path())?;
145
146    if toml_files.is_empty() {
147        return Err(ZigError::Parse(
148            "zip archive contains no .toml or .zwf workflow file".into(),
149        ));
150    }
151    if toml_files.len() > 1 {
152        return Err(ZigError::Parse(format!(
153            "zip archive contains {} workflow files (expected exactly one): {}",
154            toml_files.len(),
155            toml_files
156                .iter()
157                .map(|p| p.display().to_string())
158                .collect::<Vec<_>>()
159                .join(", ")
160        )));
161    }
162
163    let toml_path = &toml_files[0];
164    let content = std::fs::read_to_string(toml_path)
165        .map_err(|e| ZigError::Io(format!("failed to read {}: {e}", toml_path.display())))?;
166    let wf = parse(&content)?;
167
168    // The effective dir is the parent of the toml file within the temp dir
169    let extract_dir = toml_path.parent().unwrap_or(temp_dir.path()).to_path_buf();
170
171    Ok((
172        wf,
173        WorkflowSource::Zip {
174            _temp_dir: temp_dir,
175            extract_dir,
176        },
177    ))
178}
179
180/// Recursively find `.toml` and `.zwf` workflow files in a directory
181/// (only the top level and immediate subdirectories).
182pub fn find_workflow_files(dir: &Path) -> Result<Vec<PathBuf>, ZigError> {
183    let mut results = Vec::new();
184
185    fn scan_dir(dir: &Path, results: &mut Vec<PathBuf>, depth: usize) -> Result<(), ZigError> {
186        let entries = std::fs::read_dir(dir).map_err(|e| {
187            ZigError::Io(format!("failed to read directory {}: {e}", dir.display()))
188        })?;
189
190        for entry in entries {
191            let entry =
192                entry.map_err(|e| ZigError::Io(format!("failed to read directory entry: {e}")))?;
193            let path = entry.path();
194
195            if path.is_file() {
196                if let Some(ext) = path.extension() {
197                    if ext == "toml" || ext == "zwf" {
198                        // Quick check: does it look like a workflow TOML?
199                        if let Ok(content) = std::fs::read_to_string(&path) {
200                            if content.contains("[workflow]") {
201                                results.push(path);
202                            }
203                        }
204                    }
205                }
206            } else if path.is_dir() && depth < 1 {
207                scan_dir(&path, results, depth + 1)?;
208            }
209        }
210        Ok(())
211    }
212
213    scan_dir(dir, &mut results, 0)?;
214    Ok(results)
215}
216
217/// Serialize a workflow back to TOML (for the `create` command).
218pub fn to_toml(workflow: &Workflow) -> Result<String, ZigError> {
219    toml::to_string_pretty(workflow).map_err(|e| ZigError::Serialize(e.to_string()))
220}
221
222#[cfg(test)]
223#[path = "parser_tests.rs"]
224mod tests;