1use 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 ©_plan.base,
366 ©_plan.pattern,
367 asset.exclude.as_deref(),
368 )? {
369 let relative = file
370 .strip_prefix(©_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}