beet_build 0.0.8

Codegen and compilation tooling for beet
use crate::prelude::*;
use beet_core::prelude::*;
use beet_flow::prelude::*;
use std::hash::Hasher;
use std::path::PathBuf;


/// In beet all config is determined in the launch scene.
/// This config determines its location and configuration for generating
/// as required.
#[derive(Debug, Clone, Resource)]
pub struct LaunchConfig {
	/// Location of the `launch.ron` file to load the launch scene from.
	/// See [`WorkspaceConfig::launch_file`] to direct the generator to this location.
	pub launch_file: WsPathBuf,
	/// Run the launch step even if no change is detected
	pub force_launch: bool,
	/// The package to run when generating a launch scene
	pub package: Option<String>,
	/// Additional args to run for the launch step. With [`Self::no_default_args`]
	/// this will be the only command to run, otherwise these are treated as
	/// additional cargo args.
	pub additional_args: Option<String>,
	/// Exclude the 'launch' feature, package name etc from the launch step,
	/// ie only use the [`Self::launch_cargo_args`]
	pub no_default_args: bool,
}

impl Default for LaunchConfig {
	fn default() -> Self {
		Self {
			launch_file: WsPathBuf::new("launch.ron"),
			force_launch: false,
			package: None,
			additional_args: None,
			no_default_args: false,
		}
	}
}

impl LaunchConfig {
	/// Executed by a binary in its launch phase, resulting in
	/// an output [`launch.ron`] file
	pub fn runner(mut app: App) -> AppExit {
		app.init();

		app.update();
		app.world_mut()
			.run_system_once::<_, (), _>(insert_launch_hash)
			.unwrap();
		app.world_mut()
			.run_system_once::<_, (), _>(export_launch_scene)
			.unwrap();
		AppExit::Success
	}
}



/// A hash of all files matching the [`WorkspaceConfig::launch_filter`]
/// Indicates this scene is was generated by the launch step of an application,
/// containing configuration for all codegen and build steps.
/// It is usually located at `launch.ron` but this can be configured in the cli.
/// By default the `src/launch.rs` is watched, this can be extended if more files should be watched.
#[derive(
	Debug, Deref, PartialEq, Eq, PartialOrd, Ord, Hash, Reflect, Resource,
)]
#[reflect(Resource)]
pub struct LaunchHash {
	/// The hash of the [`watched_files`](Self::watched_files) at the time
	/// this state was generated.
	hash: u64,
}


impl LaunchHash {
	/// Create a new [`LaunchHash`] by hashing all files matching
	/// the [`WorkspaceConfig::launch_filter`]
	fn new(ws_config: &WorkspaceConfig) -> Result<Self> {
		let files = ReadDir::files_recursive(&ws_config.root_dir.into_abs())?
			.into_iter()
			.filter(|path| ws_config.launch_filter.passes(path))
			.collect::<Vec<_>>();

		let hash = Self::hash_paths(&files)?;
		Self { hash }.xok()
	}

	fn hash_paths(paths: &Vec<PathBuf>) -> Result<u64> {
		let mut hasher = FixedHasher::default().build_hasher();

		for path in paths {
			// let path = path.into_abs();
			let bytes = fs_ext::read(&path)?;
			hasher.write(&bytes);
		}
		Ok(hasher.finish())
	}
}


fn insert_launch_hash(
	mut commands: Commands,
	ws_config: Res<WorkspaceConfig>,
) -> Result {
	let hash = LaunchHash::new(&ws_config)?;
	commands.insert_resource(hash);
	Ok(())
}

fn export_launch_scene(world: &mut World) -> Result {
	let scene = world.build_scene();
	let launch_file =
		world.resource::<WorkspaceConfig>().launch_file.into_abs();
	fs_ext::write(&launch_file, scene)?;
	info!("Exported launch scene: {}", launch_file);
	Ok(())
}

/// The launch sequence will conditionally run the launch step
/// and then load the launch scene into the world
pub fn launch_sequence() -> impl Bundle {
	(Name::new("Launch Sequence"), InfallibleSequence, children![
		(
			Name::new("Launch Step"),
			Sequence,
			// Run launch to generate scene if needed
			children![launch_step_predicate(), run_launch_step()]
		),
		// load beet file into world
		// regardless of whether we needed to run the launch step
		(
			Name::new("Load Launch Scene"),
			OnSpawn::observe(load_launch_scene)
		)
	])
}

fn load_launch_scene(
	ev: On<GetOutcome>,
	mut commands: Commands,
	config: Res<LaunchConfig>,
) -> Result {
	// missing scene is an error at this stage
	let scene = fs_ext::read_to_string(&config.launch_file.into_abs())?;
	commands.load_scene(scene);

	commands.entity(ev.target()).trigger_target(Outcome::Pass);
	Ok(())
}

/// Whether to run the launch step, if either are true:
/// - [`LaunchConfig::force_launch`]
/// - [`LaunchConfig::launch_file`] exists and when loaded
/// the [`LaunchHash`] does not match the current state
/// and !force_lanch
fn launch_step_predicate() -> impl Bundle {
	(
		Name::new("Launch Step Predicate"),
		OnSpawn::observe(
			|ev: On<GetOutcome>,
			 workspace_config: Res<WorkspaceConfig>,
			 launch_config: Res<LaunchConfig>,
			 type_registry: Res<AppTypeRegistry>,
			 mut commands: Commands|
			 -> Result<()> {
				if launch_config.force_launch {
					commands.entity(ev.target()).trigger_target(Outcome::Pass);
					return Ok(());
				}
				let Ok(scene) = fs_ext::read_to_string(
					&workspace_config.launch_file.into_abs(),
				) else {
					// no scene, should run
					commands.entity(ev.target()).trigger_target(Outcome::Pass);
					return Ok(());
				};
				// create a temp world to extract resources from the launch scene
				let mut temp_world = World::new();
				temp_world.insert_resource(type_registry.clone());
				temp_world.load_scene(scene)?;
				let launch_hash = temp_world.get_resource::<LaunchHash>().ok_or_else(||{
					bevyhow!(
						"LaunchHash is missing from launch scene, this can happen if it was not generated with the LaunchConfig::runner"
					)
				})?;
				let ws_config = temp_world.resource::<WorkspaceConfig>();
				let current_hash = LaunchHash::new(&ws_config)?;

				let outcome = if &current_hash == launch_hash {
					// hashes match, should not run
					Outcome::Fail
				} else {
					// no match, should run
					Outcome::Pass
				};
				commands.entity(ev.target()).trigger_target(outcome);
				Ok(())
			},
		),
	)
}

/// Execute a command to run the launch step using [`CommandConfig`]
/// ## Panics
/// Panics if no_default_args but no additional args present
fn run_launch_step() -> impl Bundle {
	(
		Name::new("Run Launch Step"),
		OnSpawn::observe(
			|ev: On<GetOutcome>,
			 mut cmd_runner: CommandRunner,
			 config: Res<LaunchConfig>| {
				let cmd_config = if config.no_default_args {
					let additional_args = config
						.additional_args
						.clone()
						.ok_or_else(|| {
							bevyhow!(
								"LaunchConfig::no_default_args is true but no additional_args provided"
							)
						})
						.unwrap();
					CommandConfig::parse(additional_args)
				} else {
					let mut cmd = CargoBuildCmd::new("run")
						.no_default_features()
						.feature("launch");
					if let Some(package) = &config.package {
						cmd = cmd.package(package);
					}
					let mut cmd_config = CommandConfig::from_cargo(&cmd);
					if let Some(launch_cargo_args) = &config.additional_args {
						for arg in launch_cargo_args.split_whitespace() {
							cmd_config = cmd_config.arg(arg);
						}
					}
					cmd_config
				};

				cmd_runner.run(ev, cmd_config)
			},
		),
	)
}

#[cfg(test)]
mod test {
	use crate::prelude::*;
	use beet_core::prelude::*;

	#[test]
	fn works() {
		LaunchHash::new(&WorkspaceConfig::default())
			.unwrap()
			.hash
			.xpect_not_eq(0);
	}
}