1use chrono::{DateTime, Utc};
2use serde::ser::SerializeStruct;
3use serde::{Deserialize, Serialize, Serializer};
4use shlex::try_join;
5use std::collections::HashMap;
6use std::io::{self, Read, Write};
7use std::path::{Path, PathBuf};
8use std::process::{Command, ExitStatus, Stdio};
9use std::thread;
10use std::time::{Duration, Instant};
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}
20
21pub type UnpreparedRun = Run<()>;
22pub type PreparedRun = Run<PathBuf>;
23
24impl<Dir> Run<Dir> {
25 pub fn timestamp(&self) -> DateTime<Utc> {
26 let dt: DateTime<Utc> = self.id.datetime().into();
28 dt
29 }
30
31 pub fn gen_run_dir(&self, vault_dir: impl AsRef<Path>) -> PathBuf {
32 let timestamp = self.timestamp();
33 let date_str = timestamp.format("%Y-%m-%d").to_string();
34 let time_str = timestamp.format("%H%M%S").to_string();
35
36 let run_dir_name = format!("{}-{}", time_str, self.name);
44 vault_dir.as_ref().join(date_str).join(&run_dir_name)
45 }
46}
47
48impl Run<()> {
49 pub fn setup_run_dir(
50 &self,
51 vault_dir: impl AsRef<std::path::Path>,
52 max_retries: usize,
53 ) -> io::Result<Run<PathBuf>> {
54 setup_vault(&vault_dir)?;
55
56 let run_dir = {
58 let mut attempt = 0;
59 loop {
60 let candidate = self.gen_run_dir(&vault_dir);
61 if candidate.exists() {
62 thread::sleep(Duration::from_millis(10 * (attempt as u64 + 1)));
64 attempt += 1;
65 if attempt >= max_retries {
66 return Err(io::Error::new(
67 io::ErrorKind::AlreadyExists,
68 format!(
69 "Failed to create unique run directory after {} attempts",
70 max_retries
71 ),
72 ));
73 }
74 continue;
75 } else {
76 break candidate;
77 }
78 }
79 };
80
81 std::fs::create_dir_all(&run_dir)?;
82 Ok(Run {
83 id: self.id,
84 name: self.name.clone(),
85 command: self.command.clone(),
86 run_dir,
87 })
88 }
89}
90
91impl Serialize for Run<()> {
92 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
93 where
94 S: Serializer,
95 {
96 let mut state = serializer.serialize_struct("Run", 4)?;
97 state.serialize_field("id", &self.id)?;
98 state.serialize_field("name", &self.name)?;
99 state.serialize_field("command", &self.command)?;
100 state.serialize_field("timestamp", &self.timestamp().to_rfc3339())?;
101 state.end()
102 }
103}
104
105impl Serialize for Run<PathBuf> {
106 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
107 where
108 S: Serializer,
109 {
110 let mut state = serializer.serialize_struct("Run", 5)?;
111 state.serialize_field("id", &self.id)?;
112 state.serialize_field("name", &self.name)?;
113 state.serialize_field("command", &self.command)?;
114 state.serialize_field("timestamp", &self.timestamp().to_rfc3339())?;
115 state.serialize_field("run_dir", &self.run_dir.to_string_lossy())?;
116 state.end()
117 }
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct RunOutput {
122 pub exit_code: i32,
123 pub stdout: String,
124 pub stderr: String,
125 pub duration: Duration,
126}
127
128fn exit_code_from_status(status: ExitStatus) -> i32 {
129 match status.code() {
130 Some(c) => c,
131 None => {
132 #[cfg(unix)]
134 {
135 use std::os::unix::process::ExitStatusExt;
136 status.signal().map(|s| 128 + s).unwrap_or(1)
137 }
138 #[cfg(not(unix))]
139 {
140 1
141 }
142 }
143 }
144}
145
146impl Run<PathBuf> {
147 pub fn exec(&self) -> std::io::Result<RunOutput> {
148 if self.command.is_empty() {
149 return Err(io::Error::new(io::ErrorKind::InvalidInput, "empty command"));
150 }
151 let program = &self.command[0];
152 let args: Vec<&str> = self.command[1..].iter().map(|s| s.as_str()).collect();
153
154 let mut env_vars = HashMap::new();
155 env_vars.insert("CAPSULA_RUN_ID", self.id.to_string());
156 env_vars.insert("CAPSULA_RUN_NAME", self.name.clone());
157 env_vars.insert(
158 "CAPSULA_RUN_DIRECTORY",
159 self.run_dir.to_string_lossy().to_string(),
160 );
161 env_vars.insert("CAPSULA_RUN_TIMESTAMP", self.timestamp().to_rfc3339());
162 if let Ok(cmd_str) = try_join(self.command.iter().map(|s| s.as_str())) {
163 env_vars.insert("CAPSULA_RUN_COMMAND", cmd_str);
164 }
165
166 let start = Instant::now();
167
168 let mut child = Command::new(program)
169 .args(&args)
170 .envs(&env_vars)
171 .stdout(Stdio::piped())
172 .stderr(Stdio::piped())
173 .spawn()?;
174
175 let mut child_stdout = child
176 .stdout
177 .take()
178 .ok_or_else(|| io::Error::other("Failed to capture stdout"))?;
179 let mut child_stderr = child
180 .stderr
181 .take()
182 .ok_or_else(|| io::Error::other("Failed to capture stderr"))?;
183
184 let t_out = thread::spawn(move || -> io::Result<Vec<u8>> {
185 let mut cap = Vec::with_capacity(8 * 1024);
186 let mut buf = [0u8; 8192];
187 let mut console = io::stdout().lock();
188
189 loop {
190 let n = child_stdout.read(&mut buf)?;
191 if n == 0 {
192 break;
193 }
194 console.write_all(&buf[..n])?;
195 cap.extend_from_slice(&buf[..n]);
196 }
197 console.flush()?;
198 Ok(cap)
199 });
200
201 let t_err = thread::spawn(move || -> io::Result<Vec<u8>> {
202 let mut cap = Vec::with_capacity(8 * 1024);
203 let mut buf = [0u8; 8192];
204 let mut console = io::stderr().lock();
205
206 loop {
207 let n = child_stderr.read(&mut buf)?;
208 if n == 0 {
209 break;
210 }
211 console.write_all(&buf[..n])?;
212 cap.extend_from_slice(&buf[..n]);
213 }
214 console.flush()?;
215 Ok(cap)
216 });
217
218 let status = child.wait()?;
219 let duration = start.elapsed();
220 let cap_out = t_out
221 .join()
222 .map_err(|_| io::Error::other("stdout capture thread panicked"))??;
223 let cap_err = t_err
224 .join()
225 .map_err(|_| io::Error::other("stderr capture thread panicked"))??;
226
227 let exit_code = exit_code_from_status(status);
228
229 Ok(RunOutput {
230 exit_code,
231 stdout: String::from_utf8_lossy(&cap_out).to_string(),
232 stderr: String::from_utf8_lossy(&cap_err).to_string(),
233 duration,
234 })
235 }
236}
237
238fn setup_vault(path: impl AsRef<std::path::Path>) -> io::Result<()> {
239 let path = path.as_ref();
240 if path.exists() {
241 return Ok(());
242 }
243 std::fs::create_dir_all(path)?;
244
245 let gitignore_path = path.join(".gitignore");
247 std::fs::write(
248 gitignore_path,
249 "\
250# Automatically generated by Capsula
251*",
252 )?;
253
254 Ok(())
255}