oxdock_core/
exec.rs

1use anyhow::{Context, Result, bail};
2use std::collections::HashMap;
3use std::fs;
4use std::io::{self, Write};
5use std::path::{Path, PathBuf};
6use std::process::{Child, Command as ProcessCommand, ExitStatus};
7
8use crate::ast::{Step, StepKind, WorkspaceTarget};
9use crate::resolver::PathResolver;
10
11pub fn run_steps(fs_root: &Path, steps: &[Step]) -> Result<()> {
12    run_steps_with_context(fs_root, fs_root, steps)
13}
14
15pub fn run_steps_with_context(fs_root: &Path, build_context: &Path, steps: &[Step]) -> Result<()> {
16    run_steps_with_context_result(fs_root, build_context, steps).map(|_| ())
17}
18
19/// Execute the DSL and return the final working directory after all steps.
20pub fn run_steps_with_context_result(
21    fs_root: &Path,
22    build_context: &Path,
23    steps: &[Step],
24) -> Result<PathBuf> {
25    match run_steps_inner(fs_root, build_context, steps) {
26        Ok(final_cwd) => Ok(final_cwd),
27        Err(err) => {
28            // Compose a single error message with the top cause plus a compact fs snapshot.
29            let chain = err.chain().map(|e| e.to_string()).collect::<Vec<_>>();
30            let primary = chain
31                .first()
32                .cloned()
33                .unwrap_or_else(|| "unknown error".into());
34            let rest = if chain.len() > 1 {
35                let causes = chain
36                    .iter()
37                    .skip(1)
38                    .map(|s| s.as_str())
39                    .collect::<Vec<_>>()
40                    .join("\n  ");
41                format!("\ncauses:\n  {}", causes)
42            } else {
43                String::new()
44            };
45            let tree = describe_dir(fs_root, 2, 24);
46            let snapshot = format!(
47                "filesystem snapshot (root {}):\n{}",
48                fs_root.display(),
49                tree
50            );
51            let msg = format!("{}{}\n{}", primary, rest, snapshot);
52            Err(anyhow::anyhow!(msg))
53        }
54    }
55}
56
57fn run_steps_inner(fs_root: &Path, build_context: &Path, steps: &[Step]) -> Result<PathBuf> {
58    let cargo_target_dir = fs_root.join(".cargo-target");
59    let mut resolver = PathResolver::new(fs_root, build_context);
60    let mut cwd = resolver.root().to_path_buf();
61    let mut envs: HashMap<String, String> = HashMap::new();
62    let mut bg_children: Vec<Child> = Vec::new();
63
64    let check_bg = |bg: &mut Vec<Child>| -> Result<Option<ExitStatus>> {
65        let mut finished: Option<ExitStatus> = None;
66        for child in bg.iter_mut() {
67            if let Some(status) = child.try_wait()? {
68                finished = Some(status);
69                break;
70            }
71        }
72        if let Some(status) = finished {
73            // Tear down remaining background children.
74            for child in bg.iter_mut() {
75                if child.try_wait()?.is_none() {
76                    let _ = child.kill();
77                    let _ = child.wait();
78                }
79            }
80            bg.clear();
81            return Ok(Some(status));
82        }
83        Ok(None)
84    };
85
86    for (idx, step) in steps.iter().enumerate() {
87        if !crate::ast::guards_allow_any(&step.guards, &envs) {
88            continue;
89        }
90        match &step.kind {
91            StepKind::Workdir(path) => {
92                cwd = resolver
93                    .resolve_workdir(&cwd, path)
94                    .with_context(|| format!("step {}: WORKDIR {}", idx + 1, path))?;
95            }
96            StepKind::Workspace(target) => match target {
97                WorkspaceTarget::Snapshot => {
98                    resolver.set_root(fs_root);
99                    cwd = resolver.root().to_path_buf();
100                }
101                WorkspaceTarget::Local => {
102                    resolver.set_root(build_context);
103                    cwd = resolver.root().to_path_buf();
104                }
105            },
106            StepKind::Env { key, value } => {
107                envs.insert(key.clone(), value.clone());
108            }
109            StepKind::Run(cmd) => {
110                let mut command = shell_cmd(cmd);
111                command.current_dir(&cwd);
112                command.envs(envs.iter());
113                // Prevent contention with the outer Cargo build by isolating nested cargo targets.
114                command.env("CARGO_TARGET_DIR", &cargo_target_dir);
115                run_cmd(&mut command).with_context(|| format!("step {}: RUN {}", idx + 1, cmd))?;
116            }
117            StepKind::Echo(msg) => {
118                let out = interpolate(msg, &envs);
119                println!("{}", out);
120            }
121            StepKind::RunBg(cmd) => {
122                let mut command = shell_cmd(cmd);
123                command.current_dir(&cwd);
124                command.envs(envs.iter());
125                command.env("CARGO_TARGET_DIR", &cargo_target_dir);
126                let child = command
127                    .spawn()
128                    .with_context(|| format!("step {}: RUN_BG {}", idx + 1, cmd))?;
129                bg_children.push(child);
130            }
131            StepKind::Copy { from, to } => {
132                let from_abs = resolver
133                    .resolve_copy_source(from)
134                    .with_context(|| format!("step {}: COPY {} {}", idx + 1, from, to))?;
135                let to_abs = resolver
136                    .resolve_write(&cwd, to)
137                    .with_context(|| format!("step {}: COPY {} {}", idx + 1, from, to))?;
138                copy_entry(&from_abs, &to_abs)
139                    .with_context(|| format!("step {}: COPY {} {}", idx + 1, from, to))?;
140            }
141            StepKind::Symlink { from, to } => {
142                let to_abs = resolver
143                    .resolve_write(&cwd, to)
144                    .with_context(|| format!("step {}: SYMLINK {} {}", idx + 1, from, to))?;
145                if to_abs.exists() {
146                    bail!("SYMLINK destination already exists: {}", to_abs.display());
147                }
148                let from_abs = resolver
149                    .resolve_copy_source(from)
150                    .with_context(|| format!("step {}: SYMLINK {} {}", idx + 1, from, to))?;
151                if from_abs == to_abs {
152                    bail!(
153                        "SYMLINK source resolves to the destination itself: {}",
154                        from_abs.display()
155                    );
156                }
157                #[cfg(unix)]
158                std::os::unix::fs::symlink(&from_abs, &to_abs)
159                    .with_context(|| format!("step {}: SYMLINK {} {}", idx + 1, from, to))?;
160                #[cfg(all(windows, not(unix)))]
161                std::os::windows::fs::symlink_dir(&from_abs, &to_abs)
162                    .with_context(|| format!("step {}: SYMLINK {} {}", idx + 1, from, to))?;
163                #[cfg(not(any(unix, windows)))]
164                copy_dir(&from_abs, &to_abs)?;
165            }
166            StepKind::Mkdir(path) => {
167                let target = resolver
168                    .resolve_write(&cwd, path)
169                    .with_context(|| format!("step {}: MKDIR {}", idx + 1, path))?;
170                fs::create_dir_all(&target)
171                    .with_context(|| format!("failed to create dir {}", target.display()))?;
172            }
173            StepKind::Ls(path_opt) => {
174                let dir = if let Some(p) = path_opt.as_deref() {
175                    resolver
176                        .resolve_read(&cwd, p)
177                        .with_context(|| format!("step {}: LS {}", idx + 1, p))?
178                } else {
179                    cwd.clone()
180                };
181                let mut entries: Vec<_> = fs::read_dir(&dir)
182                    .with_context(|| format!("failed to read dir {}", dir.display()))?
183                    .collect::<Result<_, _>>()?;
184                entries.sort_by_key(|a| a.file_name());
185                println!("{}:", dir.display());
186                for entry in entries {
187                    println!("{}", entry.file_name().to_string_lossy());
188                }
189            }
190            StepKind::Cat(path) => {
191                let target = resolver
192                    .resolve_read(&cwd, path)
193                    .with_context(|| format!("step {}: CAT {}", idx + 1, path))?;
194                let data = fs::read(&target)
195                    .with_context(|| format!("failed to read {}", target.display()))?;
196                let mut out = io::stdout();
197                out.write_all(&data)
198                    .with_context(|| format!("failed to write {} to stdout", target.display()))?;
199                out.flush().ok();
200            }
201            StepKind::Write { path, contents } => {
202                let target = resolver
203                    .resolve_write(&cwd, path)
204                    .with_context(|| format!("step {}: WRITE {}", idx + 1, path))?;
205                if let Some(parent) = target.parent() {
206                    fs::create_dir_all(parent)
207                        .with_context(|| format!("failed to create parent {}", parent.display()))?;
208                }
209                fs::write(&target, contents)
210                    .with_context(|| format!("failed to write {}", target.display()))?;
211            }
212            StepKind::Exit(code) => {
213                for child in bg_children.iter_mut() {
214                    if child.try_wait()?.is_none() {
215                        let _ = child.kill();
216                        let _ = child.wait();
217                    }
218                }
219                bg_children.clear();
220                bail!("EXIT requested with code {}", code);
221            }
222        }
223
224        if let Some(status) = check_bg(&mut bg_children)? {
225            if status.success() {
226                return Ok(cwd);
227            } else {
228                bail!("RUN_BG exited with status {}", status);
229            }
230        }
231    }
232
233    if !bg_children.is_empty() {
234        let mut first = bg_children.remove(0);
235        let status = first.wait()?;
236        for child in bg_children.iter_mut() {
237            if child.try_wait()?.is_none() {
238                let _ = child.kill();
239                let _ = child.wait();
240            }
241        }
242        bg_children.clear();
243        if status.success() {
244            return Ok(cwd);
245        } else {
246            bail!("RUN_BG exited with status {}", status);
247        }
248    }
249
250    Ok(cwd)
251}
252
253fn run_cmd(cmd: &mut ProcessCommand) -> Result<()> {
254    let status = cmd
255        .status()
256        .with_context(|| format!("failed to run {:?}", cmd))?;
257    if !status.success() {
258        bail!("command {:?} failed with status {}", cmd, status);
259    }
260    Ok(())
261}
262
263fn copy_entry(src: &Path, dst: &Path) -> Result<()> {
264    if !src.exists() {
265        bail!("source missing: {}", src.display());
266    }
267    let meta = src.metadata()?;
268    if meta.is_dir() {
269        copy_dir(src, dst)?;
270    } else if meta.is_file() {
271        if let Some(parent) = dst.parent() {
272            fs::create_dir_all(parent)
273                .with_context(|| format!("creating dir {}", parent.display()))?;
274        }
275        fs::copy(src, dst)
276            .with_context(|| format!("copying {} to {}", src.display(), dst.display()))?;
277    } else {
278        bail!("unsupported file type: {}", src.display());
279    }
280    Ok(())
281}
282
283fn copy_dir(src: &Path, dst: &Path) -> Result<()> {
284    fs::create_dir_all(dst).with_context(|| format!("creating dir {}", dst.display()))?;
285    for entry in fs::read_dir(src)? {
286        let entry = entry?;
287        let file_type = entry.file_type()?;
288        let src_path = entry.path();
289        let dst_path = dst.join(entry.file_name());
290        if file_type.is_dir() {
291            copy_dir(&src_path, &dst_path)?;
292        } else if file_type.is_file() {
293            fs::copy(&src_path, &dst_path).with_context(|| {
294                format!("copying {} to {}", src_path.display(), dst_path.display())
295            })?;
296        } else {
297            bail!("unsupported file type: {}", src_path.display());
298        }
299    }
300    Ok(())
301}
302
303fn describe_dir(root: &Path, max_depth: usize, max_entries: usize) -> String {
304    fn helper(path: &Path, depth: usize, max_depth: usize, left: &mut usize, out: &mut String) {
305        if *left == 0 {
306            return;
307        }
308        let indent = "  ".repeat(depth);
309        if depth > 0 {
310            out.push_str(&format!(
311                "{}{}\n",
312                indent,
313                path.file_name().unwrap_or_default().to_string_lossy()
314            ));
315        }
316        if depth >= max_depth {
317            return;
318        }
319        let entries = match fs::read_dir(path) {
320            Ok(e) => e,
321            Err(_) => return,
322        };
323        let mut names: Vec<_> = entries.filter_map(|e| e.ok()).collect();
324        names.sort_by_key(|a| a.file_name());
325        for entry in names {
326            if *left == 0 {
327                return;
328            }
329            *left -= 1;
330            let p = entry.path();
331            if p.is_dir() {
332                helper(&p, depth + 1, max_depth, left, out);
333            } else {
334                out.push_str(&format!(
335                    "{}  {}\n",
336                    indent,
337                    entry.file_name().to_string_lossy()
338                ));
339            }
340        }
341    }
342
343    let mut out = String::new();
344    let mut left = max_entries;
345    helper(root, 0, max_depth, &mut left, &mut out);
346    out
347}
348
349pub fn shell_program() -> String {
350    #[cfg(windows)]
351    {
352        std::env::var("COMSPEC").unwrap_or_else(|_| "cmd".to_string())
353    }
354
355    #[cfg(not(windows))]
356    {
357        std::env::var("SHELL").unwrap_or_else(|_| "sh".to_string())
358    }
359}
360
361fn shell_cmd(cmd: &str) -> ProcessCommand {
362    let program = shell_program();
363    let mut c = ProcessCommand::new(program);
364    if cfg!(windows) {
365        c.arg("/C").arg(cmd);
366    } else {
367        c.arg("-c").arg(cmd);
368    }
369    c
370}
371
372#[allow(clippy::while_let_on_iterator)]
373fn interpolate(template: &str, script_envs: &HashMap<String, String>) -> String {
374    let mut out = String::with_capacity(template.len());
375    let mut chars = template.chars().peekable();
376    while let Some(c) = chars.next() {
377        if c == '$' {
378            if let Some(&'{') = chars.peek() {
379                chars.next();
380                let mut name = String::new();
381                while let Some(&ch) = chars.peek() {
382                    chars.next();
383                    if ch == '}' {
384                        break;
385                    }
386                    name.push(ch);
387                }
388                if !name.is_empty() {
389                    let val = script_envs
390                        .get(&name)
391                        .cloned()
392                        .or_else(|| std::env::var(&name).ok())
393                        .unwrap_or_default();
394                    out.push_str(&val);
395                }
396            } else {
397                let mut name = String::new();
398                while let Some(&ch) = chars.peek() {
399                    if ch.is_ascii_alphanumeric() || ch == '_' {
400                        name.push(ch);
401                        chars.next();
402                    } else {
403                        break;
404                    }
405                }
406                if !name.is_empty() {
407                    let val = script_envs
408                        .get(&name)
409                        .cloned()
410                        .or_else(|| std::env::var(&name).ok())
411                        .unwrap_or_default();
412                    out.push_str(&val);
413                } else {
414                    out.push('$');
415                }
416            }
417        } else if c == '{' {
418            let mut name = String::new();
419            while let Some(ch) = chars.next() {
420                if ch == '}' {
421                    break;
422                }
423                name.push(ch);
424            }
425            if !name.is_empty() {
426                let val = script_envs
427                    .get(&name)
428                    .cloned()
429                    .or_else(|| std::env::var(&name).ok())
430                    .unwrap_or_default();
431                out.push_str(&val);
432            }
433        } else {
434            out.push(c);
435        }
436    }
437    out
438}