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:
ddidderr committed 2026-09-12 19:41:35 +02:00
1 parent ab0c95b80f
commit 6b65a66465
21 files changed
+1777 -273

No files matched your search

@@ -14,39 +14,101 @@ use build_support::catalog_gate::{
mod build_support {
#[path = "catalog_gate.rs"]
pub(crate) mod catalog_gate;
#[path = "catalog_resources.rs"]
pub(crate) mod catalog_resources;
}
const FIXTURE_DEVELOPMENT_ENV: &str = "LANSPREAD_USE_FIXTURE_CATALOG";
const LOCAL_DEVELOPMENT_ENV: &str = "LANSPREAD_USE_LOCAL_CATALOG";
const LOCAL_GAMES_DIR_ENV: &str = "LANSPREAD_LOCAL_GAMES_DIR";
const CATALOG_CONTENT_INDEX_NAME: &str = "catalog-content-index-v1.jsonl";
const EMBEDDED_CATALOG_INDEX_NAME: &str = "embedded-catalog-content-index-v1.jsonl";
const FIXTURE_MANIFEST_ROOT: &str = "../../lanspread-peer-cli/catalogs/default/manifests";
const FIXTURE_CATALOG_ROOT: &str = "../../lanspread-peer-cli/catalogs/default";
const PRODUCTION_CATALOG_ROOT: &str = "production-catalog";
fn main() {
println!("cargo:rerun-if-env-changed=TAURI_CONFIG");
println!("cargo:rerun-if-env-changed={FIXTURE_DEVELOPMENT_ENV}");
println!("cargo:rerun-if-env-changed={LOCAL_DEVELOPMENT_ENV}");
println!("cargo:rerun-if-env-changed={LOCAL_GAMES_DIR_ENV}");
println!("cargo:rerun-if-changed=tauri.conf.json");
println!("cargo:rerun-if-changed=game.db");
println!("cargo:rerun-if-changed=manifests");
println!("cargo:rerun-if-changed={PRODUCTION_CATALOG_ROOT}");
let mode = catalog_build_mode().unwrap_or_else(|error| {
panic!("catalog packaging policy failed: {error}");
});
if mode == CatalogBuildMode::Production
&& let Err(error) = validate_production_catalog("game.db", "manifests")
assert!(
mode != CatalogBuildMode::LocalDevelopment || tauri_build::is_dev(),
"local catalog testing is only available with tauri dev"
);
if mode != CatalogBuildMode::FixtureDevelopment
&& let Err(error) = validate_production_catalog(
catalog_root(mode).join("game.db"),
catalog_root(mode).join("manifests"),
)
{
panic!("production catalog authority gate failed: {error}");
}
embed_catalog_content_index(mode).unwrap_or_else(|error| {
panic!("failed to embed catalog launch authority: {error}");
});
embed_startup_game_directory(mode).unwrap_or_else(|error| {
panic!("failed to embed local test game directory: {error}");
});
let out_dir = env::var_os("OUT_DIR").expect("OUT_DIR is not set");
let resources = build_support::catalog_resources::CatalogResources::prepare(
Path::new(&out_dir),
catalog_root(mode),
)
.unwrap_or_else(|error| panic!("failed to reset generated catalog resources: {error}"));
tauri_build::build();
// Tauri watches source resources only. Watching the generated database and
// manifest tree also repairs deleted copies and notices another catalog
// mode overwriting this profile's shared resource destination.
for resource in resources
.finish()
.unwrap_or_else(|error| panic!("failed to track generated catalog resources: {error}"))
{
println!("cargo:rerun-if-changed={}", resource.display());
}
}
fn catalog_root(mode: CatalogBuildMode) -> &'static Path {
Path::new(match mode {
CatalogBuildMode::FixtureDevelopment => FIXTURE_CATALOG_ROOT,
CatalogBuildMode::LocalDevelopment => "local-catalog",
CatalogBuildMode::Production => PRODUCTION_CATALOG_ROOT,
})
}
fn embed_startup_game_directory(mode: CatalogBuildMode) -> Result<(), Box<dyn std::error::Error>> {
let directory = if mode == CatalogBuildMode::LocalDevelopment {
let path = env::var_os(LOCAL_GAMES_DIR_ENV)
.ok_or_else(|| format!("{LOCAL_GAMES_DIR_ENV} is required for local test builds"))?;
let path = PathBuf::from(path).canonicalize()?;
if !path.is_dir() {
return Err("local test games path must be a directory".into());
}
Some(
path.into_os_string()
.into_string()
.map_err(|_| "local test games path must be UTF-8")?,
)
} else {
None
};
let out_dir = env::var_os("OUT_DIR").ok_or("OUT_DIR is not set")?;
// Rust string escaping keeps arbitrary path characters out of Cargo's
// line-based build-script protocol. Production compiles a literal None.
fs::write(
PathBuf::from(out_dir).join("startup-game-directory.rs"),
format!("{directory:?}"),
)?;
Ok(())
}
fn embed_catalog_content_index(mode: CatalogBuildMode) -> Result<(), Box<dyn std::error::Error>> {
let manifest_root = match mode {
CatalogBuildMode::FixtureDevelopment => Path::new(FIXTURE_MANIFEST_ROOT),
CatalogBuildMode::Production => Path::new("manifests"),
};
let manifest_root = catalog_root(mode).join("manifests");
let source = manifest_root.join(CATALOG_CONTENT_INDEX_NAME);
println!("cargo:rerun-if-changed={}", source.display());
let bytes = fs::read(&source)?;
@@ -74,12 +136,20 @@ fn catalog_build_mode() -> Result<CatalogBuildMode, Box<dyn std::error::Error>>
}
};
let cargo_profile = env::var("PROFILE").ok();
let local_development_opt_in = match env::var_os(LOCAL_DEVELOPMENT_ENV) {
None => false,
Some(value) if value == "1" => true,
Some(_) => {
return Err(format!("{LOCAL_DEVELOPMENT_ENV} must be exactly 1 when set").into());
}
};
let out_dir = env::var_os("OUT_DIR").map(std::path::PathBuf::from);
select_catalog_build_mode(CatalogGateInput {
base_config: &base_config,
config_override: config_override.as_deref(),
fixture_development_opt_in,
local_development_opt_in,
cargo_profile: cargo_profile.as_deref(),
out_dir: out_dir.as_deref(),
})
@@ -5,7 +5,17 @@ use std::{
use lanspread_compat::catalog_bundle::load_catalog_bundle;
const PRODUCTION_RESOURCES: [&str; 3] = ["assets/*", "game.db", "manifests/*"];
const PRODUCTION_RESOURCES: [(&str, &str); 3] = [
("assets/*", "assets/"),
("production-catalog/game.db", "game.db"),
("production-catalog/manifests/", "manifests/"),
];
const SOURCE_PRODUCTION_RESOURCES: [&str; 3] = ["assets/*", "game.db", "manifests/*"];
const LOCAL_RESOURCES: [(&str, &str); 3] = [
("assets/*", "assets/"),
("local-catalog/game.db", "game.db"),
("local-catalog/manifests/", "manifests/"),
];
const DEVELOPMENT_RESOURCES: [(&str, &str); 3] = [
(
"../../lanspread-peer-cli/catalogs/default/game.db",
@@ -21,6 +31,7 @@ const DEVELOPMENT_RESOURCES: [(&str, &str); 3] = [
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum CatalogBuildMode {
FixtureDevelopment,
LocalDevelopment,
Production,
}
@@ -29,6 +40,7 @@ pub(crate) struct CatalogGateInput<'a> {
pub(crate) base_config: &'a str,
pub(crate) config_override: Option<&'a str>,
pub(crate) fixture_development_opt_in: bool,
pub(crate) local_development_opt_in: bool,
pub(crate) cargo_profile: Option<&'a str>,
pub(crate) out_dir: Option<&'a Path>,
}
@@ -51,6 +63,21 @@ pub(crate) fn select_catalog_build_mode(
.any(|component| component.as_os_str() == "production")
});
if input.local_development_opt_in {
if input.fixture_development_opt_in || forced_production {
return Err(
"local catalog testing cannot be combined with fixture or production builds"
.to_owned(),
);
}
if resources != ResourceAuthority::LocalDevelopment {
return Err(
"local catalog opt-in requires the exact local development resource map".to_owned(),
);
}
return Ok(CatalogBuildMode::LocalDevelopment);
}
if input.fixture_development_opt_in && !forced_production {
if resources != ResourceAuthority::FixtureDevelopment {
return Err(
@@ -102,6 +129,7 @@ pub(crate) fn validate_production_catalog(
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ResourceAuthority {
FixtureDevelopment,
LocalDevelopment,
Production,
}
@@ -137,8 +165,10 @@ fn classify_resources(resources: &serde_json::Value) -> Result<ResourceAuthority
})
.collect::<Result<Vec<_>, _>>()?;
let unique = actual.iter().copied().collect::<BTreeSet<_>>();
let expected = PRODUCTION_RESOURCES.into_iter().collect::<BTreeSet<_>>();
if actual.len() == PRODUCTION_RESOURCES.len() && unique == expected {
let expected = SOURCE_PRODUCTION_RESOURCES
.into_iter()
.collect::<BTreeSet<_>>();
if actual.len() == SOURCE_PRODUCTION_RESOURCES.len() && unique == expected {
return Ok(ResourceAuthority::Production);
}
return Err(format!(
@@ -156,18 +186,26 @@ fn classify_resources(resources: &serde_json::Value) -> Result<ResourceAuthority
.ok_or_else(|| "development resource destinations must be strings".to_owned())
})
.collect::<Result<BTreeMap<_, _>, _>>()?;
let expected = DEVELOPMENT_RESOURCES
let production = PRODUCTION_RESOURCES.into_iter().collect::<BTreeMap<_, _>>();
if actual == production {
return Ok(ResourceAuthority::Production);
}
let local = LOCAL_RESOURCES.into_iter().collect::<BTreeMap<_, _>>();
if actual == local {
return Ok(ResourceAuthority::LocalDevelopment);
}
let development = DEVELOPMENT_RESOURCES
.into_iter()
.collect::<BTreeMap<_, _>>();
if actual == expected {
if actual == development {
return Ok(ResourceAuthority::FixtureDevelopment);
}
return Err(format!(
"development resource map must be exactly {expected:?}, got {actual:?}"
"resource map must be exactly production {production:?}, fixture {development:?}, or local {local:?}, got {actual:?}"
));
}
Err("bundle.resources must be an exact production list or development map".to_owned())
Err("bundle.resources must be an exact production list or catalog resource map".to_owned())
}
#[cfg(test)]
@@ -186,7 +224,11 @@ mod tests {
static TEST_SEQUENCE: AtomicU64 = AtomicU64::new(0);
const PRODUCTION: &str = r#"{
"bundle": {"resources": ["game.db", "manifests/*", "assets/*"]}
"bundle": {"resources": {
"production-catalog/game.db": "game.db",
"production-catalog/manifests/": "manifests/",
"assets/*": "assets/"
}}
}"#;
const DEVELOPMENT: &str = r#"{
"bundle": {"resources": {
@@ -204,6 +246,7 @@ mod tests {
base_config: PRODUCTION,
config_override,
fixture_development_opt_in,
local_development_opt_in: false,
cargo_profile: Some("release"),
out_dir: Some(Path::new("target/release/build/app/out")),
}
@@ -219,6 +262,13 @@ mod tests {
select_catalog_build_mode(input(Some(PRODUCTION), false)),
Ok(CatalogBuildMode::Production)
);
assert_eq!(
select_catalog_build_mode(input(
Some(r#"{"bundle":{"resources":["game.db","manifests/*","assets/*"]}}"#),
false
)),
Ok(CatalogBuildMode::Production)
);
assert_eq!(
select_catalog_build_mode(input(Some(r#"{"build": {}}"#), false)),
Ok(CatalogBuildMode::Production)
@@ -235,6 +285,48 @@ mod tests {
assert!(select_catalog_build_mode(input(None, true)).is_err());
}
#[test]
fn local_catalog_is_an_explicit_build_time_mode_excluded_from_production() {
let local = include_str!("../tauri.local.conf.json");
let mut local_input = input(Some(local), false);
assert!(select_catalog_build_mode(local_input).is_err());
local_input.local_development_opt_in = true;
assert_eq!(
select_catalog_build_mode(local_input),
Ok(CatalogBuildMode::LocalDevelopment)
);
for config in [PRODUCTION, DEVELOPMENT] {
assert!(
select_catalog_build_mode(CatalogGateInput {
config_override: Some(config),
..local_input
})
.is_err()
);
}
assert!(
select_catalog_build_mode(CatalogGateInput {
fixture_development_opt_in: true,
..local_input
})
.is_err()
);
assert!(
select_catalog_build_mode(CatalogGateInput {
cargo_profile: Some("production"),
..local_input
})
.is_err()
);
assert!(
select_catalog_build_mode(CatalogGateInput {
out_dir: Some(Path::new("target/production/build/app/out")),
..local_input
})
.is_err()
);
}
#[test]
fn production_profile_cannot_be_downgraded_to_fixture_authority() {
for mut input in [
@@ -260,7 +352,7 @@ mod tests {
fn incomplete_duplicated_or_unknown_resource_shapes_fail_closed() {
for config in [
r#"{"bundle":{"resources":["game.db","manifests/*"]}}"#,
r#"{"bundle":{"resources":["game.db","manifests/*","assets/*","assets/*"]}}"#,
r#"{"bundle":{"resources":{"production-catalog/game.db":"game.db"}}}"#,
r#"{"bundle":{"resources":{"fixture.db":"game.db"}}}"#,
r#"{"bundle":{"resources":true}}"#,
"not JSON",
@@ -0,0 +1,118 @@
use std::{
fs,
io,
path::{Path, PathBuf},
time::SystemTime,
};
pub(crate) struct CatalogResources {
files: Vec<(PathBuf, Option<SystemTime>)>,
}
impl CatalogResources {
pub(crate) fn prepare(out_dir: &Path, source: &Path) -> io::Result<Self> {
let profile_dir = profile_directory(out_dir)?;
let mut relative_files = vec![PathBuf::from("game.db")];
for entry in fs::read_dir(source.join("manifests"))? {
relative_files.push(Path::new("manifests").join(entry?.file_name()));
}
relative_files.sort();
let files = relative_files
.into_iter()
.map(|relative| {
let destination = profile_dir.join(&relative);
let previous_mtime = unchanged_mtime(&source.join(relative), &destination)?;
Ok((destination, previous_mtime))
})
.collect::<io::Result<Vec<_>>>()?;
reset_catalog_resources(out_dir)?;
Ok(Self { files })
}
pub(crate) fn finish(self) -> io::Result<Vec<PathBuf>> {
self.files
.into_iter()
.map(|(path, previous_mtime)| {
// Cargo compares watched files to the build-script start time.
// Tauri unconditionally copies, so retain the PREVIOUS OUTPUT
// mtime for identical bytes to let the next build become fresh.
// Changed bytes keep their new mtime: restoring source mtimes
// would hide another feature variant overwriting shared outputs.
if let Some(modified) = previous_mtime {
fs::OpenOptions::new()
.write(true)
.open(&path)?
.set_modified(modified)?;
}
Ok(path)
})
.collect()
}
}
fn unchanged_mtime(source: &Path, destination: &Path) -> io::Result<Option<SystemTime>> {
let metadata = match fs::symlink_metadata(destination) {
Ok(metadata) if metadata.is_file() && !metadata.is_symlink() => metadata,
Ok(_) => return Ok(None),
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error),
};
if metadata.len() == fs::metadata(source)?.len() && fs::read(source)? == fs::read(destination)?
{
Ok(Some(metadata.modified()?))
} else {
Ok(None)
}
}
/// Removes only Tauri's generated catalog copies before it copies the selected
/// authority. Tauri merges resource trees, which otherwise retains manifests
/// from a previous catalog mode or a larger previous production catalog.
pub(crate) fn reset_catalog_resources(out_dir: &Path) -> io::Result<[PathBuf; 2]> {
let profile_dir = profile_directory(out_dir)?;
let resources = [profile_dir.join("game.db"), profile_dir.join("manifests")];
for path in &resources {
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.is_symlink() => {
return Err(io::Error::other(format!(
"generated catalog resource is a symlink: {}",
path.display()
)));
}
Ok(metadata) if metadata.is_dir() && path == &resources[1] => {
fs::remove_dir_all(path)?;
}
Ok(metadata) if metadata.is_file() => fs::remove_file(path)?,
Ok(_) => {
return Err(io::Error::other(format!(
"unexpected generated catalog resource type: {}",
path.display()
)));
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error),
}
}
Ok(resources)
}
fn profile_directory(out_dir: &Path) -> io::Result<&Path> {
// Match tauri-build's OUT_DIR -> profile directory calculation, including
// custom target directories, cross-compilation targets and Cargo profiles.
// Require Cargo's layout before deriving any removal target.
if !out_dir.is_absolute()
|| out_dir.file_name().is_none_or(|name| name != "out")
|| out_dir
.ancestors()
.nth(2)
.and_then(Path::file_name)
.is_none_or(|name| name != "build")
{
return Err(io::Error::other("unexpected Cargo OUT_DIR layout"));
}
out_dir
.ancestors()
.nth(3)
.ok_or_else(|| io::Error::other("Cargo OUT_DIR has no profile directory"))
}
@@ -3010,6 +3010,11 @@ async fn set_local_network_sharing(
}
}
#[tauri::command]
fn get_startup_game_directory() -> Option<&'static str> {
include!(concat!(env!("OUT_DIR"), "/startup-game-directory.rs"))
}
#[tauri::command]
async fn update_game_directory(
app_handle: tauri::AppHandle,
@@ -4747,6 +4752,7 @@ pub fn run() {
run_game,
start_server,
game_directory_exists,
get_startup_game_directory,
update_game_directory,
update_game,
uninstall_game,
@@ -0,0 +1,22 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "softlan-launcher-local-test",
"identifier": "berlin.softlan.launcher.local-test",
"app": {
"windows": [
{
"title": "softlan-launcher — LOCAL TEST",
"width": 1526,
"height": 1120
}
]
},
"bundle": {
"active": false,
"resources": {
"local-catalog/game.db": "game.db",
"local-catalog/manifests/": "manifests/",
"assets/*": "assets/"
}
}
}
@@ -1,10 +1,10 @@
{
"$schema": "https://schema.tauri.app/config/2",
"bundle": {
"resources": [
"game.db",
"manifests/*",
"assets/*"
]
"resources": {
"production-catalog/game.db": "game.db",
"production-catalog/manifests/": "manifests/",
"assets/*": "assets/"
}
}
}
@@ -0,0 +1,372 @@
#[path = "../build_support/catalog_resources.rs"]
mod catalog_resources;
use std::{
fs,
path::{Path, PathBuf},
sync::atomic::{AtomicU64, Ordering},
time::{Duration, SystemTime},
};
use catalog_resources::{CatalogResources, reset_catalog_resources};
static NEXT_DIRECTORY: AtomicU64 = AtomicU64::new(0);
struct TestDirectory(PathBuf);
impl TestDirectory {
fn new() -> Self {
let path = std::env::temp_dir().join(format!(
"lanspread-catalog-resources-{}-{}",
std::process::id(),
NEXT_DIRECTORY.fetch_add(1, Ordering::Relaxed),
));
fs::create_dir(&path).expect("test directory should be unique");
Self(path)
}
}
impl Drop for TestDirectory {
fn drop(&mut self) {
fs::remove_dir_all(&self.0).expect("test directory should be removed");
}
}
#[test]
fn resets_catalog_copies_for_native_and_cross_target_profiles() {
let root = TestDirectory::new();
for profile in ["target/release", "custom/x86_64-pc-windows-msvc/production"] {
let profile = root.0.join(profile);
let out_dir = profile.join("build/launcher-test/out");
fs::create_dir_all(&out_dir).expect("fixture filesystem operation should succeed");
fs::create_dir(profile.join("manifests"))
.expect("fixture filesystem operation should succeed");
fs::write(profile.join("manifests/fixture.json"), b"old fixture")
.expect("fixture filesystem operation should succeed");
fs::write(
profile.join("manifests/removed-game.json"),
b"old production",
)
.expect("fixture filesystem operation should succeed");
fs::write(profile.join("game.db"), b"old database")
.expect("fixture filesystem operation should succeed");
fs::write(profile.join("launcher"), b"binary")
.expect("fixture filesystem operation should succeed");
fs::write(out_dir.join("embedded-index"), b"new authority")
.expect("fixture filesystem operation should succeed");
let resources =
reset_catalog_resources(&out_dir).expect("fixture filesystem operation should succeed");
assert_eq!(
resources,
[profile.join("game.db"), profile.join("manifests")]
);
assert!(resources.iter().all(|path| !path.exists()));
assert_eq!(
fs::read(profile.join("launcher"))
.expect("fixture filesystem operation should succeed"),
b"binary"
);
assert_eq!(
fs::read(out_dir.join("embedded-index"))
.expect("fixture filesystem operation should succeed"),
b"new authority"
);
// Missing copies are a normal input after manual cleanup or a clean build.
assert_eq!(
reset_catalog_resources(&out_dir).expect("fixture filesystem operation should succeed"),
resources
);
}
}
#[test]
fn rejects_non_cargo_layout_before_removing_anything() {
let root = TestDirectory::new();
fs::write(root.0.join("game.db"), b"keep")
.expect("fixture filesystem operation should succeed");
assert!(reset_catalog_resources(&root.0.join("unexpected/launcher/out")).is_err());
assert!(reset_catalog_resources(Path::new("out")).is_err());
assert!(reset_catalog_resources(Path::new("target/release/build/launcher/out")).is_err());
assert_eq!(
fs::read(root.0.join("game.db")).expect("fixture filesystem operation should succeed"),
b"keep"
);
}
#[cfg(unix)]
#[test]
fn rejects_linked_manifest_destination_without_traversing_it() {
let root = TestDirectory::new();
let profile = root.0.join("target/debug");
let out_dir = profile.join("build/launcher-test/out");
fs::create_dir_all(&out_dir).expect("fixture filesystem operation should succeed");
let source = root.0.join("source-manifests");
fs::create_dir(&source).expect("fixture filesystem operation should succeed");
fs::write(source.join("keep.json"), b"authority")
.expect("fixture filesystem operation should succeed");
std::os::unix::fs::symlink(&source, profile.join("manifests"))
.expect("fixture filesystem operation should succeed");
assert!(reset_catalog_resources(&out_dir).is_err());
assert_eq!(
fs::read(source.join("keep.json")).expect("fixture filesystem operation should succeed"),
b"authority"
);
}
fn timestamp(offset: u64) -> SystemTime {
SystemTime::UNIX_EPOCH + Duration::from_secs(1_600_000_000 + offset)
}
fn set_modified(path: &Path, modified: SystemTime) {
fs::OpenOptions::new()
.write(true)
.open(path)
.expect("fixture filesystem operation should succeed")
.set_modified(modified)
.expect("fixture filesystem operation should succeed");
}
fn write_catalog(root: &Path, database: &str, manifests: &[(&str, &str)]) {
fs::create_dir_all(root.join("manifests"))
.expect("fixture filesystem operation should succeed");
fs::write(root.join("game.db"), database).expect("fixture filesystem operation should succeed");
for (name, body) in manifests {
fs::write(root.join("manifests").join(name), body)
.expect("fixture filesystem operation should succeed");
}
for path in catalog_files(root) {
// Sources can be older than every build using them. Their timestamps
// must never replace the timestamps of changed destination bytes.
set_modified(&path, timestamp(0));
}
}
fn catalog_files(root: &Path) -> Vec<PathBuf> {
let mut paths = vec![root.join("game.db")];
paths.extend(
fs::read_dir(root.join("manifests"))
.expect("fixture filesystem operation should succeed")
.map(|entry| {
entry
.expect("manifest directory entry should be readable")
.path()
}),
);
paths.sort();
paths
}
fn simulate_tauri_copy(source: &Path, destination: &Path, modified: SystemTime) {
fs::create_dir_all(destination.join("manifests"))
.expect("fixture filesystem operation should succeed");
for path in catalog_files(source) {
let destination = destination.join(
path.strip_prefix(source)
.expect("catalog path should be below its source root"),
);
fs::copy(path, &destination).expect("fixture filesystem operation should succeed");
set_modified(&destination, modified);
}
}
fn assert_catalog_matches(source: &Path, destination: &Path, watched: &[PathBuf]) {
let expected = catalog_files(source)
.iter()
.map(|path| {
destination.join(
path.strip_prefix(source)
.expect("catalog path should be below its source root"),
)
})
.collect::<Vec<_>>();
assert_eq!(watched, expected);
assert_eq!(catalog_files(destination), expected);
for path in watched {
let source = source.join(
path.strip_prefix(destination)
.expect("copied path should be below its destination root"),
);
assert_eq!(
fs::read(path).expect("fixture filesystem operation should succeed"),
fs::read(source).expect("fixture filesystem operation should succeed")
);
}
}
#[test]
fn unchanged_copies_keep_previous_output_timestamps() {
let root = TestDirectory::new();
let source = root.0.join("source");
let profile = root.0.join("target/release");
let out_dir = profile.join("build/launcher-test/out");
fs::create_dir_all(&out_dir).expect("fixture filesystem operation should succeed");
write_catalog(&source, "database", &[("game.json", "manifest")]);
simulate_tauri_copy(&source, &profile, timestamp(10));
let resources =
CatalogResources::prepare(&out_dir, &source).expect("catalog resources should prepare");
simulate_tauri_copy(&source, &profile, timestamp(20));
let watched = resources.finish().expect("copied resources should finish");
assert_catalog_matches(&source, &profile, &watched);
for path in watched {
assert_eq!(
fs::metadata(path)
.expect("fixture filesystem operation should succeed")
.modified()
.expect("fixture filesystem operation should succeed"),
timestamp(10)
);
}
}
#[test]
fn changed_copies_keep_new_timestamps_then_stabilize_on_the_next_build() {
let root = TestDirectory::new();
let source = root.0.join("source");
let profile = root.0.join("target/release");
let out_dir = profile.join("build/launcher-test/out");
fs::create_dir_all(&out_dir).expect("fixture filesystem operation should succeed");
write_catalog(
&source,
"old database",
&[("game.json", "old manifest"), ("shared.json", "unchanged")],
);
simulate_tauri_copy(&source, &profile, timestamp(10));
// Equal lengths ensure that changed bytes, rather than just file sizes,
// distinguish a different catalog occupying the shared profile directory.
write_catalog(
&source,
"new database",
&[("game.json", "new manifest"), ("shared.json", "unchanged")],
);
for copied_at in [timestamp(20), timestamp(30)] {
let resources =
CatalogResources::prepare(&out_dir, &source).expect("catalog resources should prepare");
simulate_tauri_copy(&source, &profile, copied_at);
let watched = resources.finish().expect("copied resources should finish");
assert_catalog_matches(&source, &profile, &watched);
for path in watched {
let expected = if path.ends_with("shared.json") {
timestamp(10)
} else {
timestamp(20)
};
assert_eq!(
fs::metadata(path)
.expect("fixture filesystem operation should succeed")
.modified()
.expect("fixture filesystem operation should succeed"),
expected
);
}
}
}
#[test]
fn missing_database_manifest_or_manifest_directory_is_repaired_and_watched() {
let root = TestDirectory::new();
let source = root.0.join("source");
let profile = root.0.join("target/release");
let out_dir = profile.join("build/launcher-test/out");
fs::create_dir_all(&out_dir).expect("fixture filesystem operation should succeed");
write_catalog(&source, "database", &[("game.json", "manifest")]);
for missing in ["game.db", "manifests/game.json", "manifests"] {
simulate_tauri_copy(&source, &profile, timestamp(10));
let missing_path = profile.join(missing);
if missing_path.is_dir() {
fs::remove_dir_all(&missing_path).expect("fixture filesystem operation should succeed");
} else {
fs::remove_file(&missing_path).expect("fixture filesystem operation should succeed");
}
let resources =
CatalogResources::prepare(&out_dir, &source).expect("catalog resources should prepare");
simulate_tauri_copy(&source, &profile, timestamp(20));
let watched = resources.finish().expect("copied resources should finish");
assert_catalog_matches(&source, &profile, &watched);
for path in watched {
let expected = if path.starts_with(&missing_path) {
timestamp(20)
} else {
timestamp(10)
};
assert_eq!(
fs::metadata(path)
.expect("fixture filesystem operation should succeed")
.modified()
.expect("fixture filesystem operation should succeed"),
expected
);
}
}
}
#[test]
fn mode_switch_and_catalog_shrink_remove_previous_manifests() {
let root = TestDirectory::new();
let fixture = root.0.join("fixture");
let production = root.0.join("production");
let profile = root.0.join("target/release");
let out_dir = profile.join("build/launcher-test/out");
fs::create_dir_all(&out_dir).expect("fixture filesystem operation should succeed");
write_catalog(
&fixture,
"fixture database",
&[
("fixture.json", "fixture only"),
("shared.json", "fixture game"),
],
);
write_catalog(
&production,
"production database",
&[
("production.json", "production only"),
("shared.json", "real game"),
],
);
simulate_tauri_copy(&fixture, &profile, timestamp(10));
let resources = CatalogResources::prepare(&out_dir, &production)
.expect("production resources should prepare");
simulate_tauri_copy(&production, &profile, timestamp(20));
assert_catalog_matches(
&production,
&profile,
&resources.finish().expect("copied resources should finish"),
);
assert!(!profile.join("manifests/fixture.json").exists());
fs::remove_file(production.join("manifests/production.json"))
.expect("fixture filesystem operation should succeed");
write_catalog(
&production,
"smaller database",
&[("shared.json", "real game")],
);
let resources = CatalogResources::prepare(&out_dir, &production)
.expect("production resources should prepare");
simulate_tauri_copy(&production, &profile, timestamp(30));
assert_catalog_matches(
&production,
&profile,
&resources.finish().expect("copied resources should finish"),
);
assert!(!profile.join("manifests/production.json").exists());
let resources =
CatalogResources::prepare(&out_dir, &fixture).expect("fixture resources should prepare");
simulate_tauri_copy(&fixture, &profile, timestamp(40));
assert_catalog_matches(
&fixture,
&profile,
&resources.finish().expect("copied resources should finish"),
);
}