1use chrono::{DateTime, Utc};
2use serde::ser::SerializeStruct;
3use serde::{Deserialize, Serialize, Serializer};
4use std::collections::HashMap;
5use std::io::{self, Read, Write};
6use std::path::{Path, PathBuf};
7use std::process::{Command, ExitStatus, Stdio};
8use std::thread;
9use std::time::{Duration, Instant};
10use tracing::debug;
11use ulid::Ulid;
12
13#[derive(Debug, Clone, Deserialize)]
14pub struct Run<Dir = PathBuf> {
15 pub id: Ulid,
16 pub name: String,
17 pub command: Vec<String>,
18 pub run_dir: Dir,
19 pub project_root: PathBuf,
20}
21
22pub type UnpreparedRun = Run<()>;
23pub type PreparedRun = Run<PathBuf>;
24
25impl<Dir> Run<Dir> {
26 pub fn timestamp(&self) -> DateTime<Utc> {
27 let dt: DateTime<Utc> = self.id.datetime().into();
29 dt
30 }
31
32 fn gen_run_dir(&self, vault_dir: impl AsRef<Path>) -> PathBuf {
33 vault_dir
34 .as_ref()
35 .join(run_dir_relative_path(&self.id, &self.name))
36 }
37}
38
39#[must_use]
55pub fn run_dir_relative_path(id: &Ulid, name: &str) -> PathBuf {
56 let timestamp: DateTime<Utc> = id.datetime().into();
57 let date_str = timestamp.format("%Y-%m-%d").to_string();
58 let time_str = timestamp.format("%H%M%S").to_string();
59 PathBuf::from(date_str).join(format!("{time_str}-{name}"))
60}
61
62impl UnpreparedRun {
63 pub fn setup_run_dir(
64 &self,
65 vault_dir: impl AsRef<std::path::Path>,
66 max_retries: usize,
67 ) -> io::Result<PreparedRun> {
68 debug!(
69 "Setting up vault directory: {}",
70 vault_dir.as_ref().display()
71 );
72 setup_vault(&vault_dir)?;
73
74 let run_dir = {
76 let mut attempt = 0;
77 loop {
78 let candidate = self.gen_run_dir(&vault_dir);
79 debug!("Candidate run directory: {}", candidate.display());
80 if candidate.exists() {
81 debug!(
82 "Directory already exists, retrying (attempt {})",
83 attempt + 1
84 );
85 thread::sleep(Duration::from_millis(10 * (attempt as u64 + 1)));
87 attempt += 1;
88 if attempt >= max_retries {
89 return Err(io::Error::new(
90 io::ErrorKind::AlreadyExists,
91 format!(
92 "Failed to create unique run directory after {max_retries} attempts",
93 ),
94 ));
95 }
96 } else {
97 break candidate;
98 }
99 }
100 };
101
102 debug!("Creating run directory: {}", run_dir.display());
103 std::fs::create_dir_all(&run_dir)?;
104 debug!("Run directory created successfully");
105
106 Ok(PreparedRun {
107 id: self.id,
108 name: self.name.clone(),
109 command: self.command.clone(),
110 run_dir,
111 project_root: self.project_root.clone(),
112 })
113 }
114}
115
116impl Serialize for UnpreparedRun {
117 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
118 where
119 S: Serializer,
120 {
121 let mut state = serializer.serialize_struct("Run", 5)?;
122 state.serialize_field("id", &self.id)?;
123 state.serialize_field("name", &self.name)?;
124 state.serialize_field("command", &self.command)?;
125 state.serialize_field("timestamp", &self.timestamp().to_rfc3339())?;
126 state.serialize_field("project_root", &self.project_root.to_string_lossy())?;
127 state.end()
128 }
129}
130
131impl Serialize for PreparedRun {
132 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
133 where
134 S: Serializer,
135 {
136 let mut state = serializer.serialize_struct("Run", 6)?;
137 state.serialize_field("id", &self.id)?;
138 state.serialize_field("name", &self.name)?;
139 state.serialize_field("command", &self.command)?;
140 state.serialize_field("timestamp", &self.timestamp().to_rfc3339())?;
141 state.serialize_field("run_dir", &self.run_dir.to_string_lossy())?;
142 state.serialize_field("project_root", &self.project_root.to_string_lossy())?;
143 state.end()
144 }
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct RunOutput {
149 pub exit_code: i32,
150 stdout: String,
151 stderr: String,
152 duration: Duration,
153}
154
155fn exit_code_from_status(status: ExitStatus) -> i32 {
156 status.code().unwrap_or_else(|| {
157 #[cfg(unix)]
159 {
160 use std::os::unix::process::ExitStatusExt;
161 status.signal().map_or(1, |s| 128 + s)
162 }
163 #[cfg(not(unix))]
164 {
165 1
166 }
167 })
168}
169
170impl PreparedRun {
171 pub fn exec(&self) -> std::io::Result<RunOutput> {
172 if self.command.is_empty() {
173 return Err(io::Error::new(io::ErrorKind::InvalidInput, "empty command"));
174 }
175 let program = &self.command[0];
176 let args: Vec<&str> = self.command[1..].iter().map(String::as_str).collect();
177
178 debug!("Executing command: {} with {} args", program, args.len());
179
180 let mut env_vars = HashMap::new();
181 env_vars.insert("CAPSULA_RUN_ID", self.id.to_string());
182 env_vars.insert("CAPSULA_RUN_NAME", self.name.clone());
183 env_vars.insert(
184 "CAPSULA_RUN_DIRECTORY",
185 self.run_dir.to_string_lossy().to_string(),
186 );
187 env_vars.insert("CAPSULA_RUN_TIMESTAMP", self.timestamp().to_rfc3339());
188 let command_display = shlex::try_join(self.command.iter().map(String::as_str))
189 .unwrap_or_else(|_| self.command.join(" "));
190 env_vars.insert("CAPSULA_RUN_COMMAND", command_display);
191
192 let pre_run_output_json_path = self.run_dir.join("_capsula").join("pre-run.json");
193 env_vars.insert(
194 "CAPSULA_PRE_RUN_OUTPUT_PATH",
195 pre_run_output_json_path.to_string_lossy().to_string(),
196 );
197 env_vars.insert(
198 "CAPSULA_PROJECT_ROOT",
199 self.project_root.to_string_lossy().to_string(),
200 );
201
202 debug!(
203 "Setting {} environment variables for command execution",
204 env_vars.len()
205 );
206
207 let start = Instant::now();
208
209 debug!("Spawning child process");
210 let mut child = Command::new(program)
211 .args(&args)
212 .envs(&env_vars)
213 .stdout(Stdio::piped())
214 .stderr(Stdio::piped())
215 .spawn()?;
216
217 let mut child_stdout = child
218 .stdout
219 .take()
220 .ok_or_else(|| io::Error::other("Failed to capture stdout"))?;
221 let mut child_stderr = child
222 .stderr
223 .take()
224 .ok_or_else(|| io::Error::other("Failed to capture stderr"))?;
225
226 let t_out = thread::spawn(move || -> io::Result<Vec<u8>> {
227 let mut cap = Vec::with_capacity(8 * 1024);
228 let mut buf = [0u8; 8192];
229 let mut console = io::stdout().lock();
230
231 loop {
232 let n = child_stdout.read(&mut buf)?;
233 if n == 0 {
234 break;
235 }
236 console.write_all(&buf[..n])?;
237 cap.extend_from_slice(&buf[..n]);
238 }
239 console.flush()?;
240 Ok(cap)
241 });
242
243 let t_err = thread::spawn(move || -> io::Result<Vec<u8>> {
244 let mut cap = Vec::with_capacity(8 * 1024);
245 let mut buf = [0u8; 8192];
246 let mut console = io::stderr().lock();
247
248 loop {
249 let n = child_stderr.read(&mut buf)?;
250 if n == 0 {
251 break;
252 }
253 console.write_all(&buf[..n])?;
254 cap.extend_from_slice(&buf[..n]);
255 }
256 console.flush()?;
257 Ok(cap)
258 });
259
260 debug!("Waiting for child process to complete");
261 let status = child.wait()?;
262 let duration = start.elapsed();
263 debug!("Child process completed in {:?}", duration);
264
265 let cap_out = t_out
266 .join()
267 .map_err(|_| io::Error::other("stdout capture thread panicked"))??;
268 let cap_err = t_err
269 .join()
270 .map_err(|_| io::Error::other("stderr capture thread panicked"))??;
271
272 let exit_code = exit_code_from_status(status);
273 debug!(
274 "Command finished with exit code {}, captured {} bytes stdout, {} bytes stderr",
275 exit_code,
276 cap_out.len(),
277 cap_err.len()
278 );
279
280 Ok(RunOutput {
281 exit_code,
282 stdout: String::from_utf8_lossy(&cap_out).to_string(),
283 stderr: String::from_utf8_lossy(&cap_err).to_string(),
284 duration,
285 })
286 }
287}
288
289pub fn setup_vault(path: impl AsRef<std::path::Path>) -> io::Result<()> {
291 let path = path.as_ref();
292 if path.exists() {
293 debug!("Vault directory already exists: {}", path.display());
294 return Ok(());
295 }
296
297 debug!("Creating vault directory: {}", path.display());
298 std::fs::create_dir_all(path)?;
299
300 let gitignore_path = path.join(".gitignore");
302 debug!("Creating .gitignore in vault directory");
303 std::fs::write(
304 gitignore_path,
305 "\
306# Automatically generated by Capsula
307*",
308 )?;
309
310 Ok(())
311}