Skip to main content

cli/actions/
build_executor.rs

1//! Executable build behavior for Rust projects generated by this CLI.
2
3use std::collections::BTreeMap;
4use std::fs;
5use std::path::{Component, Path, PathBuf};
6use std::thread;
7use std::time::{Duration, SystemTime};
8
9use crate::Result;
10use crate::build_action::{BuildActionPlan, ProjectBuildActionPlan};
11use crate::compiler::{AssetPlan, CompilerInputs, OutputCleanup};
12use crate::error::CliError;
13use crate::runners::{ProcessRunner, Runner, RunnerCommand, RunnerKind};
14
15#[derive(Clone, Debug, PartialEq, Eq)]
16pub struct BuildExecutionPlan {
17    pub commands: Vec<RunnerCommand>,
18    pub warnings: Vec<String>,
19    pub projects: Vec<ProjectBuildExecutionPlan>,
20    pub watch: Option<BuildWatchExecutionPlan>,
21}
22
23#[derive(Clone, Debug, PartialEq, Eq)]
24pub struct ProjectBuildExecutionPlan {
25    pub command: RunnerCommand,
26    pub cwd: PathBuf,
27    pub source_root: PathBuf,
28    pub assets: Vec<AssetPlan>,
29    pub output_cleanup: Option<OutputCleanup>,
30}
31
32#[derive(Clone, Debug, PartialEq, Eq)]
33pub struct BuildWatchExecutionPlan {
34    pub poll_interval: Duration,
35    pub projects: Vec<ProjectBuildWatchExecutionPlan>,
36}
37
38#[derive(Clone, Debug, PartialEq, Eq)]
39pub struct ProjectBuildWatchExecutionPlan {
40    pub project_index: usize,
41    pub roots: Vec<PathBuf>,
42}
43
44#[derive(Clone, Debug, PartialEq, Eq)]
45pub struct BuildWatchState {
46    pub project_snapshots: Vec<FileSnapshot>,
47}
48
49#[derive(Clone, Debug, PartialEq, Eq)]
50pub struct BuildWatchTickResult {
51    pub project_index: usize,
52    pub changed: bool,
53}
54
55#[derive(Clone, Debug, Default, PartialEq, Eq)]
56pub struct FileSnapshot {
57    files: BTreeMap<PathBuf, FileFingerprint>,
58}
59
60#[derive(Clone, Copy, Debug, PartialEq, Eq)]
61struct FileFingerprint {
62    modified: SystemTime,
63    len: u64,
64}
65
66pub fn create_build_execution_plan(plan: &BuildActionPlan) -> Result<BuildExecutionPlan> {
67    let projects = plan
68        .project_plans
69        .iter()
70        .map(project_execution_plan)
71        .collect::<Vec<_>>();
72    let warnings = plan
73        .type_check_warnings
74        .iter()
75        .map(|warning| warning.message.clone())
76        .collect();
77    let commands = projects
78        .iter()
79        .map(|project| project.command.clone())
80        .collect();
81    let watch = plan
82        .watch_mode
83        .then(|| create_build_watch_execution_plan(&projects));
84
85    Ok(BuildExecutionPlan {
86        commands,
87        warnings,
88        projects,
89        watch,
90    })
91}
92
93pub fn execute_build_plan(plan: &BuildExecutionPlan) -> Result<()> {
94    for project in &plan.projects {
95        execute_project_build(project)?;
96    }
97
98    Ok(())
99}
100
101pub fn execute_build_watch_plan(plan: &BuildExecutionPlan) -> Result<()> {
102    let watch = plan.watch.as_ref().ok_or_else(|| {
103        CliError::UnsupportedCommand(
104            "`nest build --watch` requires a watch execution plan".to_string(),
105        )
106    })?;
107
108    execute_build_plan(plan)?;
109    let mut state = create_build_watch_state(watch)?;
110    loop {
111        thread::sleep(watch.poll_interval);
112        execute_build_watch_tick(plan, &mut state)?;
113    }
114}
115
116pub fn create_build_watch_state(watch: &BuildWatchExecutionPlan) -> Result<BuildWatchState> {
117    let project_snapshots = watch
118        .projects
119        .iter()
120        .map(|project| snapshot_roots(&project.roots))
121        .collect::<Result<Vec<_>>>()?;
122
123    Ok(BuildWatchState { project_snapshots })
124}
125
126pub fn execute_build_watch_tick(
127    plan: &BuildExecutionPlan,
128    state: &mut BuildWatchState,
129) -> Result<Vec<BuildWatchTickResult>> {
130    build_watch_tick(plan, state, execute_project_build)
131}
132
133pub fn build_watch_tick(
134    plan: &BuildExecutionPlan,
135    state: &mut BuildWatchState,
136    mut rebuild: impl FnMut(&ProjectBuildExecutionPlan) -> Result<()>,
137) -> Result<Vec<BuildWatchTickResult>> {
138    let watch = plan.watch.as_ref().ok_or_else(|| {
139        CliError::UnsupportedCommand(
140            "`nest build --watch` requires a watch execution plan".to_string(),
141        )
142    })?;
143    let mut results = Vec::with_capacity(watch.projects.len());
144
145    for project_watch in &watch.projects {
146        let next_snapshot = snapshot_roots(&project_watch.roots)?;
147        let previous_snapshot = state
148            .project_snapshots
149            .get(project_watch.project_index)
150            .ok_or_else(|| {
151                CliError::InvalidConfiguration(format!(
152                    "Missing watch snapshot for project index {}",
153                    project_watch.project_index
154                ))
155            })?;
156        let changed = previous_snapshot != &next_snapshot;
157
158        if changed {
159            let project = plan
160                .projects
161                .get(project_watch.project_index)
162                .ok_or_else(|| {
163                    CliError::InvalidConfiguration(format!(
164                        "Missing build project for watch index {}",
165                        project_watch.project_index
166                    ))
167                })?;
168            rebuild(project)?;
169        }
170
171        if let Some(snapshot) = state.project_snapshots.get_mut(project_watch.project_index) {
172            *snapshot = next_snapshot;
173        }
174        results.push(BuildWatchTickResult {
175            project_index: project_watch.project_index,
176            changed,
177        });
178    }
179
180    Ok(results)
181}
182
183fn project_execution_plan(project_plan: &ProjectBuildActionPlan) -> ProjectBuildExecutionPlan {
184    let inputs = &project_plan.build_plan.inputs;
185
186    ProjectBuildExecutionPlan {
187        command: cargo_build_command(project_plan),
188        cwd: inputs.cwd.clone(),
189        source_root: inputs.source_root.clone(),
190        assets: inputs.assets.clone(),
191        output_cleanup: inputs.output_cleanup.clone(),
192    }
193}
194
195fn create_build_watch_execution_plan(
196    projects: &[ProjectBuildExecutionPlan],
197) -> BuildWatchExecutionPlan {
198    BuildWatchExecutionPlan {
199        poll_interval: Duration::from_secs(1),
200        projects: projects
201            .iter()
202            .enumerate()
203            .map(|(index, project)| ProjectBuildWatchExecutionPlan {
204                project_index: index,
205                roots: watch_roots(project),
206            })
207            .collect(),
208    }
209}
210
211fn watch_roots(project: &ProjectBuildExecutionPlan) -> Vec<PathBuf> {
212    let mut roots = vec![resolve_for_watch(&project.cwd, &project.source_root)];
213
214    for asset in &project.assets {
215        let root = match (&asset.include, asset.glob.is_empty()) {
216            (Some(include), false) => resolve_for_watch(&project.cwd, include),
217            (Some(include), true) => split_glob_root_for_watch(&project.cwd, include),
218            (None, _) => resolve_for_watch(&project.cwd, &project.source_root),
219        };
220
221        if !roots.contains(&root) {
222            roots.push(root);
223        }
224    }
225
226    roots
227}
228
229fn resolve_for_watch(cwd: &Path, path: &Path) -> PathBuf {
230    if path.is_absolute() {
231        normalize_absolute_path(path)
232    } else {
233        normalize_absolute_path(&cwd.join(path))
234    }
235}
236
237fn split_glob_root_for_watch(cwd: &Path, pattern: &Path) -> PathBuf {
238    let mut base = PathBuf::new();
239
240    for component in pattern.components() {
241        let text = component.as_os_str().to_string_lossy();
242        if has_glob_chars(&text) {
243            break;
244        }
245        base.push(component.as_os_str());
246    }
247
248    resolve_for_watch(cwd, &base)
249}
250
251fn execute_project_build(project: &ProjectBuildExecutionPlan) -> Result<()> {
252    if let Some(cleanup) = &project.output_cleanup {
253        clean_output(&project.cwd, cleanup)?;
254    }
255
256    project.command.execute()?;
257    copy_assets(&project.cwd, &project.source_root, &project.assets)?;
258    Ok(())
259}
260
261fn cargo_build_command(project_plan: &ProjectBuildActionPlan) -> RunnerCommand {
262    let inputs = &project_plan.build_plan.inputs;
263    let command = match &project_plan.app_name {
264        Some(_) => {
265            let manifest_path = project_manifest_path(project_plan, inputs);
266            format!(
267                "build --manifest-path {}",
268                quote_command_path(&manifest_path)
269            )
270        }
271        None => "build".to_string(),
272    };
273
274    ProcessRunner::new(RunnerKind::Cargo).describe(command, false, Some(inputs.cwd.clone()))
275}
276
277fn snapshot_roots(roots: &[PathBuf]) -> Result<FileSnapshot> {
278    let mut snapshot = FileSnapshot::default();
279    for root in roots {
280        snapshot_path(root, &mut snapshot)?;
281    }
282    Ok(snapshot)
283}
284
285fn snapshot_path(path: &Path, snapshot: &mut FileSnapshot) -> Result<()> {
286    if !path.exists() {
287        return Ok(());
288    }
289
290    let metadata = fs::metadata(path)?;
291    if metadata.is_file() {
292        snapshot.files.insert(
293            normalize_absolute_path(path),
294            FileFingerprint {
295                modified: metadata.modified()?,
296                len: metadata.len(),
297            },
298        );
299        return Ok(());
300    }
301
302    if metadata.is_dir() {
303        for entry in fs::read_dir(path)? {
304            let entry = entry?;
305            snapshot_path(&entry.path(), snapshot)?;
306        }
307    }
308
309    Ok(())
310}
311
312fn project_manifest_path(
313    project_plan: &ProjectBuildActionPlan,
314    inputs: &CompilerInputs,
315) -> PathBuf {
316    project_plan
317        .project_root
318        .clone()
319        .unwrap_or_else(|| source_root_project_root(&inputs.source_root))
320        .join("Cargo.toml")
321}
322
323fn source_root_project_root(source_root: &Path) -> PathBuf {
324    source_root
325        .parent()
326        .filter(|parent| !parent.as_os_str().is_empty())
327        .map(Path::to_path_buf)
328        .unwrap_or_else(|| source_root.to_path_buf())
329}
330
331fn clean_output(cwd: &Path, cleanup: &OutputCleanup) -> Result<()> {
332    let cwd = canonical_existing_dir(cwd)?;
333    let out_dir = resolve_under_cwd(&cwd, &cleanup.out_dir)?;
334
335    if out_dir.exists() {
336        let canonical_out_dir = out_dir.canonicalize()?;
337        ensure_removable_child(&cwd, &canonical_out_dir)?;
338        fs::remove_dir_all(canonical_out_dir)?;
339    }
340
341    if let Some(ts_build_info_file) = &cleanup.ts_build_info_file {
342        let ts_build_info_file = resolve_under_cwd(&cwd, ts_build_info_file)?;
343        if ts_build_info_file.exists() {
344            let canonical_file = ts_build_info_file.canonicalize()?;
345            ensure_removable_child(&cwd, &canonical_file)?;
346            if canonical_file.is_file() {
347                fs::remove_file(canonical_file)?;
348            }
349        }
350    }
351
352    Ok(())
353}
354
355fn copy_assets(cwd: &Path, source_root: &Path, assets: &[AssetPlan]) -> Result<()> {
356    let cwd = canonical_existing_dir(cwd)?;
357
358    for asset in assets {
359        let copy_plan = AssetCopyPlan::from_asset(&cwd, source_root, asset)?;
360        if !copy_plan.base.exists() {
361            continue;
362        }
363
364        for file in collect_matching_files(
365            &copy_plan.base,
366            &copy_plan.pattern,
367            asset.exclude.as_deref(),
368        )? {
369            let relative = file
370                .strip_prefix(&copy_plan.base)
371                .map_err(|error| CliError::InvalidConfiguration(error.to_string()))?;
372            let destination = if asset.flat {
373                let file_name = file.file_name().ok_or_else(|| {
374                    CliError::InvalidConfiguration(format!(
375                        "Asset path `{}` has no file name",
376                        file.display()
377                    ))
378                })?;
379                copy_plan.out_dir.join(file_name)
380            } else {
381                copy_plan.out_dir.join(relative)
382            };
383
384            if let Some(parent) = destination.parent() {
385                fs::create_dir_all(parent)?;
386            }
387            fs::copy(&file, destination)?;
388        }
389    }
390
391    Ok(())
392}
393
394#[derive(Debug)]
395struct AssetCopyPlan {
396    base: PathBuf,
397    pattern: String,
398    out_dir: PathBuf,
399}
400
401impl AssetCopyPlan {
402    fn from_asset(cwd: &Path, source_root: &Path, asset: &AssetPlan) -> Result<Self> {
403        let out_dir = resolve_under_cwd(cwd, &asset.out_dir)?;
404        let source_root = resolve_under_cwd(cwd, source_root)?;
405
406        let (base, pattern) = match (&asset.include, asset.glob.is_empty()) {
407            (Some(include), false) => (resolve_under_cwd(cwd, include)?, asset.glob.clone()),
408            (Some(include), true) => split_glob_root(cwd, include)?,
409            (None, false) => (source_root, asset.glob.clone()),
410            (None, true) => (source_root, "**/*".to_string()),
411        };
412
413        Ok(Self {
414            base,
415            pattern: normalize_pattern(&pattern),
416            out_dir,
417        })
418    }
419}
420
421fn split_glob_root(cwd: &Path, pattern: &Path) -> Result<(PathBuf, String)> {
422    let mut base = PathBuf::new();
423    let mut pattern_parts = Vec::new();
424    let mut in_pattern = false;
425
426    for component in pattern.components() {
427        let text = component.as_os_str().to_string_lossy();
428        if !in_pattern && has_glob_chars(&text) {
429            in_pattern = true;
430        }
431
432        if in_pattern {
433            pattern_parts.push(text.into_owned());
434        } else {
435            base.push(component.as_os_str());
436        }
437    }
438
439    if pattern_parts.is_empty() {
440        let file_name = base
441            .file_name()
442            .map(|name| name.to_string_lossy().into_owned())
443            .unwrap_or_else(|| "**/*".to_string());
444        base.pop();
445        pattern_parts.push(file_name);
446    }
447
448    Ok((resolve_under_cwd(cwd, &base)?, pattern_parts.join("/")))
449}
450
451fn collect_matching_files(
452    base: &Path,
453    pattern: &str,
454    exclude: Option<&str>,
455) -> Result<Vec<PathBuf>> {
456    let mut files = Vec::new();
457    collect_matching_files_inner(
458        base,
459        base,
460        pattern,
461        exclude.map(normalize_pattern),
462        &mut files,
463    )?;
464    Ok(files)
465}
466
467fn collect_matching_files_inner(
468    base: &Path,
469    current: &Path,
470    pattern: &str,
471    exclude: Option<String>,
472    files: &mut Vec<PathBuf>,
473) -> Result<()> {
474    for entry in fs::read_dir(current)? {
475        let entry = entry?;
476        let path = entry.path();
477        if entry.file_type()?.is_dir() {
478            collect_matching_files_inner(base, &path, pattern, exclude.clone(), files)?;
479            continue;
480        }
481
482        let relative = path
483            .strip_prefix(base)
484            .map_err(|error| CliError::InvalidConfiguration(error.to_string()))?;
485        let relative = normalize_path(relative);
486        if wildcard_matches(pattern, &relative)
487            && !exclude
488                .as_deref()
489                .is_some_and(|exclude| wildcard_matches(exclude, &relative))
490        {
491            files.push(path);
492        }
493    }
494
495    Ok(())
496}
497
498fn canonical_existing_dir(path: &Path) -> Result<PathBuf> {
499    let canonical = path.canonicalize()?;
500    if canonical.is_dir() {
501        Ok(canonical)
502    } else {
503        Err(CliError::InvalidConfiguration(format!(
504            "Build cwd `{}` is not a directory",
505            path.display()
506        )))
507    }
508}
509
510fn resolve_under_cwd(cwd: &Path, path: &Path) -> Result<PathBuf> {
511    let candidate = if path.is_absolute() {
512        path.to_path_buf()
513    } else {
514        cwd.join(path)
515    };
516    let normalized = normalize_absolute_path(&candidate);
517
518    if normalized.starts_with(cwd) {
519        Ok(normalized)
520    } else {
521        Err(CliError::InvalidConfiguration(format!(
522            "Refusing to access `{}` outside build cwd `{}`",
523            normalized.display(),
524            cwd.display()
525        )))
526    }
527}
528
529fn ensure_removable_child(cwd: &Path, path: &Path) -> Result<()> {
530    if path.starts_with(cwd) && path != cwd {
531        Ok(())
532    } else {
533        Err(CliError::InvalidConfiguration(format!(
534            "Refusing to delete `{}` outside build cwd `{}`",
535            path.display(),
536            cwd.display()
537        )))
538    }
539}
540
541fn normalize_absolute_path(path: &Path) -> PathBuf {
542    let mut normalized = PathBuf::new();
543
544    for component in path.components() {
545        match component {
546            Component::CurDir => {}
547            Component::ParentDir => {
548                normalized.pop();
549            }
550            _ => normalized.push(component.as_os_str()),
551        }
552    }
553
554    normalized
555}
556
557fn normalize_path(path: &Path) -> String {
558    path.components()
559        .map(|component| component.as_os_str().to_string_lossy())
560        .collect::<Vec<_>>()
561        .join("/")
562}
563
564fn normalize_pattern(pattern: impl AsRef<str>) -> String {
565    pattern.as_ref().replace('\\', "/")
566}
567
568fn has_glob_chars(value: &str) -> bool {
569    value.contains('*') || value.contains('?')
570}
571
572fn wildcard_matches(pattern: &str, value: &str) -> bool {
573    wildcard_match_bytes(pattern.as_bytes(), value.as_bytes())
574}
575
576fn wildcard_match_bytes(pattern: &[u8], value: &[u8]) -> bool {
577    if pattern.is_empty() {
578        return value.is_empty();
579    }
580
581    if pattern.starts_with(b"**/") {
582        return wildcard_match_bytes(&pattern[3..], value)
583            || value
584                .iter()
585                .position(|byte| *byte == b'/')
586                .is_some_and(|index| wildcard_match_bytes(pattern, &value[index + 1..]));
587    }
588
589    match pattern[0] {
590        b'*' => {
591            wildcard_match_bytes(&pattern[1..], value)
592                || (!value.is_empty()
593                    && value[0] != b'/'
594                    && wildcard_match_bytes(pattern, &value[1..]))
595        }
596        b'?' => {
597            !value.is_empty()
598                && value[0] != b'/'
599                && wildcard_match_bytes(&pattern[1..], &value[1..])
600        }
601        byte => {
602            !value.is_empty()
603                && byte == value[0]
604                && wildcard_match_bytes(&pattern[1..], &value[1..])
605        }
606    }
607}
608
609fn quote_command_path(path: &Path) -> String {
610    let value = path.display().to_string();
611    if value.contains(char::is_whitespace) {
612        format!("\"{}\"", value.replace('"', "\\\""))
613    } else {
614        value
615    }
616}