use std::collections::BTreeMap;
use std::fs;
use std::path::{Component, Path, PathBuf};
use std::thread;
use std::time::{Duration, SystemTime};
use crate::Result;
use crate::build_action::{BuildActionPlan, ProjectBuildActionPlan};
use crate::compiler::{AssetPlan, CompilerInputs, OutputCleanup};
use crate::error::CliError;
use crate::runners::{ProcessRunner, Runner, RunnerCommand, RunnerKind};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BuildExecutionPlan {
pub commands: Vec<RunnerCommand>,
pub warnings: Vec<String>,
pub projects: Vec<ProjectBuildExecutionPlan>,
pub watch: Option<BuildWatchExecutionPlan>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ProjectBuildExecutionPlan {
pub command: RunnerCommand,
pub cwd: PathBuf,
pub source_root: PathBuf,
pub assets: Vec<AssetPlan>,
pub output_cleanup: Option<OutputCleanup>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BuildWatchExecutionPlan {
pub poll_interval: Duration,
pub projects: Vec<ProjectBuildWatchExecutionPlan>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ProjectBuildWatchExecutionPlan {
pub project_index: usize,
pub roots: Vec<PathBuf>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BuildWatchState {
pub project_snapshots: Vec<FileSnapshot>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BuildWatchTickResult {
pub project_index: usize,
pub changed: bool,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct FileSnapshot {
files: BTreeMap<PathBuf, FileFingerprint>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct FileFingerprint {
modified: SystemTime,
len: u64,
}
pub fn create_build_execution_plan(plan: &BuildActionPlan) -> Result<BuildExecutionPlan> {
let projects = plan
.project_plans
.iter()
.map(project_execution_plan)
.collect::<Vec<_>>();
let warnings = plan
.type_check_warnings
.iter()
.map(|warning| warning.message.clone())
.collect();
let commands = projects
.iter()
.map(|project| project.command.clone())
.collect();
let watch = plan
.watch_mode
.then(|| create_build_watch_execution_plan(&projects));
Ok(BuildExecutionPlan {
commands,
warnings,
projects,
watch,
})
}
pub fn execute_build_plan(plan: &BuildExecutionPlan) -> Result<()> {
for project in &plan.projects {
execute_project_build(project)?;
}
Ok(())
}
pub fn execute_build_watch_plan(plan: &BuildExecutionPlan) -> Result<()> {
let watch = plan.watch.as_ref().ok_or_else(|| {
CliError::UnsupportedCommand(
"`nest build --watch` requires a watch execution plan".to_string(),
)
})?;
execute_build_plan(plan)?;
let mut state = create_build_watch_state(watch)?;
loop {
thread::sleep(watch.poll_interval);
execute_build_watch_tick(plan, &mut state)?;
}
}
pub fn create_build_watch_state(watch: &BuildWatchExecutionPlan) -> Result<BuildWatchState> {
let project_snapshots = watch
.projects
.iter()
.map(|project| snapshot_roots(&project.roots))
.collect::<Result<Vec<_>>>()?;
Ok(BuildWatchState { project_snapshots })
}
pub fn execute_build_watch_tick(
plan: &BuildExecutionPlan,
state: &mut BuildWatchState,
) -> Result<Vec<BuildWatchTickResult>> {
build_watch_tick(plan, state, execute_project_build)
}
pub fn build_watch_tick(
plan: &BuildExecutionPlan,
state: &mut BuildWatchState,
mut rebuild: impl FnMut(&ProjectBuildExecutionPlan) -> Result<()>,
) -> Result<Vec<BuildWatchTickResult>> {
let watch = plan.watch.as_ref().ok_or_else(|| {
CliError::UnsupportedCommand(
"`nest build --watch` requires a watch execution plan".to_string(),
)
})?;
let mut results = Vec::with_capacity(watch.projects.len());
for project_watch in &watch.projects {
let next_snapshot = snapshot_roots(&project_watch.roots)?;
let previous_snapshot = state
.project_snapshots
.get(project_watch.project_index)
.ok_or_else(|| {
CliError::InvalidConfiguration(format!(
"Missing watch snapshot for project index {}",
project_watch.project_index
))
})?;
let changed = previous_snapshot != &next_snapshot;
if changed {
let project = plan
.projects
.get(project_watch.project_index)
.ok_or_else(|| {
CliError::InvalidConfiguration(format!(
"Missing build project for watch index {}",
project_watch.project_index
))
})?;
rebuild(project)?;
}
if let Some(snapshot) = state.project_snapshots.get_mut(project_watch.project_index) {
*snapshot = next_snapshot;
}
results.push(BuildWatchTickResult {
project_index: project_watch.project_index,
changed,
});
}
Ok(results)
}
fn project_execution_plan(project_plan: &ProjectBuildActionPlan) -> ProjectBuildExecutionPlan {
let inputs = &project_plan.build_plan.inputs;
ProjectBuildExecutionPlan {
command: cargo_build_command(project_plan),
cwd: inputs.cwd.clone(),
source_root: inputs.source_root.clone(),
assets: inputs.assets.clone(),
output_cleanup: inputs.output_cleanup.clone(),
}
}
fn create_build_watch_execution_plan(
projects: &[ProjectBuildExecutionPlan],
) -> BuildWatchExecutionPlan {
BuildWatchExecutionPlan {
poll_interval: Duration::from_secs(1),
projects: projects
.iter()
.enumerate()
.map(|(index, project)| ProjectBuildWatchExecutionPlan {
project_index: index,
roots: watch_roots(project),
})
.collect(),
}
}
fn watch_roots(project: &ProjectBuildExecutionPlan) -> Vec<PathBuf> {
let mut roots = vec![resolve_for_watch(&project.cwd, &project.source_root)];
for asset in &project.assets {
let root = match (&asset.include, asset.glob.is_empty()) {
(Some(include), false) => resolve_for_watch(&project.cwd, include),
(Some(include), true) => split_glob_root_for_watch(&project.cwd, include),
(None, _) => resolve_for_watch(&project.cwd, &project.source_root),
};
if !roots.contains(&root) {
roots.push(root);
}
}
roots
}
fn resolve_for_watch(cwd: &Path, path: &Path) -> PathBuf {
if path.is_absolute() {
normalize_absolute_path(path)
} else {
normalize_absolute_path(&cwd.join(path))
}
}
fn split_glob_root_for_watch(cwd: &Path, pattern: &Path) -> PathBuf {
let mut base = PathBuf::new();
for component in pattern.components() {
let text = component.as_os_str().to_string_lossy();
if has_glob_chars(&text) {
break;
}
base.push(component.as_os_str());
}
resolve_for_watch(cwd, &base)
}
fn execute_project_build(project: &ProjectBuildExecutionPlan) -> Result<()> {
if let Some(cleanup) = &project.output_cleanup {
clean_output(&project.cwd, cleanup)?;
}
project.command.execute()?;
copy_assets(&project.cwd, &project.source_root, &project.assets)?;
Ok(())
}
fn cargo_build_command(project_plan: &ProjectBuildActionPlan) -> RunnerCommand {
let inputs = &project_plan.build_plan.inputs;
let command = match &project_plan.app_name {
Some(_) => {
let manifest_path = project_manifest_path(project_plan, inputs);
format!(
"build --manifest-path {}",
quote_command_path(&manifest_path)
)
}
None => "build".to_string(),
};
ProcessRunner::new(RunnerKind::Cargo).describe(command, false, Some(inputs.cwd.clone()))
}
fn snapshot_roots(roots: &[PathBuf]) -> Result<FileSnapshot> {
let mut snapshot = FileSnapshot::default();
for root in roots {
snapshot_path(root, &mut snapshot)?;
}
Ok(snapshot)
}
fn snapshot_path(path: &Path, snapshot: &mut FileSnapshot) -> Result<()> {
if !path.exists() {
return Ok(());
}
let metadata = fs::metadata(path)?;
if metadata.is_file() {
snapshot.files.insert(
normalize_absolute_path(path),
FileFingerprint {
modified: metadata.modified()?,
len: metadata.len(),
},
);
return Ok(());
}
if metadata.is_dir() {
for entry in fs::read_dir(path)? {
let entry = entry?;
snapshot_path(&entry.path(), snapshot)?;
}
}
Ok(())
}
fn project_manifest_path(
project_plan: &ProjectBuildActionPlan,
inputs: &CompilerInputs,
) -> PathBuf {
project_plan
.project_root
.clone()
.unwrap_or_else(|| source_root_project_root(&inputs.source_root))
.join("Cargo.toml")
}
fn source_root_project_root(source_root: &Path) -> PathBuf {
source_root
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.map(Path::to_path_buf)
.unwrap_or_else(|| source_root.to_path_buf())
}
fn clean_output(cwd: &Path, cleanup: &OutputCleanup) -> Result<()> {
let cwd = canonical_existing_dir(cwd)?;
let out_dir = resolve_under_cwd(&cwd, &cleanup.out_dir)?;
if out_dir.exists() {
let canonical_out_dir = out_dir.canonicalize()?;
ensure_removable_child(&cwd, &canonical_out_dir)?;
fs::remove_dir_all(canonical_out_dir)?;
}
if let Some(ts_build_info_file) = &cleanup.ts_build_info_file {
let ts_build_info_file = resolve_under_cwd(&cwd, ts_build_info_file)?;
if ts_build_info_file.exists() {
let canonical_file = ts_build_info_file.canonicalize()?;
ensure_removable_child(&cwd, &canonical_file)?;
if canonical_file.is_file() {
fs::remove_file(canonical_file)?;
}
}
}
Ok(())
}
fn copy_assets(cwd: &Path, source_root: &Path, assets: &[AssetPlan]) -> Result<()> {
let cwd = canonical_existing_dir(cwd)?;
for asset in assets {
let copy_plan = AssetCopyPlan::from_asset(&cwd, source_root, asset)?;
if !copy_plan.base.exists() {
continue;
}
for file in collect_matching_files(
©_plan.base,
©_plan.pattern,
asset.exclude.as_deref(),
)? {
let relative = file
.strip_prefix(©_plan.base)
.map_err(|error| CliError::InvalidConfiguration(error.to_string()))?;
let destination = if asset.flat {
let file_name = file.file_name().ok_or_else(|| {
CliError::InvalidConfiguration(format!(
"Asset path `{}` has no file name",
file.display()
))
})?;
copy_plan.out_dir.join(file_name)
} else {
copy_plan.out_dir.join(relative)
};
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(&file, destination)?;
}
}
Ok(())
}
#[derive(Debug)]
struct AssetCopyPlan {
base: PathBuf,
pattern: String,
out_dir: PathBuf,
}
impl AssetCopyPlan {
fn from_asset(cwd: &Path, source_root: &Path, asset: &AssetPlan) -> Result<Self> {
let out_dir = resolve_under_cwd(cwd, &asset.out_dir)?;
let source_root = resolve_under_cwd(cwd, source_root)?;
let (base, pattern) = match (&asset.include, asset.glob.is_empty()) {
(Some(include), false) => (resolve_under_cwd(cwd, include)?, asset.glob.clone()),
(Some(include), true) => split_glob_root(cwd, include)?,
(None, false) => (source_root, asset.glob.clone()),
(None, true) => (source_root, "**/*".to_string()),
};
Ok(Self {
base,
pattern: normalize_pattern(&pattern),
out_dir,
})
}
}
fn split_glob_root(cwd: &Path, pattern: &Path) -> Result<(PathBuf, String)> {
let mut base = PathBuf::new();
let mut pattern_parts = Vec::new();
let mut in_pattern = false;
for component in pattern.components() {
let text = component.as_os_str().to_string_lossy();
if !in_pattern && has_glob_chars(&text) {
in_pattern = true;
}
if in_pattern {
pattern_parts.push(text.into_owned());
} else {
base.push(component.as_os_str());
}
}
if pattern_parts.is_empty() {
let file_name = base
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| "**/*".to_string());
base.pop();
pattern_parts.push(file_name);
}
Ok((resolve_under_cwd(cwd, &base)?, pattern_parts.join("/")))
}
fn collect_matching_files(
base: &Path,
pattern: &str,
exclude: Option<&str>,
) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
collect_matching_files_inner(
base,
base,
pattern,
exclude.map(normalize_pattern),
&mut files,
)?;
Ok(files)
}
fn collect_matching_files_inner(
base: &Path,
current: &Path,
pattern: &str,
exclude: Option<String>,
files: &mut Vec<PathBuf>,
) -> Result<()> {
for entry in fs::read_dir(current)? {
let entry = entry?;
let path = entry.path();
if entry.file_type()?.is_dir() {
collect_matching_files_inner(base, &path, pattern, exclude.clone(), files)?;
continue;
}
let relative = path
.strip_prefix(base)
.map_err(|error| CliError::InvalidConfiguration(error.to_string()))?;
let relative = normalize_path(relative);
if wildcard_matches(pattern, &relative)
&& !exclude
.as_deref()
.is_some_and(|exclude| wildcard_matches(exclude, &relative))
{
files.push(path);
}
}
Ok(())
}
fn canonical_existing_dir(path: &Path) -> Result<PathBuf> {
let canonical = path.canonicalize()?;
if canonical.is_dir() {
Ok(canonical)
} else {
Err(CliError::InvalidConfiguration(format!(
"Build cwd `{}` is not a directory",
path.display()
)))
}
}
fn resolve_under_cwd(cwd: &Path, path: &Path) -> Result<PathBuf> {
let candidate = if path.is_absolute() {
path.to_path_buf()
} else {
cwd.join(path)
};
let normalized = normalize_absolute_path(&candidate);
if normalized.starts_with(cwd) {
Ok(normalized)
} else {
Err(CliError::InvalidConfiguration(format!(
"Refusing to access `{}` outside build cwd `{}`",
normalized.display(),
cwd.display()
)))
}
}
fn ensure_removable_child(cwd: &Path, path: &Path) -> Result<()> {
if path.starts_with(cwd) && path != cwd {
Ok(())
} else {
Err(CliError::InvalidConfiguration(format!(
"Refusing to delete `{}` outside build cwd `{}`",
path.display(),
cwd.display()
)))
}
}
fn normalize_absolute_path(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
normalized.pop();
}
_ => normalized.push(component.as_os_str()),
}
}
normalized
}
fn normalize_path(path: &Path) -> String {
path.components()
.map(|component| component.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/")
}
fn normalize_pattern(pattern: impl AsRef<str>) -> String {
pattern.as_ref().replace('\\', "/")
}
fn has_glob_chars(value: &str) -> bool {
value.contains('*') || value.contains('?')
}
fn wildcard_matches(pattern: &str, value: &str) -> bool {
wildcard_match_bytes(pattern.as_bytes(), value.as_bytes())
}
fn wildcard_match_bytes(pattern: &[u8], value: &[u8]) -> bool {
if pattern.is_empty() {
return value.is_empty();
}
if pattern.starts_with(b"**/") {
return wildcard_match_bytes(&pattern[3..], value)
|| value
.iter()
.position(|byte| *byte == b'/')
.is_some_and(|index| wildcard_match_bytes(pattern, &value[index + 1..]));
}
match pattern[0] {
b'*' => {
wildcard_match_bytes(&pattern[1..], value)
|| (!value.is_empty()
&& value[0] != b'/'
&& wildcard_match_bytes(pattern, &value[1..]))
}
b'?' => {
!value.is_empty()
&& value[0] != b'/'
&& wildcard_match_bytes(&pattern[1..], &value[1..])
}
byte => {
!value.is_empty()
&& byte == value[0]
&& wildcard_match_bytes(&pattern[1..], &value[1..])
}
}
}
fn quote_command_path(path: &Path) -> String {
let value = path.display().to_string();
if value.contains(char::is_whitespace) {
format!("\"{}\"", value.replace('"', "\\\""))
} else {
value
}
}