foch-cli 0.0.2

EU4 mod analysis, merge and language server CLI
Documentation
use crate::cli::arg::{
	DataArgs, DataBuildArgs, DataInstallArgs, DataListArgs, FochCliDataCommands,
};
use crate::cli::handler::HandlerResult;
use foch::game::eu4::Eu4;
use foch::game::eu4::base::snapshot::{
	BaseBuildObserver, BaseDataSource, INSTALLED_COVERAGE_FILE_NAME,
	build_base_snapshot_with_observer, default_release_tag, install_built_snapshot,
	install_snapshot_from_release, list_installed_base_data, resolve_game_root_and_version,
	write_release_artifacts, write_snapshot_bundle,
};
use foch::input::{Config, FileFilter};

pub fn handle_data(data_args: &DataArgs, config: Config) -> HandlerResult {
	match &data_args.command {
		FochCliDataCommands::Install(args) => handle_data_install(args, config),
		FochCliDataCommands::Build(args) => handle_data_build(args, &config),
		FochCliDataCommands::List(args) => handle_data_list(args),
	}
}

fn handle_data_install(args: &DataInstallArgs, config: Config) -> HandlerResult {
	let game = parse_game(&args.game_name)?;
	let resolved_version = if args.game_version.eq_ignore_ascii_case("auto") {
		let (_game_root, version) = resolve_game_root_and_version(&config, &game)
			.map_err(|err| -> Box<dyn std::error::Error> { err.into() })?;
		version
	} else {
		args.game_version.clone()
	};
	let installed =
		install_snapshot_from_release(&game, &resolved_version, args.release_tag.as_deref())
			.map_err(|err| -> Box<dyn std::error::Error> { err.into() })?;
	println!(
		"installed base data: game={} version={} source=download path={}",
		installed.metadata.game,
		installed.metadata.game_version,
		installed.install_dir.display()
	);
	Ok(0)
}

fn handle_data_build(args: &DataBuildArgs, config: &Config) -> HandlerResult {
	let observer = BaseBuildObserver::stderr(&args.game_name);
	for line in run_data_build(args, config, observer)? {
		println!("{line}");
	}
	Ok(0)
}

/// Build EU4 base data as `foch data build` does and return its report
/// lines. Bare `foch` builds through this too, with the arguments it shows
/// as the command.
pub fn run_data_build(
	args: &DataBuildArgs,
	config: &Config,
	mut observer: BaseBuildObserver,
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
	let mut report = Vec::new();
	if !args.install && args.output_dir.is_none() && !args.release_asset {
		return Err(
			"please specify at least one of --install, --output-dir, or --release-asset".into(),
		);
	}

	let game = parse_game(&args.game_name)?;
	let resolved_version = if args.game_version.eq_ignore_ascii_case("auto") {
		None
	} else {
		Some(args.game_version.as_str())
	};
	let filter = FileFilter::new(game, &config.extra_ignore_patterns)
		.map_err(|err| -> Box<dyn std::error::Error> { err.into() })?;
	let build = build_base_snapshot_with_observer(
		&game,
		&args.from_game_path,
		resolved_version,
		&filter,
		&mut observer,
	)
	.map_err(|err: String| -> Box<dyn std::error::Error> { err.into() })?;

	observer
		.run_stage(
			"write_outputs",
			|counts: &mut std::collections::BTreeMap<String, u64>| {
				if args.install {
					let installed = install_built_snapshot(
						&build.encoded_snapshot,
						BaseDataSource::Build,
						Some(build.snapshot_asset_name.clone()),
						Some(build.snapshot_sha256.clone()),
					)?;
					counts.insert(
						"install_snapshot_bytes".to_string(),
						build.encoded_snapshot.len() as u64,
					);
					let coverage_bytes =
						std::fs::metadata(installed.install_dir.join(INSTALLED_COVERAGE_FILE_NAME))
							.map(|item| item.len())
							.unwrap_or_default();
					counts.insert("install_coverage_bytes".to_string(), coverage_bytes);
					report.push(format!(
						"built and installed base data: game={} version={} path={}",
						installed.metadata.game,
						installed.metadata.game_version,
						installed.install_dir.display()
					));
				}

				if args.release_asset {
					let output_dir = args.output_dir.as_ref().ok_or_else(|| {
						"--output-dir is required when using --release-asset".to_string()
					})?;
					let release_output = write_release_artifacts(
						&build.encoded_snapshot,
						output_dir,
						&default_release_tag(),
					)?;
					let manifest_bytes = std::fs::metadata(&release_output.manifest_path)
						.map(|item| item.len())
						.unwrap_or_default();
					let coverage_bytes = std::fs::metadata(&release_output.coverage_path)
						.map(|item| item.len())
						.unwrap_or_default();
					counts.insert(
						"release_snapshot_bytes".to_string(),
						build.encoded_snapshot.len() as u64,
					);
					counts.insert("release_manifest_bytes".to_string(), manifest_bytes);
					counts.insert("release_coverage_bytes".to_string(), coverage_bytes);
					report.push(format!(
						"wrote release data asset: snapshot={} manifest={} coverage={}",
						release_output.snapshot_path.display(),
						release_output.manifest_path.display(),
						release_output.coverage_path.display()
					));
				} else if let Some(output_dir) = args.output_dir.as_ref() {
					let bundle = write_snapshot_bundle(
						&build.encoded_snapshot,
						output_dir,
						BaseDataSource::Build,
						Some(build.snapshot_asset_name.clone()),
						Some(build.snapshot_sha256.clone()),
					)?;
					let metadata_bytes = std::fs::metadata(&bundle.metadata_path)
						.map(|item| item.len())
						.unwrap_or_default();
					let coverage_bytes = std::fs::metadata(&bundle.coverage_path)
						.map(|item| item.len())
						.unwrap_or_default();
					counts.insert(
						"bundle_snapshot_bytes".to_string(),
						build.encoded_snapshot.len() as u64,
					);
					counts.insert("bundle_metadata_bytes".to_string(), metadata_bytes);
					counts.insert("bundle_coverage_bytes".to_string(), coverage_bytes);
					report.push(format!(
						"wrote snapshot bundle: snapshot={} metadata={} coverage={}",
						bundle.snapshot_path.display(),
						bundle.metadata_path.display(),
						bundle.coverage_path.display()
					));
				}
				Ok(())
			},
		)
		.map_err(|err: String| -> Box<dyn std::error::Error> { err.into() })?;

	let profile = observer.finish();
	if let Some(path) = args.profile_out.as_ref() {
		if let Some(parent) = path.parent()
			&& !parent.as_os_str().is_empty()
		{
			std::fs::create_dir_all(parent)?;
		}
		std::fs::write(path, serde_json::to_vec_pretty(&profile)?)?;
		report.push(format!("wrote profiling result: {}", path.display()));
	}

	Ok(report)
}

fn handle_data_list(args: &DataListArgs) -> HandlerResult {
	let entries = list_installed_base_data()
		.map_err(|err: String| -> Box<dyn std::error::Error> { err.into() })?;
	if args.json {
		println!("{}", serde_json::to_string_pretty(&entries)?);
		return Ok(0);
	}

	if entries.is_empty() {
		println!("No installed base data");
		return Ok(0);
	}

	for entry in entries {
		println!(
			"game={} version={} data={} source={:?} path={}",
			entry.game,
			entry.game_version,
			entry.snapshot_format_version,
			entry.source,
			entry.install_path.display()
		);
	}

	Ok(0)
}

fn parse_game(value: &str) -> Result<Eu4, Box<dyn std::error::Error>> {
	Eu4::from_key(value).ok_or_else(|| format!("unsupported game key: {value}").into())
}