1use std::{
2 ffi::OsString,
3 future::Future,
4 path::{Path, PathBuf},
5 process::{ExitStatus, Stdio},
6};
7
8use thiserror::Error;
9use tokio::process::{Child, Command};
10
11mod release;
12mod scaffold;
13
14pub use release::{release_build, release_install, release_rollback, release_status};
15pub use scaffold::{
16 ControllerOptions, DependencySource, GenerateOptions, ModelOptions, NewProjectOptions,
17 ProjectGenerator, ScaffoldError, create_project,
18};
19
20#[derive(Clone, Debug, Eq, PartialEq)]
21pub struct CommandSpec {
22 pub program: PathBuf,
23 pub args: Vec<OsString>,
24}
25
26impl CommandSpec {
27 #[must_use]
28 pub fn new(program: impl Into<PathBuf>) -> Self {
29 Self {
30 program: program.into(),
31 args: Vec::new(),
32 }
33 }
34
35 #[must_use]
36 pub fn arg(mut self, argument: impl Into<OsString>) -> Self {
37 self.args.push(argument.into());
38 self
39 }
40
41 #[must_use]
42 pub fn args(mut self, arguments: impl IntoIterator<Item = impl Into<OsString>>) -> Self {
43 self.args.extend(arguments.into_iter().map(Into::into));
44 self
45 }
46}
47
48#[derive(Clone, Debug)]
49pub struct DevConfig {
50 pub working_directory: PathBuf,
51 pub rust: CommandSpec,
52 pub vite: CommandSpec,
53}
54
55impl Default for DevConfig {
56 fn default() -> Self {
57 Self {
58 working_directory: PathBuf::from("."),
59 rust: CommandSpec::new("cargo").args(["run", "--", "serve"]),
60 vite: CommandSpec::new("npm").args(["run", "dev", "--", "--strictPort"]),
61 }
62 }
63}
64
65impl DevConfig {
66 #[must_use]
67 pub fn working_directory(mut self, directory: impl Into<PathBuf>) -> Self {
68 self.working_directory = directory.into();
69 self
70 }
71
72 #[must_use]
73 pub fn rust(mut self, command: CommandSpec) -> Self {
74 self.rust = command;
75 self
76 }
77
78 #[must_use]
79 pub fn vite(mut self, command: CommandSpec) -> Self {
80 self.vite = command;
81 self
82 }
83}
84
85pub struct DevSupervisor {
86 config: DevConfig,
87}
88
89impl DevSupervisor {
90 #[must_use]
91 pub fn new(config: DevConfig) -> Self {
92 Self { config }
93 }
94
95 pub async fn run(self) -> Result<(), DevError> {
103 self.run_with_shutdown(async { tokio::signal::ctrl_c().await.map_err(DevError::Signal) })
104 .await
105 }
106
107 pub async fn run_with_shutdown<F>(self, shutdown: F) -> Result<(), DevError>
114 where
115 F: Future<Output = Result<(), DevError>> + Send,
116 {
117 let mut rust = spawn("Rust", &self.config.rust, &self.config.working_directory)?;
118 let mut vite = match spawn("Vite", &self.config.vite, &self.config.working_directory) {
119 Ok(vite) => vite,
120 Err(error) => {
121 terminate(&mut rust).await?;
122 return Err(error);
123 }
124 };
125 tokio::pin!(shutdown);
126
127 let event = tokio::select! {
128 result = rust.wait() => Event::Rust(result.map_err(DevError::Wait)?),
129 result = vite.wait() => Event::Vite(result.map_err(DevError::Wait)?),
130 result = &mut shutdown => Event::Shutdown(result),
131 };
132 terminate(&mut rust).await?;
133 terminate(&mut vite).await?;
134
135 match event {
136 Event::Shutdown(result) => result,
137 Event::Rust(status) => Err(DevError::Exited {
138 process: "Rust",
139 status,
140 }),
141 Event::Vite(status) => Err(DevError::Exited {
142 process: "Vite",
143 status,
144 }),
145 }
146 }
147}
148
149enum Event {
150 Rust(ExitStatus),
151 Vite(ExitStatus),
152 Shutdown(Result<(), DevError>),
153}
154
155fn spawn(label: &'static str, spec: &CommandSpec, cwd: &Path) -> Result<Child, DevError> {
156 let mut command = Command::new(&spec.program);
157 command
158 .args(&spec.args)
159 .current_dir(cwd)
160 .stdin(Stdio::inherit())
161 .stdout(Stdio::inherit())
162 .stderr(Stdio::inherit())
163 .kill_on_drop(true);
164 #[cfg(unix)]
165 command.process_group(0);
166 command.spawn().map_err(|source| DevError::Spawn {
167 process: label,
168 source,
169 })
170}
171
172#[cfg(unix)]
173async fn terminate(child: &mut Child) -> Result<(), DevError> {
174 use nix::{
175 sys::signal::{Signal, killpg},
176 unistd::Pid,
177 };
178 use std::time::Duration;
179
180 let Some(id) = child.id() else {
181 let _ = child.wait().await.map_err(DevError::Wait)?;
182 return Ok(());
183 };
184 let process_group = Pid::from_raw(i32::try_from(id).map_err(|_| DevError::InvalidProcessId)?);
185 if let Err(error) = killpg(process_group, Signal::SIGTERM)
186 && error != nix::errno::Errno::ESRCH
187 {
188 return Err(DevError::SignalProcess(error));
189 }
190 if tokio::time::timeout(Duration::from_secs(3), child.wait())
191 .await
192 .is_err()
193 {
194 if let Err(error) = killpg(process_group, Signal::SIGKILL)
195 && error != nix::errno::Errno::ESRCH
196 {
197 return Err(DevError::SignalProcess(error));
198 }
199 let _ = child.wait().await.map_err(DevError::Wait)?;
200 }
201 Ok(())
202}
203
204#[cfg(not(unix))]
205async fn terminate(child: &mut Child) -> Result<(), DevError> {
206 if child.id().is_some() {
207 child.start_kill().map_err(DevError::Shutdown)?;
208 }
209 let _ = child.wait().await.map_err(DevError::Wait)?;
210 Ok(())
211}
212
213#[derive(Debug, Error)]
214pub enum DevError {
215 #[error("failed to start the {process} development process: {source}")]
216 Spawn {
217 process: &'static str,
218 source: std::io::Error,
219 },
220 #[error("failed while waiting for a development process: {0}")]
221 Wait(std::io::Error),
222 #[error("failed to stop a development process: {0}")]
223 Shutdown(std::io::Error),
224 #[cfg(unix)]
225 #[error("failed to signal a development process group: {0}")]
226 SignalProcess(nix::errno::Errno),
227 #[error("a development process returned an invalid process id")]
228 InvalidProcessId,
229 #[error("failed to listen for Ctrl-C: {0}")]
230 Signal(std::io::Error),
231 #[error("the {process} development process exited early with {status}")]
232 Exited {
233 process: &'static str,
234 status: ExitStatus,
235 },
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241 use std::time::Duration;
242
243 fn shell(script: &str) -> CommandSpec {
244 CommandSpec::new("sh").args(["-c", script])
245 }
246
247 #[test]
248 fn default_dev_config_runs_serve() {
249 let config = DevConfig::default();
250 assert_eq!(config.rust.program, PathBuf::from("cargo"));
251 assert_eq!(
252 config.rust.args,
253 vec![
254 OsString::from("run"),
255 OsString::from("--"),
256 OsString::from("serve")
257 ]
258 );
259 }
260
261 #[tokio::test]
262 async fn shutdown_stops_and_reaps_both_processes() {
263 let supervisor = DevSupervisor::new(
264 DevConfig::default()
265 .rust(shell("sleep 10 & wait"))
266 .vite(shell("sleep 10 & wait")),
267 );
268 supervisor
269 .run_with_shutdown(async {
270 tokio::time::sleep(Duration::from_millis(50)).await;
271 Ok(())
272 })
273 .await
274 .unwrap();
275 }
276
277 #[tokio::test]
278 async fn one_failed_process_stops_the_other() {
279 let supervisor = DevSupervisor::new(
280 DevConfig::default()
281 .rust(shell("exit 7"))
282 .vite(shell("sleep 10")),
283 );
284 let result = supervisor.run_with_shutdown(std::future::pending()).await;
285
286 assert!(matches!(
287 result,
288 Err(DevError::Exited {
289 process: "Rust",
290 status,
291 }) if status.code() == Some(7)
292 ));
293 }
294}