Skip to main content

zoi_lua/api/
system.rs

1use mlua::{self, Lua, Table, Value};
2use std::fs;
3use std::io::{BufRead, BufReader, Write};
4use std::path::PathBuf;
5use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
6use std::sync::Mutex;
7
8struct InnerSession {
9    child: Child,
10    stdin: ChildStdin,
11    stdout: BufReader<ChildStdout>,
12    stderr_path: PathBuf,
13    sentinel: String,
14}
15
16impl Drop for InnerSession {
17    fn drop(&mut self) {
18        let _ = self.child.kill();
19        let _ = self.child.wait();
20        if self.stderr_path.exists() {
21            let _ = fs::remove_file(&self.stderr_path);
22        }
23    }
24}
25
26struct ShellSession {
27    inner: Mutex<InnerSession>,
28}
29
30impl ShellSession {
31    fn new(lua: &Lua, build_dir: &str) -> Result<Self, anyhow::Error> {
32        let sentinel = format!("---ZOI_CMD_COMPLETE_{}---", uuid::Uuid::new_v4());
33        let stderr_path = PathBuf::from(build_dir).join(format!(".zoi_stderr_{}", sentinel));
34
35        let mut child = if cfg!(target_os = "windows") {
36            Command::new("pwsh")
37                .arg("-NoProfile")
38                .arg("-NonInteractive")
39                .arg("-Command")
40                .arg("-")
41                .current_dir(build_dir)
42                .stdin(Stdio::piped())
43                .stdout(Stdio::piped())
44                .stderr(Stdio::null())
45                .spawn()?
46        } else {
47            Command::new("bash")
48                .arg("--noprofile")
49                .arg("--norc")
50                .current_dir(build_dir)
51                .stdin(Stdio::piped())
52                .stdout(Stdio::piped())
53                .stderr(Stdio::null())
54                .spawn()?
55        };
56
57        let mut stdin = child.stdin.take().expect("Failed to open stdin");
58        let stdout = BufReader::new(child.stdout.take().expect("Failed to open stdout"));
59
60        // Inject Zoi environment variables
61        let mut env_cmds = String::new();
62        let globals = lua.globals();
63
64        let vars = [
65            ("BUILD_TYPE", "BUILD_TYPE"),
66            ("SUBPKG", "SUBPKG"),
67            ("BUILD_DIR", "BUILD_DIR"),
68            ("STAGING_DIR", "STAGING_DIR"),
69        ];
70
71        for (lua_name, env_name) in vars {
72            if let Ok(val) = globals.get::<String>(lua_name) {
73                if cfg!(target_os = "windows") {
74                    env_cmds.push_str(&format!(
75                        "$env:{} = '{}'\n",
76                        env_name,
77                        val.replace("'", "''")
78                    ));
79                } else {
80                    env_cmds.push_str(&format!("export {}={:?}\n", env_name, val));
81                }
82            }
83        }
84
85        // Handle SYSTEM and ZOI tables
86        let tables = [("SYSTEM", "SYSTEM_"), ("ZOI", "ZOI_")];
87        for (table_name, prefix) in tables {
88            if let Ok(table) = globals.get::<Table>(table_name) {
89                for (k, v) in table.pairs::<String, Value>().flatten() {
90                    let val_str = match v {
91                        Value::String(s) => s
92                            .to_str()
93                            .map_err(|e| anyhow::anyhow!(e.to_string()))?
94                            .to_string(),
95                        Value::Integer(i) => i.to_string(),
96                        Value::Number(n) => n.to_string(),
97                        Value::Boolean(b) => b.to_string(),
98                        _ => continue,
99                    };
100                    if cfg!(target_os = "windows") {
101                        env_cmds.push_str(&format!(
102                            "$env:{}{} = '{}'\n",
103                            prefix,
104                            k.to_uppercase(),
105                            val_str.replace("'", "''")
106                        ));
107                    } else {
108                        env_cmds.push_str(&format!(
109                            "export {}{}={:?}\n",
110                            prefix,
111                            k.to_uppercase(),
112                            val_str
113                        ));
114                    }
115                }
116            }
117        }
118
119        stdin.write_all(env_cmds.as_bytes())?;
120        stdin.flush()?;
121
122        Ok(Self {
123            inner: Mutex::new(InnerSession {
124                child,
125                stdin,
126                stdout,
127                stderr_path,
128                sentinel,
129            }),
130        })
131    }
132}
133
134/// Exposes system command and patching utilities to the Lua environment.
135///
136/// These functions provide the bridge to the host operating system's tools:
137/// - `cmd`: Executes a shell command in a persistent session and captures its output and exit code.
138/// - `zpatch`: A wrapper around the `patch` command for applying diffs.
139///
140/// All commands are executed relative to the `BUILD_DIR` and respect the
141/// user's environment and Zoi's quiet/verbose settings.
142pub fn add_cmd_util(lua: &Lua, quiet: bool) -> Result<(), mlua::Error> {
143    let cmd_fn = lua.create_function(move |lua, command: String| {
144        let build_dir: String = lua.globals().get("BUILD_DIR")?;
145
146        let session_is_dead = if let Some(session) = lua.app_data_ref::<ShellSession>() {
147            let mut inner = session.inner.lock().unwrap();
148            inner.child.try_wait().map(|s| s.is_some()).unwrap_or(true)
149        } else {
150            true
151        };
152
153        if session_is_dead {
154            let session = ShellSession::new(lua, &build_dir)
155                .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
156            lua.set_app_data(session);
157        }
158
159        let session = lua
160            .app_data_ref::<ShellSession>()
161            .expect("ShellSession missing from app_data");
162        let mut inner = session.inner.lock().unwrap();
163
164        if !quiet {
165            println!("Executing: {}", command);
166        }
167
168        let sentinel = inner.sentinel.clone();
169
170        if cfg!(target_os = "windows") {
171            let stderr_path_str = inner.stderr_path.to_string_lossy().replace("'", "''");
172            let cmd_text = format!(
173                "$ErrorActionPreference = 'Continue'; & {{ {} }} 2> '{}'; \"{} $LASTEXITCODE\"\n",
174                command, stderr_path_str, sentinel
175            );
176            inner
177                .stdin
178                .write_all(cmd_text.as_bytes())
179                .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
180            inner
181                .stdin
182                .flush()
183                .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
184        } else {
185            let cmd_text = format!(
186                "{{ {} ; }} 2>{:?} ; printf \"\\n%s %d\\n\" {:?} $?\n",
187                command, inner.stderr_path, sentinel
188            );
189            inner
190                .stdin
191                .write_all(cmd_text.as_bytes())
192                .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
193            inner
194                .stdin
195                .flush()
196                .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
197        }
198
199        let mut stdout_accum = String::new();
200        let mut line = String::new();
201        let exit_code;
202
203        loop {
204            line.clear();
205            let n = inner
206                .stdout
207                .read_line(&mut line)
208                .map_err(|e| mlua::Error::RuntimeError(e.to_string()))?;
209            if n == 0 {
210                return Err(mlua::Error::RuntimeError(
211                    "Shell session ended unexpectedly".to_string(),
212                ));
213            }
214
215            if let Some(idx) = line.find(&sentinel) {
216                let out_part = &line[..idx];
217                stdout_accum.push_str(out_part);
218
219                let rest = line[idx..].strip_prefix(&sentinel).unwrap().trim();
220                exit_code = rest.parse::<i32>().unwrap_or(0);
221                break;
222            }
223            stdout_accum.push_str(&line);
224        }
225
226        let stderr = fs::read_to_string(&inner.stderr_path).unwrap_or_default();
227        if inner.stderr_path.exists() {
228            let _ = fs::File::create(&inner.stderr_path);
229        }
230
231        if exit_code != 0 && !quiet {
232            eprintln!("[cmd] {}", stderr);
233        }
234
235        Ok((stdout_accum.trim_end().to_string(), stderr, exit_code))
236    })?;
237    lua.globals().set("cmd", cmd_fn)?;
238    Ok(())
239}
240
241pub fn add_zpatch(lua: &Lua, quiet: bool) -> Result<(), mlua::Error> {
242    let zpatch_fn =
243        lua.create_function(move |lua, (patch_file, strip): (String, Option<u32>)| {
244            let build_dir: String = lua.globals().get("BUILD_DIR")?;
245            let strip_level = strip.unwrap_or(1);
246
247            if !quiet {
248                println!("Applying patch: {}", patch_file);
249            }
250
251            let output = std::process::Command::new("patch")
252                .arg(format!("-p{}", strip_level))
253                .arg("-i")
254                .arg(&patch_file)
255                .current_dir(&build_dir)
256                .output();
257
258            match output {
259                Ok(out) => {
260                    if !out.status.success() {
261                        let stderr = String::from_utf8_lossy(&out.stderr).to_string();
262                        return Err(mlua::Error::RuntimeError(format!(
263                            "patch failed: {}",
264                            stderr
265                        )));
266                    }
267                    if !quiet {
268                        println!("Successfully applied patch {}", patch_file);
269                    }
270                    Ok(())
271                }
272                Err(e) => Err(mlua::Error::RuntimeError(format!(
273                    "Failed to execute patch command: {}",
274                    e
275                ))),
276            }
277        })?;
278    lua.globals().set("zpatch", zpatch_fn)?;
279    Ok(())
280}