Skip to main content

phoenix_cli/
lib.rs

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    /// Run Rust and Vite together until Ctrl-C, or until either process exits.
96    /// Both children are terminated and reaped before this function returns.
97    ///
98    /// # Errors
99    ///
100    /// Returns an error when a child cannot start, signal registration fails,
101    /// or either development process exits on its own.
102    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    /// Run with a caller-provided shutdown future, primarily for integration
108    /// with another application lifecycle or deterministic tests.
109    ///
110    /// # Errors
111    ///
112    /// Returns a spawn, wait, shutdown, or early-child-exit error.
113    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}