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