feat(catalog): separate generated production and local test authority
The launcher previously defaulted to fixtures and production generation used the source database directly. Generate a separate database and manifest set from package directories, validating staged output with the application loader before installation. Keep strict selection by default, with independent opt-ins for missing games and package version overrides in the copied database. Share database filtering and staging with the fixture publisher, and include source metadata and generation modes in the publication cache. Make normal runs consume existing production authority. Add an explicit local test recipe with separate resources, app settings, and a compiled startup game directory. Build-time gates exclude local authority from production and require Tauri development mode. Document the generation and launch workflows. Clear generated catalog copies before Tauri copies the selected resource tree so mode switches and reduced catalogs cannot retain stale manifests. Watch the copied files to repair deletion and preserve prior output mtimes only when the bytes are unchanged, allowing subsequent builds to become fresh. Test Plan: - `just fmt` -- passed. - `just clippy` -- passed with warnings denied. - `just test` -- workspace tests passed using fixture authority. - `just frontend-test` -- 94 passed. - `python3 -m unittest discover -s tools -p 'test_catalog_source_cache.py'` -- 4 passed. - `git diff --cached --check` -- passed. - Interactive GUI launches and production bundles were not exercised.
This commit is contained in:
1 parent
ab0c95b80f
commit
6b65a66465
21 files changed
+1777
-273
No files matched your search
@@ -0,0 +1,202 @@
|
||||
use std::{
|
||||
ffi::OsString,
|
||||
io::{self, Write},
|
||||
path::PathBuf,
|
||||
};
|
||||
|
||||
use lanspread_compat::catalog_publisher::production::{
|
||||
ProductionCatalogOptions,
|
||||
generate_production_catalog,
|
||||
};
|
||||
|
||||
const HELP: &str = "\
|
||||
Usage:
|
||||
lanspread-production-catalog --source-catalog-db PATH --packages-dir PATH --output-dir PATH --unrar PATH [--allow-missing-games] [--allow-version-mismatch]
|
||||
|
||||
Missing games and version mismatches each require their corresponding explicit option.";
|
||||
|
||||
enum ParseOutcome {
|
||||
Help,
|
||||
Options(ProductionCatalogOptions),
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
if let Err(error) = run().await {
|
||||
eprintln!("error: {error:#}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async fn run() -> eyre::Result<()> {
|
||||
match parse_args(std::env::args_os().skip(1))? {
|
||||
ParseOutcome::Help => println!("{HELP}"),
|
||||
ParseOutcome::Options(options) => {
|
||||
let report = generate_production_catalog(&options).await?;
|
||||
let stdout = io::stdout();
|
||||
let mut stdout = stdout.lock();
|
||||
for manifest in &report.manifests {
|
||||
writeln!(
|
||||
stdout,
|
||||
"generated game_id={} game_version={} content_id={}",
|
||||
manifest.game_id, manifest.game_version, manifest.content_id
|
||||
)?;
|
||||
}
|
||||
for game_id in &report.version_mismatches {
|
||||
writeln!(stdout, "included game_id={game_id} source=package-version")?;
|
||||
}
|
||||
writeln!(stdout, "generated total={}", report.manifests.len())?;
|
||||
writeln!(
|
||||
stdout,
|
||||
"skipped missing_games={} allowed_version_mismatches={}",
|
||||
report.missing_games,
|
||||
report.version_mismatches.len()
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_args(args: impl IntoIterator<Item = OsString>) -> eyre::Result<ParseOutcome> {
|
||||
let mut source_catalog_db = None;
|
||||
let mut packages_dir = None;
|
||||
let mut output_dir = None;
|
||||
let mut unrar = None;
|
||||
let mut allow_missing_games = false;
|
||||
let mut allow_version_mismatch = false;
|
||||
let mut args = args.into_iter();
|
||||
|
||||
while let Some(argument) = args.next() {
|
||||
match argument.to_str() {
|
||||
Some("--help" | "-h") => return Ok(ParseOutcome::Help),
|
||||
Some("--source-catalog-db") => set_once(
|
||||
&mut source_catalog_db,
|
||||
next_path(&mut args, "--source-catalog-db")?,
|
||||
"--source-catalog-db",
|
||||
)?,
|
||||
Some("--packages-dir") => set_once(
|
||||
&mut packages_dir,
|
||||
next_path(&mut args, "--packages-dir")?,
|
||||
"--packages-dir",
|
||||
)?,
|
||||
Some("--output-dir") => set_once(
|
||||
&mut output_dir,
|
||||
next_path(&mut args, "--output-dir")?,
|
||||
"--output-dir",
|
||||
)?,
|
||||
Some("--unrar") => {
|
||||
set_once(&mut unrar, next_path(&mut args, "--unrar")?, "--unrar")?;
|
||||
}
|
||||
Some("--allow-missing-games") => {
|
||||
if allow_missing_games {
|
||||
eyre::bail!("--allow-missing-games may be specified only once");
|
||||
}
|
||||
allow_missing_games = true;
|
||||
}
|
||||
Some("--allow-version-mismatch") => {
|
||||
if allow_version_mismatch {
|
||||
eyre::bail!("--allow-version-mismatch may be specified only once");
|
||||
}
|
||||
allow_version_mismatch = true;
|
||||
}
|
||||
Some(other) => eyre::bail!("unknown argument: {other}"),
|
||||
None => eyre::bail!("argument is not valid UTF-8: {argument:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ParseOutcome::Options(ProductionCatalogOptions {
|
||||
source_catalog_db: required(source_catalog_db, "--source-catalog-db")?,
|
||||
packages_dir: required(packages_dir, "--packages-dir")?,
|
||||
output_dir: required(output_dir, "--output-dir")?,
|
||||
unrar: required(unrar, "--unrar")?,
|
||||
allow_missing_games,
|
||||
allow_version_mismatch,
|
||||
}))
|
||||
}
|
||||
|
||||
fn next_path(args: &mut impl Iterator<Item = OsString>, option: &str) -> eyre::Result<PathBuf> {
|
||||
args.next()
|
||||
.map(PathBuf::from)
|
||||
.ok_or_else(|| eyre::eyre!("{option} requires a value"))
|
||||
}
|
||||
|
||||
fn set_once<T>(slot: &mut Option<T>, value: T, option: &str) -> eyre::Result<()> {
|
||||
if slot.replace(value).is_some() {
|
||||
eyre::bail!("{option} may be specified only once");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn required<T>(value: Option<T>, option: &str) -> eyre::Result<T> {
|
||||
value.ok_or_else(|| eyre::eyre!("{option} is required"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn missing_and_version_mismatch_options_are_independent() {
|
||||
let strict = parse_args(
|
||||
[
|
||||
"--source-catalog-db",
|
||||
"source.db",
|
||||
"--packages-dir",
|
||||
"packages",
|
||||
"--output-dir",
|
||||
"output",
|
||||
"--unrar",
|
||||
"unrar",
|
||||
]
|
||||
.map(OsString::from),
|
||||
)
|
||||
.expect("strict arguments should parse");
|
||||
let ParseOutcome::Options(strict) = strict else {
|
||||
panic!("arguments should produce options");
|
||||
};
|
||||
assert!(!strict.allow_missing_games);
|
||||
assert!(!strict.allow_version_mismatch);
|
||||
|
||||
let missing_only = parse_args(
|
||||
[
|
||||
"--source-catalog-db",
|
||||
"source.db",
|
||||
"--packages-dir",
|
||||
"packages",
|
||||
"--output-dir",
|
||||
"output",
|
||||
"--unrar",
|
||||
"unrar",
|
||||
"--allow-missing-games",
|
||||
]
|
||||
.map(OsString::from),
|
||||
)
|
||||
.expect("missing-game opt-in should parse");
|
||||
let ParseOutcome::Options(missing_only) = missing_only else {
|
||||
panic!("arguments should produce options");
|
||||
};
|
||||
assert!(missing_only.allow_missing_games);
|
||||
assert!(!missing_only.allow_version_mismatch);
|
||||
|
||||
let version_only = parse_args(
|
||||
[
|
||||
"--source-catalog-db",
|
||||
"source.db",
|
||||
"--packages-dir",
|
||||
"packages",
|
||||
"--output-dir",
|
||||
"output",
|
||||
"--unrar",
|
||||
"unrar",
|
||||
"--allow-version-mismatch",
|
||||
]
|
||||
.map(OsString::from),
|
||||
)
|
||||
.expect("version-mismatch opt-in should parse");
|
||||
let ParseOutcome::Options(version_only) = version_only else {
|
||||
panic!("arguments should produce options");
|
||||
};
|
||||
assert!(!version_only.allow_missing_games);
|
||||
assert!(version_only.allow_version_mismatch);
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,7 @@
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
path::PathBuf,
|
||||
};
|
||||
|
||||
use eyre::WrapErr;
|
||||
@@ -19,21 +18,19 @@ use lanspread_db::content_manifest::{
|
||||
write_canonical_content_index_atomic,
|
||||
write_canonical_manifest_atomic,
|
||||
};
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||
|
||||
use super::{
|
||||
CatalogGame,
|
||||
CatalogSelection,
|
||||
CheckOptions,
|
||||
ManifestReport,
|
||||
catalog::{load_catalog_games, validate_regular_directory},
|
||||
catalog::load_catalog_games,
|
||||
check_catalog_manifests,
|
||||
package::{StreamedInstallPolicy, build_manifest_from_package_with_policy},
|
||||
staging::{CatalogStagingDirectory, copy_filtered_catalog},
|
||||
};
|
||||
use crate::catalog_bundle::load_catalog_bundle;
|
||||
|
||||
static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// One canonical package selected for a generated acceptance-test catalog.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct FixturePackage {
|
||||
@@ -69,7 +66,7 @@ pub async fn generate_fixture_catalog(
|
||||
) -> eyre::Result<Vec<ManifestReport>> {
|
||||
let source_catalog = load_catalog_games(&options.source_catalog_db).await?;
|
||||
let packages = validate_packages(&source_catalog, &options.packages)?;
|
||||
let staging = StagingDirectory::create(&options.output_dir)?;
|
||||
let staging = CatalogStagingDirectory::create(&options.output_dir)?;
|
||||
let staged_db = staging.path().join("game.db");
|
||||
let staged_manifests = staging.path().join("manifests");
|
||||
|
||||
@@ -77,6 +74,7 @@ pub async fn generate_fixture_catalog(
|
||||
&options.source_catalog_db,
|
||||
&staged_db,
|
||||
packages.keys().map(String::as_str),
|
||||
&BTreeMap::new(),
|
||||
)
|
||||
.await?;
|
||||
fs::create_dir(&staged_manifests).wrap_err_with(|| {
|
||||
@@ -178,152 +176,6 @@ fn validate_packages<'a>(
|
||||
Ok(selected)
|
||||
}
|
||||
|
||||
async fn copy_filtered_catalog<'a>(
|
||||
source: &Path,
|
||||
destination: &Path,
|
||||
selected_ids: impl Iterator<Item = &'a str>,
|
||||
) -> eyre::Result<()> {
|
||||
fs::copy(source, destination).wrap_err_with(|| {
|
||||
format!(
|
||||
"failed to copy source catalog {} to {}",
|
||||
source.display(),
|
||||
destination.display()
|
||||
)
|
||||
})?;
|
||||
let selected_ids = selected_ids.map(str::to_owned).collect::<BTreeSet<_>>();
|
||||
let all_games = load_catalog_games(destination).await?;
|
||||
let options = SqliteConnectOptions::new().filename(destination);
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect_with(options)
|
||||
.await
|
||||
.wrap_err_with(|| format!("failed to open fixture catalog {}", destination.display()))?;
|
||||
let mutation_result = async {
|
||||
let mut transaction = pool.begin().await?;
|
||||
for game_id in all_games.keys() {
|
||||
if !selected_ids.contains(game_id) {
|
||||
sqlx::query("DELETE FROM games WHERE game_id = ?")
|
||||
.bind(game_id)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
sqlx::query(
|
||||
"DELETE FROM genre
|
||||
WHERE genre_id NOT IN (SELECT DISTINCT genre_id FROM games)",
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
transaction.commit().await?;
|
||||
sqlx::query("VACUUM").execute(&pool).await?;
|
||||
Ok::<(), sqlx::Error>(())
|
||||
}
|
||||
.await;
|
||||
pool.close().await;
|
||||
mutation_result.wrap_err("failed to filter fixture catalog database")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct StagingDirectory {
|
||||
path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl StagingDirectory {
|
||||
fn create(output: &Path) -> eyre::Result<Self> {
|
||||
let parent = output
|
||||
.parent()
|
||||
.filter(|parent| !parent.as_os_str().is_empty())
|
||||
.unwrap_or_else(|| Path::new("."));
|
||||
fs::create_dir_all(parent)?;
|
||||
validate_regular_directory(parent)?;
|
||||
if let Ok(metadata) = fs::symlink_metadata(output)
|
||||
&& (!metadata.is_dir() || is_link_or_reparse(&metadata))
|
||||
{
|
||||
eyre::bail!(
|
||||
"fixture catalog output is not a regular non-link directory: {}",
|
||||
output.display()
|
||||
);
|
||||
}
|
||||
let stem = output
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.ok_or_else(|| eyre::eyre!("fixture catalog output needs a UTF-8 directory name"))?;
|
||||
for _ in 0..100 {
|
||||
let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
|
||||
let candidate = parent.join(format!(".{stem}.tmp-{}-{sequence}", std::process::id()));
|
||||
match fs::create_dir(&candidate) {
|
||||
Ok(()) => {
|
||||
return Ok(Self {
|
||||
path: Some(candidate),
|
||||
});
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
}
|
||||
eyre::bail!("could not allocate a fixture catalog staging directory")
|
||||
}
|
||||
|
||||
fn path(&self) -> &Path {
|
||||
match self.path.as_deref() {
|
||||
Some(path) => path,
|
||||
None => panic!("staging directory is present until installation"),
|
||||
}
|
||||
}
|
||||
|
||||
fn install(mut self, output: &Path) -> eyre::Result<()> {
|
||||
let staging = self.path().to_path_buf();
|
||||
if !output.exists() {
|
||||
fs::rename(&staging, output)?;
|
||||
self.path = None;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
validate_regular_directory(output)?;
|
||||
let parent = output
|
||||
.parent()
|
||||
.filter(|parent| !parent.as_os_str().is_empty())
|
||||
.unwrap_or_else(|| Path::new("."));
|
||||
let stem = output
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.ok_or_else(|| eyre::eyre!("fixture catalog output needs a UTF-8 directory name"))?;
|
||||
let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
|
||||
let backup = parent.join(format!(".{stem}.old-{}-{sequence}", std::process::id()));
|
||||
fs::rename(output, &backup)?;
|
||||
if let Err(error) = fs::rename(&staging, output) {
|
||||
let restore = fs::rename(&backup, output);
|
||||
return match restore {
|
||||
Ok(()) => Err(error.into()),
|
||||
Err(restore_error) => Err(eyre::eyre!(
|
||||
"failed to install fixture catalog: {error}; failed to restore previous output: {restore_error}"
|
||||
)),
|
||||
};
|
||||
}
|
||||
self.path = None;
|
||||
fs::remove_dir_all(&backup).wrap_err_with(|| {
|
||||
format!(
|
||||
"installed fixture catalog but failed to remove backup {}",
|
||||
backup.display()
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for StagingDirectory {
|
||||
fn drop(&mut self) {
|
||||
if let Some(path) = self.path.take() {
|
||||
let _ = fs::remove_dir_all(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn is_link_or_reparse(metadata: &fs::Metadata) -> bool {
|
||||
metadata.file_type().is_symlink()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
@@ -331,6 +183,8 @@ mod tests {
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||
|
||||
use super::*;
|
||||
|
||||
struct TestDirectory(PathBuf);
|
||||
@@ -488,17 +342,3 @@ mod tests {
|
||||
.expect("installed replacement profile should be coherent");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn is_link_or_reparse(metadata: &fs::Metadata) -> bool {
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
|
||||
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
|
||||
metadata.file_type().is_symlink()
|
||||
|| metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn is_link_or_reparse(metadata: &fs::Metadata) -> bool {
|
||||
metadata.file_type().is_symlink()
|
||||
}
|
||||
@@ -8,6 +8,8 @@ mod catalog;
|
||||
pub mod cli;
|
||||
pub mod fixture;
|
||||
mod package;
|
||||
pub mod production;
|
||||
mod staging;
|
||||
mod unrar;
|
||||
|
||||
#[cfg(unix)]
|
||||
|
||||
@@ -128,15 +128,23 @@ pub(super) fn preflight_package_version(package_root: &Path, expected: &str) ->
|
||||
}
|
||||
|
||||
pub(super) fn validate_package_version(package_root: &Path, expected: &str) -> eyre::Result<()> {
|
||||
if !package_version_matches(package_root, expected)? {
|
||||
eyre::bail!("package version mismatch: game.db expects {expected}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn package_version_matches(package_root: &Path, expected: &str) -> eyre::Result<bool> {
|
||||
Ok(package_version(package_root)? == expected)
|
||||
}
|
||||
|
||||
pub(super) fn package_version(package_root: &Path) -> eyre::Result<String> {
|
||||
let path = package_root.join("version.ini");
|
||||
let bytes = read_bounded_regular_file(&path, MAX_VERSION_INI_BYTES)?;
|
||||
let version = std::str::from_utf8(&bytes)
|
||||
.wrap_err("version.ini is not valid UTF-8")?
|
||||
.trim();
|
||||
if version != expected {
|
||||
eyre::bail!("package version mismatch: game.db expects {expected}");
|
||||
}
|
||||
Ok(())
|
||||
Ok(version.to_owned())
|
||||
}
|
||||
|
||||
fn scan_ordinary_package(root: &Path) -> eyre::Result<Vec<CatalogFileEntry>> {
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
//! Coherent production catalog generation from a complete or reduced package tree.
|
||||
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
fs,
|
||||
path::PathBuf,
|
||||
};
|
||||
|
||||
use eyre::WrapErr;
|
||||
|
||||
use super::{
|
||||
CatalogSelection,
|
||||
GenerateOptions,
|
||||
ManifestReport,
|
||||
catalog::{load_catalog_games, validate_regular_directory},
|
||||
generate_catalog_manifests,
|
||||
package::package_version,
|
||||
staging::{CatalogStagingDirectory, copy_filtered_catalog},
|
||||
};
|
||||
use crate::catalog_bundle::load_catalog_bundle;
|
||||
|
||||
/// Inputs for atomically generating a production `game.db` and manifest set.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ProductionCatalogOptions {
|
||||
pub source_catalog_db: PathBuf,
|
||||
pub packages_dir: PathBuf,
|
||||
pub output_dir: PathBuf,
|
||||
pub unrar: PathBuf,
|
||||
pub allow_missing_games: bool,
|
||||
pub allow_version_mismatch: bool,
|
||||
}
|
||||
|
||||
/// Result of generating a complete or reduced production catalog.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ProductionCatalogReport {
|
||||
pub manifests: Vec<ManifestReport>,
|
||||
pub missing_games: usize,
|
||||
pub version_mismatches: Vec<String>,
|
||||
}
|
||||
|
||||
struct PackageSelection {
|
||||
selected_ids: BTreeSet<String>,
|
||||
missing_games: usize,
|
||||
version_mismatches: Vec<String>,
|
||||
version_overrides: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// Builds one coherent production catalog in `output_dir`.
|
||||
///
|
||||
/// The checked-in source database is copied into staging and filtered to the
|
||||
/// package directories that exist only when `allow_missing_games` is enabled.
|
||||
/// The generated database, manifests, and compact index are fully validated
|
||||
/// before they atomically replace the previous output directory.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when the source catalog, package tree, generated manifests,
|
||||
/// filtered database, or output directory is invalid.
|
||||
pub async fn generate_production_catalog(
|
||||
options: &ProductionCatalogOptions,
|
||||
) -> eyre::Result<ProductionCatalogReport> {
|
||||
let source_catalog = load_catalog_games(&options.source_catalog_db).await?;
|
||||
let selected = select_package_games(
|
||||
&source_catalog,
|
||||
&options.packages_dir,
|
||||
options.allow_missing_games,
|
||||
options.allow_version_mismatch,
|
||||
)?;
|
||||
let staging = CatalogStagingDirectory::create(&options.output_dir)?;
|
||||
let staged_db = staging.path().join("game.db");
|
||||
let staged_manifests = staging.path().join("manifests");
|
||||
|
||||
copy_filtered_catalog(
|
||||
&options.source_catalog_db,
|
||||
&staged_db,
|
||||
selected.selected_ids.iter().map(String::as_str),
|
||||
&selected.version_overrides,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let reports = generate_catalog_manifests(&GenerateOptions {
|
||||
catalog_db: staged_db.clone(),
|
||||
packages_dir: options.packages_dir.clone(),
|
||||
manifests_dir: staged_manifests.clone(),
|
||||
unrar: options.unrar.clone(),
|
||||
selection: CatalogSelection::All,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let loaded = load_catalog_bundle(&staged_db, &staged_manifests)
|
||||
.await
|
||||
.wrap_err("generated production catalog failed application loader validation")?;
|
||||
loaded
|
||||
.bundle()
|
||||
.validate_all()
|
||||
.wrap_err("generated production catalog failed complete manifest validation")?;
|
||||
|
||||
staging.install(&options.output_dir)?;
|
||||
Ok(ProductionCatalogReport {
|
||||
manifests: reports,
|
||||
missing_games: selected.missing_games,
|
||||
version_mismatches: selected.version_mismatches,
|
||||
})
|
||||
}
|
||||
|
||||
fn select_package_games(
|
||||
catalog: &std::collections::BTreeMap<String, super::CatalogGame>,
|
||||
packages_dir: &std::path::Path,
|
||||
allow_missing_games: bool,
|
||||
allow_version_mismatch: bool,
|
||||
) -> eyre::Result<PackageSelection> {
|
||||
validate_regular_directory(packages_dir)?;
|
||||
if !allow_missing_games && !allow_version_mismatch {
|
||||
return Ok(PackageSelection {
|
||||
selected_ids: catalog.keys().cloned().collect(),
|
||||
missing_games: 0,
|
||||
version_mismatches: Vec::new(),
|
||||
version_overrides: BTreeMap::new(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut selected_ids = BTreeSet::new();
|
||||
let mut missing_games = 0;
|
||||
let mut version_mismatches = Vec::new();
|
||||
let mut version_overrides = BTreeMap::new();
|
||||
for (game_id, game) in catalog {
|
||||
let package_root = packages_dir.join(game_id);
|
||||
match fs::symlink_metadata(&package_root) {
|
||||
Ok(_) => {
|
||||
validate_regular_directory(&package_root).wrap_err_with(|| {
|
||||
format!("invalid package directory for catalog game {game_id}")
|
||||
})?;
|
||||
let observed_version = package_version(&package_root)
|
||||
.wrap_err_with(|| format!("failed to inspect package version for {game_id}"))?;
|
||||
let version_matches = observed_version == game.game_version;
|
||||
if version_matches || !allow_version_mismatch {
|
||||
selected_ids.insert(game_id.clone());
|
||||
} else {
|
||||
selected_ids.insert(game_id.clone());
|
||||
version_mismatches.push(game_id.clone());
|
||||
version_overrides.insert(game_id.clone(), observed_version);
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
if allow_missing_games {
|
||||
missing_games += 1;
|
||||
} else {
|
||||
selected_ids.insert(game_id.clone());
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(error).wrap_err_with(|| {
|
||||
format!(
|
||||
"failed to inspect package directory {}",
|
||||
package_root.display()
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if selected_ids.is_empty() {
|
||||
eyre::bail!("package tree contains no games matching the source catalog IDs and versions");
|
||||
}
|
||||
Ok(PackageSelection {
|
||||
selected_ids,
|
||||
missing_games,
|
||||
version_mismatches,
|
||||
version_overrides,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||
|
||||
use super::*;
|
||||
|
||||
static TEST_SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
struct TestDirectory(PathBuf);
|
||||
|
||||
impl TestDirectory {
|
||||
fn new() -> Self {
|
||||
let sequence = TEST_SEQUENCE.fetch_add(1, Ordering::Relaxed);
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("system clock should follow the epoch")
|
||||
.as_nanos();
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"lanspread-production-catalog-{}-{nanos}-{sequence}",
|
||||
std::process::id()
|
||||
));
|
||||
fs::create_dir(&path).expect("test directory should be created");
|
||||
Self(path)
|
||||
}
|
||||
|
||||
fn path(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDirectory {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_source_catalog(path: &Path) {
|
||||
let options = SqliteConnectOptions::new()
|
||||
.filename(path)
|
||||
.create_if_missing(true);
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect_with(options)
|
||||
.await
|
||||
.expect("source catalog should open");
|
||||
sqlx::query(
|
||||
"CREATE TABLE games (
|
||||
game_id TEXT, db_id INTEGER PRIMARY KEY, game_title TEXT,
|
||||
game_key TEXT, game_release TEXT, game_publisher TEXT,
|
||||
game_size NUMERIC, game_readme_de TEXT, game_readme_en TEXT,
|
||||
game_readme_fr TEXT, game_maxplayers INTEGER,
|
||||
game_master_req INTEGER, genre_id INTEGER, game_version TEXT
|
||||
)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("games table should be created");
|
||||
sqlx::query(
|
||||
"CREATE TABLE genre (
|
||||
genre_id INTEGER PRIMARY KEY, genre_de TEXT,
|
||||
genre_en TEXT, genre_fr TEXT
|
||||
)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("genre table should be created");
|
||||
sqlx::query("INSERT INTO genre VALUES (1, 'Games', 'Games', 'Games')")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("genre should be inserted");
|
||||
sqlx::query(
|
||||
"INSERT INTO games VALUES
|
||||
('keep', 1, 'Keep', '', '', '', 1, '', '', '', 1, 0, 1, '20250101'),
|
||||
('outdated', 2, 'Outdated', '', '', '', 1, '', '', '', 1, 0, 1, '20250102'),
|
||||
('absent', 3, 'Absent', '', '', '', 1, '', '', '', 1, 0, 1, '20250103')",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("games should be inserted");
|
||||
pool.close().await;
|
||||
}
|
||||
|
||||
fn create_package(root: &Path) {
|
||||
let package = root.join("packages/keep");
|
||||
fs::create_dir_all(&package).expect("package directory should be created");
|
||||
fs::write(package.join("version.ini"), "20250101")
|
||||
.expect("package version should be written");
|
||||
fs::write(package.join("payload.bin"), b"payload")
|
||||
.expect("package payload should be written");
|
||||
let outdated = root.join("packages/outdated");
|
||||
fs::create_dir(&outdated).expect("outdated package directory should be created");
|
||||
fs::write(outdated.join("version.ini"), "20240101")
|
||||
.expect("outdated package version should be written");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn combined_options_generate_a_complete_reduced_catalog_with_package_versions() {
|
||||
let root = TestDirectory::new();
|
||||
let source_catalog_db = root.path().join("source.db");
|
||||
create_source_catalog(&source_catalog_db).await;
|
||||
create_package(root.path());
|
||||
let output_dir = root.path().join("output");
|
||||
|
||||
let reports = generate_production_catalog(&ProductionCatalogOptions {
|
||||
source_catalog_db,
|
||||
packages_dir: root.path().join("packages"),
|
||||
output_dir: output_dir.clone(),
|
||||
unrar: root.path().join("unused-unrar"),
|
||||
allow_missing_games: true,
|
||||
allow_version_mismatch: true,
|
||||
})
|
||||
.await
|
||||
.expect("reduced production catalog should generate");
|
||||
|
||||
assert_eq!(reports.manifests.len(), 2);
|
||||
assert_eq!(reports.manifests[0].game_id, "keep");
|
||||
assert_eq!(reports.manifests[1].game_id, "outdated");
|
||||
assert_eq!(reports.manifests[1].game_version, "20240101");
|
||||
assert_eq!(reports.missing_games, 1);
|
||||
assert_eq!(reports.version_mismatches, ["outdated"]);
|
||||
let games = load_catalog_games(&output_dir.join("game.db"))
|
||||
.await
|
||||
.expect("generated database should load");
|
||||
assert_eq!(
|
||||
games.keys().map(String::as_str).collect::<Vec<_>>(),
|
||||
["keep", "outdated"]
|
||||
);
|
||||
assert_eq!(games["outdated"].game_version, "20240101");
|
||||
load_catalog_bundle(&output_dir.join("game.db"), &output_dir.join("manifests"))
|
||||
.await
|
||||
.expect("generated catalog should pass the application loader");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn strict_generation_still_rejects_a_missing_game() {
|
||||
let root = TestDirectory::new();
|
||||
let source_catalog_db = root.path().join("source.db");
|
||||
create_source_catalog(&source_catalog_db).await;
|
||||
create_package(root.path());
|
||||
let output_dir = root.path().join("output");
|
||||
|
||||
let error = generate_production_catalog(&ProductionCatalogOptions {
|
||||
source_catalog_db,
|
||||
packages_dir: root.path().join("packages"),
|
||||
output_dir: output_dir.clone(),
|
||||
unrar: root.path().join("unused-unrar"),
|
||||
allow_missing_games: false,
|
||||
allow_version_mismatch: false,
|
||||
})
|
||||
.await
|
||||
.expect_err("strict generation must require every source-catalog game");
|
||||
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("failed to preflight package version")
|
||||
);
|
||||
assert!(!output_dir.exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn allow_missing_games_alone_still_rejects_a_version_mismatch() {
|
||||
let root = TestDirectory::new();
|
||||
let source_catalog_db = root.path().join("source.db");
|
||||
create_source_catalog(&source_catalog_db).await;
|
||||
create_package(root.path());
|
||||
let output_dir = root.path().join("output");
|
||||
|
||||
let error = generate_production_catalog(&ProductionCatalogOptions {
|
||||
source_catalog_db,
|
||||
packages_dir: root.path().join("packages"),
|
||||
output_dir: output_dir.clone(),
|
||||
unrar: root.path().join("unused-unrar"),
|
||||
allow_missing_games: true,
|
||||
allow_version_mismatch: false,
|
||||
})
|
||||
.await
|
||||
.expect_err("missing-game opt-in must not allow version mismatches");
|
||||
|
||||
assert!(format!("{error:#}").contains("package version mismatch"));
|
||||
assert!(!output_dir.exists());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
use eyre::WrapErr;
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||
|
||||
use super::catalog::{load_catalog_games, validate_regular_directory};
|
||||
|
||||
static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
pub(super) async fn copy_filtered_catalog<'a>(
|
||||
source: &Path,
|
||||
destination: &Path,
|
||||
selected_ids: impl Iterator<Item = &'a str>,
|
||||
version_overrides: &BTreeMap<String, String>,
|
||||
) -> eyre::Result<()> {
|
||||
fs::copy(source, destination).wrap_err_with(|| {
|
||||
format!(
|
||||
"failed to copy source catalog {} to {}",
|
||||
source.display(),
|
||||
destination.display()
|
||||
)
|
||||
})?;
|
||||
let selected_ids = selected_ids.map(str::to_owned).collect::<BTreeSet<_>>();
|
||||
let all_games = load_catalog_games(destination).await?;
|
||||
let options = SqliteConnectOptions::new().filename(destination);
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect_with(options)
|
||||
.await
|
||||
.wrap_err_with(|| format!("failed to open catalog {}", destination.display()))?;
|
||||
let mutation_result = async {
|
||||
let mut transaction = pool.begin().await?;
|
||||
for game_id in all_games.keys() {
|
||||
if !selected_ids.contains(game_id) {
|
||||
sqlx::query("DELETE FROM games WHERE game_id = ?")
|
||||
.bind(game_id)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
for (game_id, game_version) in version_overrides {
|
||||
sqlx::query("UPDATE games SET game_version = ? WHERE game_id = ?")
|
||||
.bind(game_version)
|
||||
.bind(game_id)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
}
|
||||
sqlx::query(
|
||||
"DELETE FROM genre
|
||||
WHERE genre_id NOT IN (SELECT DISTINCT genre_id FROM games)",
|
||||
)
|
||||
.execute(&mut *transaction)
|
||||
.await?;
|
||||
transaction.commit().await?;
|
||||
sqlx::query("VACUUM").execute(&pool).await?;
|
||||
Ok::<(), sqlx::Error>(())
|
||||
}
|
||||
.await;
|
||||
pool.close().await;
|
||||
mutation_result.wrap_err("failed to filter catalog database")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) struct CatalogStagingDirectory {
|
||||
path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl CatalogStagingDirectory {
|
||||
pub(super) fn create(output: &Path) -> eyre::Result<Self> {
|
||||
let parent = output
|
||||
.parent()
|
||||
.filter(|parent| !parent.as_os_str().is_empty())
|
||||
.unwrap_or_else(|| Path::new("."));
|
||||
fs::create_dir_all(parent)?;
|
||||
validate_regular_directory(parent)?;
|
||||
if let Ok(metadata) = fs::symlink_metadata(output)
|
||||
&& (!metadata.is_dir() || is_link_or_reparse(&metadata))
|
||||
{
|
||||
eyre::bail!(
|
||||
"catalog output is not a regular non-link directory: {}",
|
||||
output.display()
|
||||
);
|
||||
}
|
||||
let stem = output
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.ok_or_else(|| eyre::eyre!("catalog output needs a UTF-8 directory name"))?;
|
||||
for _ in 0..100 {
|
||||
let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
|
||||
let candidate = parent.join(format!(".{stem}.tmp-{}-{sequence}", std::process::id()));
|
||||
match fs::create_dir(&candidate) {
|
||||
Ok(()) => {
|
||||
return Ok(Self {
|
||||
path: Some(candidate),
|
||||
});
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
}
|
||||
eyre::bail!("could not allocate a catalog staging directory")
|
||||
}
|
||||
|
||||
pub(super) fn path(&self) -> &Path {
|
||||
match self.path.as_deref() {
|
||||
Some(path) => path,
|
||||
None => panic!("staging directory is present until installation"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn install(mut self, output: &Path) -> eyre::Result<()> {
|
||||
let staging = self.path().to_path_buf();
|
||||
if !output.exists() {
|
||||
fs::rename(&staging, output)?;
|
||||
self.path = None;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
validate_regular_directory(output)?;
|
||||
let parent = output
|
||||
.parent()
|
||||
.filter(|parent| !parent.as_os_str().is_empty())
|
||||
.unwrap_or_else(|| Path::new("."));
|
||||
let stem = output
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.ok_or_else(|| eyre::eyre!("catalog output needs a UTF-8 directory name"))?;
|
||||
let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
|
||||
let backup = parent.join(format!(".{stem}.old-{}-{sequence}", std::process::id()));
|
||||
fs::rename(output, &backup)?;
|
||||
if let Err(error) = fs::rename(&staging, output) {
|
||||
let restore = fs::rename(&backup, output);
|
||||
return match restore {
|
||||
Ok(()) => Err(error.into()),
|
||||
Err(restore_error) => Err(eyre::eyre!(
|
||||
"failed to install catalog: {error}; failed to restore previous output: {restore_error}"
|
||||
)),
|
||||
};
|
||||
}
|
||||
self.path = None;
|
||||
fs::remove_dir_all(&backup).wrap_err_with(|| {
|
||||
format!(
|
||||
"installed catalog but failed to remove backup {}",
|
||||
backup.display()
|
||||
)
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CatalogStagingDirectory {
|
||||
fn drop(&mut self) {
|
||||
if let Some(path) = self.path.take() {
|
||||
let _ = fs::remove_dir_all(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn is_link_or_reparse(metadata: &fs::Metadata) -> bool {
|
||||
metadata.file_type().is_symlink()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn is_link_or_reparse(metadata: &fs::Metadata) -> bool {
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
|
||||
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
|
||||
metadata.file_type().is_symlink()
|
||||
|| metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn is_link_or_reparse(metadata: &fs::Metadata) -> bool {
|
||||
metadata.file_type().is_symlink()
|
||||
}
|
||||
Reference in new issue
Block a user