Skip to main content

cli/actions/
start_executor.rs

1//! Executable start behavior for Rust projects generated by this CLI.
2
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::process::{Child, Command, Stdio};
6use std::thread;
7
8use crate::Result;
9use crate::build_executor::{
10    BuildExecutionPlan, BuildWatchState, BuildWatchTickResult, create_build_execution_plan,
11    create_build_watch_state, execute_build_plan, execute_build_watch_tick,
12};
13use crate::error::CliError;
14use crate::runners::RunnerCommand;
15use crate::start_action::StartActionPlan;
16
17#[derive(Clone, Debug, PartialEq, Eq)]
18pub struct StartExecutionPlan {
19    pub command: RunnerCommand,
20    pub warnings: Vec<String>,
21    pub watch: Option<StartWatchExecutionPlan>,
22}
23
24#[derive(Clone, Debug, PartialEq, Eq)]
25pub struct StartWatchExecutionPlan {
26    pub build_plan: BuildExecutionPlan,
27    pub kill_previous_process_on_success: bool,
28}
29
30pub trait StartChildProcess {
31    fn kill(&mut self) -> Result<()>;
32}
33
34pub trait StartProcessSpawner {
35    type Child: StartChildProcess;
36
37    fn spawn(&mut self, command: &RunnerCommand) -> Result<Self::Child>;
38}
39
40#[derive(Debug, Default)]
41pub struct OsStartProcessSpawner;
42
43#[derive(Debug)]
44pub struct OsStartChildProcess {
45    child: Child,
46}
47
48pub fn create_start_execution_plan(plan: &StartActionPlan) -> Result<StartExecutionPlan> {
49    let mut warnings = Vec::new();
50    if plan.process_plan.debug_flag.is_some() {
51        warnings.push("`--debug` is ignored for Rust cargo runs".to_string());
52    }
53    if plan.process_plan.requested_exec.is_some() {
54        warnings.push("`--exec` is ignored for Rust cargo runs".to_string());
55    }
56    if !plan.process_plan.shell {
57        warnings.push("`--no-shell` is ignored for Rust cargo runs".to_string());
58    }
59
60    let watch = if plan.build_plan.watch_mode {
61        let build_plan = create_build_execution_plan(&plan.build_plan)?;
62        warnings.extend(build_plan.warnings.clone());
63        Some(StartWatchExecutionPlan {
64            build_plan,
65            kill_previous_process_on_success: plan
66                .process_plan
67                .restart
68                .kill_previous_process_on_success,
69        })
70    } else {
71        None
72    };
73
74    let mut command = plan.process_plan.source_root_command.clone();
75    command.env = load_env_files(command.cwd.as_deref(), &plan.process_plan.env_file)?;
76
77    Ok(StartExecutionPlan {
78        command,
79        warnings,
80        watch,
81    })
82}
83
84pub fn execute_start_plan(plan: &StartExecutionPlan) -> Result<()> {
85    if plan.watch.is_some() {
86        let mut spawner = OsStartProcessSpawner;
87        return execute_start_watch_plan_with(plan, &mut spawner);
88    }
89
90    plan.command.execute().map(|_| ())
91}
92
93pub fn execute_start_watch_plan_with<S>(plan: &StartExecutionPlan, spawner: &mut S) -> Result<()>
94where
95    S: StartProcessSpawner,
96{
97    let watch = plan.watch.as_ref().ok_or_else(|| {
98        CliError::UnsupportedCommand(
99            "`nest start --watch` requires a watch execution plan".to_string(),
100        )
101    })?;
102    let build_watch = watch.build_plan.watch.as_ref().ok_or_else(|| {
103        CliError::UnsupportedCommand(
104            "`nest start --watch` requires a build watch execution plan".to_string(),
105        )
106    })?;
107
108    execute_build_plan(&watch.build_plan)?;
109    let mut child = spawner.spawn(&plan.command)?;
110    let mut state = create_build_watch_state(build_watch)?;
111
112    loop {
113        thread::sleep(build_watch.poll_interval);
114        start_watch_tick(plan, &mut state, &mut child, spawner)?;
115    }
116}
117
118pub fn start_watch_tick<S>(
119    plan: &StartExecutionPlan,
120    state: &mut BuildWatchState,
121    child: &mut S::Child,
122    spawner: &mut S,
123) -> Result<Vec<BuildWatchTickResult>>
124where
125    S: StartProcessSpawner,
126{
127    let watch = plan.watch.as_ref().ok_or_else(|| {
128        CliError::UnsupportedCommand(
129            "`nest start --watch` requires a watch execution plan".to_string(),
130        )
131    })?;
132
133    let results = execute_build_watch_tick(&watch.build_plan, state)?;
134    if results.iter().any(|result| result.changed) {
135        let mut next_child = spawner.spawn(&plan.command)?;
136        if watch.kill_previous_process_on_success {
137            child.kill()?;
138        }
139        std::mem::swap(child, &mut next_child);
140    }
141
142    Ok(results)
143}
144
145impl StartProcessSpawner for OsStartProcessSpawner {
146    type Child = OsStartChildProcess;
147
148    fn spawn(&mut self, command: &RunnerCommand) -> Result<Self::Child> {
149        let mut process = command_for_spawn(command);
150        if let Some(cwd) = &command.cwd {
151            process.current_dir(cwd);
152        }
153        process.envs(command.env.iter().map(|(key, value)| (key, value)));
154        process
155            .stdin(Stdio::inherit())
156            .stdout(Stdio::inherit())
157            .stderr(Stdio::inherit());
158
159        let child = process.spawn().map_err(|error| CliError::RunnerFailed {
160            command: command.raw_full_command(),
161            reason: format!("failed to spawn process: {error}"),
162        })?;
163
164        Ok(OsStartChildProcess { child })
165    }
166}
167
168impl StartChildProcess for OsStartChildProcess {
169    fn kill(&mut self) -> Result<()> {
170        self.child.kill()?;
171        self.child.wait()?;
172        Ok(())
173    }
174}
175
176fn command_for_spawn(command: &RunnerCommand) -> Command {
177    if command.shell {
178        return shell_command(&command_line_for_execution(command));
179    }
180
181    let mut process = Command::new(&command.binary);
182    process.args(&command.prefix_args).arg(&command.command);
183    process.envs(command.env.iter().map(|(key, value)| (key, value)));
184    process
185}
186
187pub fn load_env_files(cwd: Option<&Path>, env_files: &[String]) -> Result<Vec<(String, String)>> {
188    let cwd = cwd.unwrap_or_else(|| Path::new("."));
189    let mut values = Vec::new();
190
191    for env_file in env_files {
192        let path = resolve_env_file(cwd, env_file);
193        let content = fs::read_to_string(&path).map_err(|error| {
194            CliError::InvalidConfiguration(format!(
195                "Failed to read env file `{}`: {error}",
196                path.display()
197            ))
198        })?;
199        values.extend(parse_env_file(&content)?);
200    }
201
202    Ok(values)
203}
204
205fn resolve_env_file(cwd: &Path, env_file: &str) -> PathBuf {
206    let path = Path::new(env_file);
207    if path.is_absolute() {
208        path.to_path_buf()
209    } else {
210        cwd.join(path)
211    }
212}
213
214pub fn parse_env_file(content: &str) -> Result<Vec<(String, String)>> {
215    let mut values = Vec::new();
216
217    for (line_number, raw_line) in content.lines().enumerate() {
218        let line = raw_line.trim();
219        if line.is_empty() || line.starts_with('#') {
220            continue;
221        }
222        let line = line.strip_prefix("export ").unwrap_or(line);
223        let Some((key, value)) = line.split_once('=') else {
224            return Err(CliError::InvalidConfiguration(format!(
225                "Invalid env file line {}: missing `=`",
226                line_number + 1
227            )));
228        };
229        let key = key.trim();
230        if key.is_empty() || key.contains(char::is_whitespace) {
231            return Err(CliError::InvalidConfiguration(format!(
232                "Invalid env file line {}: invalid key `{key}`",
233                line_number + 1
234            )));
235        }
236        values.push((key.to_string(), parse_env_value(value.trim())));
237    }
238
239    Ok(values)
240}
241
242fn parse_env_value(value: &str) -> String {
243    let value = strip_inline_comment(value).trim();
244    if (value.starts_with('"') && value.ends_with('"'))
245        || (value.starts_with('\'') && value.ends_with('\''))
246    {
247        value[1..value.len() - 1].to_string()
248    } else {
249        value.to_string()
250    }
251}
252
253fn strip_inline_comment(value: &str) -> &str {
254    let mut in_single = false;
255    let mut in_double = false;
256    let mut previous = None;
257
258    for (index, character) in value.char_indices() {
259        match character {
260            '\'' if !in_double => in_single = !in_single,
261            '"' if !in_single => in_double = !in_double,
262            '#' if !in_single
263                && !in_double
264                && previous.is_none_or(|previous: char| previous.is_whitespace()) =>
265            {
266                return &value[..index];
267            }
268            _ => {}
269        }
270        previous = Some(character);
271    }
272    value
273}
274
275fn command_line_for_execution(command: &RunnerCommand) -> String {
276    let mut parts = Vec::with_capacity(2 + command.prefix_args.len());
277    parts.push(quote_shell_part(&command.binary));
278    parts.extend(command.prefix_args.iter().map(|arg| quote_shell_part(arg)));
279    parts.push(command.command.clone());
280    parts.join(" ")
281}
282
283fn shell_command(command_line: &str) -> Command {
284    #[cfg(windows)]
285    {
286        let mut command =
287            Command::new(std::env::var_os("COMSPEC").unwrap_or_else(|| "cmd.exe".into()));
288        command.arg("/C").arg(command_line);
289        command
290    }
291
292    #[cfg(not(windows))]
293    {
294        let mut command = Command::new("sh");
295        command.arg("-c").arg(command_line);
296        command
297    }
298}
299
300fn quote_shell_part(part: &str) -> String {
301    if part.is_empty()
302        || part.starts_with('"')
303        || part.starts_with('\'')
304        || !part.chars().any(char::is_whitespace)
305    {
306        return part.to_owned();
307    }
308
309    #[cfg(windows)]
310    {
311        format!("\"{}\"", part.replace('"', "\\\""))
312    }
313
314    #[cfg(not(windows))]
315    {
316        format!("'{}'", part.replace('\'', "'\\''"))
317    }
318}