629 lines
27 KiB
Rust
629 lines
27 KiB
Rust
//! The marketplace: the tile sets and source files of a version an operator may choose.
|
|
//!
|
|
//! The DHP decides what is optional: a manifest item with `"optional": true` (and a title and
|
|
//! description) is left out of Install and offered here; everything else is part of every
|
|
//! install. A manifest from before the marketplace flags nothing, and the same rule is applied
|
|
//! to it here instead (see `pre_catalog`). What the operator chose is
|
|
//! `LauncherState::extras`, a set of item keys that outlive versions (`tiles:<style>/<dataset>`,
|
|
//! `src:<path>`), so an update refreshes exactly the chosen items.
|
|
|
|
use std::collections::BTreeSet;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use odian_packs::manifest::{Manifest, SrcFile, TileSet};
|
|
use serde::Serialize;
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum Kind {
|
|
Tiles,
|
|
Src,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum Presence {
|
|
/// Every byte of this version's item is on the PC.
|
|
Installed,
|
|
/// Part of it is: a download that stopped, or an older version's copy. Installing resumes.
|
|
Partial,
|
|
Absent,
|
|
}
|
|
|
|
/// One item as the marketplace shows it.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
|
pub struct Item {
|
|
pub key: String,
|
|
pub kind: Kind,
|
|
pub title: String,
|
|
pub description: String,
|
|
/// What installing it downloads, in full.
|
|
pub bytes: u64,
|
|
/// How many files it is made of: what a removal counts down.
|
|
pub files: u64,
|
|
/// Part of every install: shown, never offered for removal.
|
|
pub required: bool,
|
|
pub presence: Presence,
|
|
/// Chosen: installed with the version and kept by updates (required items always are).
|
|
pub selected: bool,
|
|
/// False when the DHP does not carry this item's bytes: it is listed so an operator knows
|
|
/// it exists, but only an ODIAN drive can install it.
|
|
pub on_dhp: bool,
|
|
}
|
|
|
|
/// The map a version from BEFORE the marketplace opens on. Those versions flag nothing, so
|
|
/// the catalog's rule is applied to them here - and what they shipped as their one certain
|
|
/// basemap was `dark`. Current versions say it themselves (`catalog.json` now keeps
|
|
/// `dark_v2` required instead), and this is not read for them.
|
|
const ALWAYS_INSTALLED_STYLE: &str = "dark";
|
|
|
|
/// A manifest from before the marketplace (pack 0.61.0) flags nothing optional, so everything
|
|
/// in it would be part of every install: copying such a version from a drive would offer no
|
|
/// choice at all, and the operator would face the whole 228 GB or nothing. The catalog's own
|
|
/// rule is applied to it instead - the app, the database and the dark map come with every
|
|
/// install, the rest is offered - so an older version is chosen from like a current one. A
|
|
/// manifest that flags anything is the DHP's to decide and is taken exactly as it stands.
|
|
fn pre_catalog(manifest: &Manifest) -> bool {
|
|
!manifest.tiles.iter().flat_map(|tiles| &tiles.sets).any(|set| set.optional)
|
|
&& !manifest.src.iter().flat_map(|src| &src.files).any(|file| file.optional)
|
|
}
|
|
|
|
fn tiles_optional(set: &TileSet, pre_catalog: bool) -> bool {
|
|
set.optional || (pre_catalog && set.style != ALWAYS_INSTALLED_STYLE)
|
|
}
|
|
|
|
fn src_optional(file: &SrcFile, pre_catalog: bool) -> bool {
|
|
file.optional || pre_catalog
|
|
}
|
|
|
|
pub fn tiles_key(set: &TileSet) -> String {
|
|
format!("tiles:{}/{}", set.style, set.dataset)
|
|
}
|
|
|
|
pub fn src_key(file: &SrcFile) -> String {
|
|
format!("src:{}", file.path)
|
|
}
|
|
|
|
fn tile_set_dir(data_dir: &Path, set: &TileSet) -> PathBuf {
|
|
data_dir.join("tiles").join(&set.style).join(&set.dataset)
|
|
}
|
|
|
|
fn src_path(data_dir: &Path, file: &SrcFile) -> PathBuf {
|
|
file.path.split('/').fold(data_dir.join("src"), |path, part| path.join(part))
|
|
}
|
|
|
|
fn with_suffix(path: &Path, suffix: &str) -> PathBuf {
|
|
let mut name = path.as_os_str().to_owned();
|
|
name.push(suffix);
|
|
PathBuf::from(name)
|
|
}
|
|
|
|
/// A tile set is complete when the installer's `.blocks-done.json` names this index and lists
|
|
/// all its blocks; it is partial when its folder exists at all.
|
|
fn tile_set_presence(data_dir: &Path, set: &TileSet) -> Presence {
|
|
let dir = tile_set_dir(data_dir, set);
|
|
let done = std::fs::read(dir.join(".blocks-done.json"))
|
|
.ok()
|
|
.and_then(|raw| serde_json::from_slice::<serde_json::Value>(&raw).ok())
|
|
.filter(|done| done["index_sha256"].as_str() == Some(set.index_sha256.as_str()))
|
|
.and_then(|done| done["blocks"].as_array().map(Vec::len));
|
|
match done {
|
|
Some(blocks) if blocks as u64 >= set.blocks => Presence::Installed,
|
|
_ if dir.is_dir() => Presence::Partial,
|
|
_ => Presence::Absent,
|
|
}
|
|
}
|
|
|
|
/// One item weighed against what the version says it holds.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
|
pub struct Check {
|
|
pub key: String,
|
|
pub found: u64,
|
|
pub expected: u64,
|
|
/// True when at least everything the version names is on disk. MORE than that is normal
|
|
/// and not a fault: a PC bakes tiles of its own into the same folders, and the tile sync
|
|
/// brings in what other operators baked.
|
|
pub whole: bool,
|
|
}
|
|
|
|
/// What a tile set's folder really holds. Dot-files are the installer's own markers
|
|
/// (`.blocks-done.json`) and the tile server's negative ones (`.missing`), never tiles.
|
|
fn count_tiles(dir: &Path) -> u64 {
|
|
let Ok(entries) = std::fs::read_dir(dir) else { return 0 };
|
|
let mut found = 0;
|
|
for entry in entries.flatten() {
|
|
let name = entry.file_name();
|
|
if name.to_string_lossy().starts_with('.') {
|
|
continue;
|
|
}
|
|
match entry.file_type() {
|
|
Ok(kind) if kind.is_dir() => found += count_tiles(&entry.path()),
|
|
Ok(kind) if kind.is_file() => found += 1,
|
|
_ => {}
|
|
}
|
|
}
|
|
found
|
|
}
|
|
|
|
/// Count what is on disk for every tile set a marker calls installed.
|
|
///
|
|
/// This is the slow, true answer - a walk of hundreds of thousands of files - so it is
|
|
/// never on the path that opens the marketplace. The window asks for it after, and moves
|
|
/// the badge when it comes back.
|
|
pub fn check_installed(manifest: &Manifest, data_dir: &Path, extras: &BTreeSet<String>) -> Vec<Check> {
|
|
let mut out = Vec::new();
|
|
for set in manifest.tiles.iter().flat_map(|tiles| &tiles.sets) {
|
|
if tile_set_presence(data_dir, set) != Presence::Installed {
|
|
continue;
|
|
}
|
|
let found = count_tiles(&tile_set_dir(data_dir, set));
|
|
out.push(Check { key: tiles_key(set), found, expected: set.files, whole: found >= set.files });
|
|
}
|
|
let _ = extras;
|
|
out
|
|
}
|
|
|
|
/// A source file is complete when its `.ok` marker names its chunk list and the size is right
|
|
/// (the installer's own test); partial when the file exists.
|
|
fn src_presence(data_dir: &Path, file: &SrcFile) -> Presence {
|
|
let path = src_path(data_dir, file);
|
|
let marker_ok = std::fs::read_to_string(with_suffix(&path, ".ok")).is_ok_and(|text| text.trim() == file.chunks_sha256);
|
|
let size_ok = std::fs::metadata(&path).is_ok_and(|md| md.is_file() && md.len() == file.size);
|
|
match (marker_ok && size_ok, path.exists()) {
|
|
(true, _) => Presence::Installed,
|
|
(false, true) => Presence::Partial,
|
|
(false, false) => Presence::Absent,
|
|
}
|
|
}
|
|
|
|
/// Every tile set and source file of `manifest`, required ones first, as the marketplace shows
|
|
/// them. `extras` is the operator's choice.
|
|
pub fn items(manifest: &Manifest, data_dir: &Path, extras: &BTreeSet<String>) -> Vec<Item> {
|
|
items_from(manifest, data_dir, None, extras)
|
|
}
|
|
|
|
/// The same, for an install that copies another one: an item the source does not carry cannot
|
|
/// be offered, whatever the version's manifest lists. `presence` stays what THIS PC has.
|
|
pub fn items_from(
|
|
manifest: &Manifest,
|
|
data_dir: &Path,
|
|
source_data: Option<&Path>,
|
|
extras: &BTreeSet<String>,
|
|
) -> Vec<Item> {
|
|
let mut out = all_items(manifest, data_dir, extras);
|
|
if let Some(source) = source_data {
|
|
let carried: BTreeSet<String> = all_items(manifest, source, extras)
|
|
.into_iter()
|
|
.filter(|item| item.presence != Presence::Absent)
|
|
.map(|item| item.key)
|
|
.collect();
|
|
out.retain(|item| carried.contains(&item.key));
|
|
}
|
|
out
|
|
}
|
|
|
|
fn all_items(manifest: &Manifest, data_dir: &Path, extras: &BTreeSet<String>) -> Vec<Item> {
|
|
let old = pre_catalog(manifest);
|
|
let mut out = Vec::new();
|
|
for set in manifest.tiles.iter().flat_map(|tiles| &tiles.sets) {
|
|
let key = tiles_key(set);
|
|
let optional = tiles_optional(set, old);
|
|
let presence = tile_set_presence(data_dir, set);
|
|
out.push(Item {
|
|
selected: !optional || extras.contains(&key),
|
|
title: set.title.clone().unwrap_or_else(|| format!("{} map ({})", set.style, set.dataset)),
|
|
description: set.description.clone().unwrap_or_default(),
|
|
bytes: set.bytes,
|
|
files: set.files,
|
|
required: !optional,
|
|
presence,
|
|
on_dhp: set.on_dhp,
|
|
kind: Kind::Tiles,
|
|
key,
|
|
});
|
|
}
|
|
for file in manifest.src.iter().filter(|src| src.enabled).flat_map(|src| &src.files) {
|
|
let key = src_key(file);
|
|
let optional = src_optional(file, old);
|
|
let presence = src_presence(data_dir, file);
|
|
out.push(Item {
|
|
selected: !optional || extras.contains(&key),
|
|
title: file.title.clone().unwrap_or_else(|| file.path.clone()),
|
|
description: file.description.clone().unwrap_or_default(),
|
|
bytes: file.size,
|
|
// A source is one file (plus its small markers, not worth counting).
|
|
files: 1,
|
|
required: !optional,
|
|
presence,
|
|
on_dhp: file.on_dhp,
|
|
kind: Kind::Src,
|
|
key,
|
|
});
|
|
}
|
|
out.sort_by_key(|item| !item.required);
|
|
out
|
|
}
|
|
|
|
/// What installing `manifest` fetches: every required item and the chosen optional ones, as
|
|
/// the installer's `tile_sets` (`style/dataset`) and `src_paths` filters.
|
|
///
|
|
/// `from_dhp` drops what the store does not carry. The same choice is honoured when the
|
|
/// bytes come from a drive - that is where such an item comes from - but asked of the DHP
|
|
/// it would be a 404 per chunk and a failed install, for an item nobody could have got that
|
|
/// way. It is dropped here rather than refused at the checkbox because the choice outlives
|
|
/// the version it was made against: a PC that took the height map off a drive keeps it in
|
|
/// `extras`, and its next update from the DHP must simply leave it alone.
|
|
pub fn install_filter(
|
|
manifest: &Manifest,
|
|
extras: &BTreeSet<String>,
|
|
from_dhp: bool,
|
|
) -> (BTreeSet<String>, BTreeSet<String>) {
|
|
let old = pre_catalog(manifest);
|
|
let tile_sets = manifest
|
|
.tiles
|
|
.iter()
|
|
.flat_map(|tiles| &tiles.sets)
|
|
.filter(|set| set.on_dhp || !from_dhp)
|
|
.filter(|set| !tiles_optional(set, old) || extras.contains(&tiles_key(set)))
|
|
.map(|set| format!("{}/{}", set.style, set.dataset))
|
|
.collect();
|
|
let src_paths = manifest
|
|
.src
|
|
.iter()
|
|
.flat_map(|src| &src.files)
|
|
.filter(|file| file.on_dhp || !from_dhp)
|
|
.filter(|file| !src_optional(file, old) || extras.contains(&src_key(file)))
|
|
.map(|file| file.path.clone())
|
|
.collect();
|
|
(tile_sets, src_paths)
|
|
}
|
|
|
|
/// The optional items this PC already has, complete or not. A PC installed before the
|
|
/// marketplace has all of them: its first choice starts from what is on disk, so nothing is
|
|
/// removed or downloaded again behind the operator's back.
|
|
pub fn present_extras(manifest: &Manifest, data_dir: &Path) -> BTreeSet<String> {
|
|
items(manifest, data_dir, &BTreeSet::new())
|
|
.into_iter()
|
|
.filter(|item| !item.required && item.presence != Presence::Absent)
|
|
.map(|item| item.key)
|
|
.collect()
|
|
}
|
|
|
|
/// Keep only the keys of optional items `manifest` offers: a stale or required key means nothing.
|
|
pub fn optional_keys(manifest: &Manifest, keys: impl IntoIterator<Item = String>) -> BTreeSet<String> {
|
|
let offered: BTreeSet<String> =
|
|
items(manifest, Path::new(""), &BTreeSet::new()).into_iter().filter(|item| !item.required).map(|item| item.key).collect();
|
|
keys.into_iter().filter(|key| offered.contains(key)).collect()
|
|
}
|
|
|
|
/// The optional items on this PC the operator no longer wants.
|
|
pub fn to_remove(manifest: &Manifest, data_dir: &Path, extras: &BTreeSet<String>) -> Vec<Item> {
|
|
items(manifest, data_dir, extras)
|
|
.into_iter()
|
|
.filter(|item| !item.required && !item.selected && item.presence != Presence::Absent)
|
|
.collect()
|
|
}
|
|
|
|
/// What this PC has settled on: what the operator chose, plus whatever is actually here.
|
|
///
|
|
/// `extras` records answers about OPTIONAL items, so an item that is HERE and not in it was
|
|
/// never a choice the operator could have made - required by the version that installed it
|
|
/// (Dark, until Dark V2 took its place), off a drive, or not downloadable at all. Applying
|
|
/// `extras` alone as a selection therefore reads as "remove every one of those".
|
|
pub fn held_keys(manifest: &Manifest, data_dir: &Path, extras: &BTreeSet<String>) -> BTreeSet<String> {
|
|
items(manifest, data_dir, extras)
|
|
.into_iter()
|
|
.filter(|item| !item.required && (item.selected || item.presence != Presence::Absent))
|
|
.map(|item| item.key)
|
|
.collect()
|
|
}
|
|
|
|
/// The chosen items that are not all here, weighed against a count when one has been taken.
|
|
///
|
|
/// `to_add` answers from the installers' markers alone, which is what the marketplace opens
|
|
/// on. A set whose marker says "installed" while the disk holds half of it is exactly what
|
|
/// `check_installed` is for, so anything it found short is counted as unfinished here too.
|
|
pub fn unfinished(
|
|
manifest: &Manifest,
|
|
data_dir: &Path,
|
|
extras: &BTreeSet<String>,
|
|
checked: &[Check],
|
|
) -> Vec<Item> {
|
|
let short: BTreeSet<&str> = checked.iter().filter(|c| !c.whole).map(|c| c.key.as_str()).collect();
|
|
items(manifest, data_dir, extras)
|
|
.into_iter()
|
|
.filter(|item| !item.required && item.selected)
|
|
.filter(|item| item.presence != Presence::Installed || short.contains(item.key.as_str()))
|
|
.collect()
|
|
}
|
|
|
|
/// Whether the chosen optional items still need downloading.
|
|
pub fn to_add(manifest: &Manifest, data_dir: &Path, extras: &BTreeSet<String>) -> Vec<Item> {
|
|
items(manifest, data_dir, extras)
|
|
.into_iter()
|
|
// An item the store does not carry is never downloaded, so it is never announced as
|
|
// one: `install_filter` drops it, and a line saying it is being fetched would be a
|
|
// promise nothing keeps.
|
|
.filter(|item| item.on_dhp)
|
|
.filter(|item| !item.required && item.selected && item.presence != Presence::Installed)
|
|
.collect()
|
|
}
|
|
|
|
/// Remove an item's files from the PC: a tile set's folder, or a source file with its markers.
|
|
pub fn remove(manifest: &Manifest, data_dir: &Path, key: &str) -> Result<(), String> {
|
|
remove_counting(manifest, data_dir, key, &mut |_| {})
|
|
}
|
|
|
|
/// The same, calling `removed` with each file as it goes, so a caller can show progress.
|
|
pub fn remove_counting(
|
|
manifest: &Manifest,
|
|
data_dir: &Path,
|
|
key: &str,
|
|
removed: &mut dyn FnMut(u64),
|
|
) -> Result<(), String> {
|
|
for set in manifest.tiles.iter().flat_map(|tiles| &tiles.sets) {
|
|
if tiles_key(set) == key {
|
|
let dir = tile_set_dir(data_dir, set);
|
|
return if dir.exists() { crate::prune::remove_folder_counting(&dir, removed) } else { Ok(()) };
|
|
}
|
|
}
|
|
for file in manifest.src.iter().flat_map(|src| &src.files) {
|
|
if src_key(file) == key {
|
|
let path = src_path(data_dir, file);
|
|
for extra in ["", ".ok", ".chunks.json", ".part"] {
|
|
let target = with_suffix(&path, extra);
|
|
if target.exists() {
|
|
std::fs::remove_file(&target).map_err(|e| format!("cannot remove {}: {e}", target.display()))?;
|
|
if extra.is_empty() {
|
|
removed(1);
|
|
}
|
|
}
|
|
}
|
|
return Ok(());
|
|
}
|
|
}
|
|
Err(format!("{key} is not in this version"))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn manifest() -> Manifest {
|
|
serde_json::from_value(serde_json::json!({
|
|
"schema": 1, "product": "odian", "version": "0.61.0", "released_at": "2026-09-22T12:00:00Z",
|
|
"app": {"files": []},
|
|
"tiles": {"block_shift": 6, "sets": [
|
|
{"style": "dark", "dataset": "ds", "journal_seq": 1, "blocks": 2, "files": 3, "bytes": 100,
|
|
"index": "tiles/dark.ds.index.json", "index_sha256": "a".repeat(64), "title": "Dark map"},
|
|
{"style": "dark_v2", "dataset": "ds", "journal_seq": 1, "blocks": 2, "files": 3, "bytes": 200,
|
|
"index": "tiles/dark_v2.ds.index.json", "index_sha256": "b".repeat(64), "optional": true,
|
|
"title": "Dark V2 map", "description": "Buildings."}
|
|
]},
|
|
"src": {"enabled": true, "chunk_bytes": 10, "files": [
|
|
{"path": "osm/x.pmtiles", "size": 4, "chunks": ["c".repeat(64)], "chunks_sha256": "d".repeat(64), "optional": true}
|
|
]}
|
|
}))
|
|
.unwrap()
|
|
}
|
|
|
|
/// A version from before the catalog: nothing in it is flagged.
|
|
fn old_manifest() -> Manifest {
|
|
let mut manifest = manifest();
|
|
manifest.version = "0.60.0".to_string();
|
|
for set in manifest.tiles.iter_mut().flat_map(|tiles| &mut tiles.sets) {
|
|
set.optional = false;
|
|
set.description = None;
|
|
}
|
|
for file in manifest.src.iter_mut().flat_map(|src| &mut src.files) {
|
|
file.optional = false;
|
|
}
|
|
manifest
|
|
}
|
|
|
|
fn data_dir() -> PathBuf {
|
|
std::env::temp_dir().join(format!("odian-market-test-{}", uuid::Uuid::new_v4().simple()))
|
|
}
|
|
|
|
fn complete_tile_set(data: &Path, style: &str, index: char, blocks: usize) {
|
|
let dir = data.join("tiles").join(style).join("ds");
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
let list: Vec<String> = (0..blocks).map(|i| format!("7/{i}/0:x")).collect();
|
|
let done = serde_json::json!({"index_sha256": index.to_string().repeat(64), "blocks": list});
|
|
std::fs::write(dir.join(".blocks-done.json"), done.to_string()).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn required_items_come_first_and_the_choice_marks_the_rest() {
|
|
let data = data_dir();
|
|
let listed = items(&manifest(), &data, &["src:osm/x.pmtiles".to_string()].into_iter().collect());
|
|
let summary: Vec<(&str, bool, bool)> = listed.iter().map(|i| (i.key.as_str(), i.required, i.selected)).collect();
|
|
assert_eq!(summary, [("tiles:dark/ds", true, true), ("tiles:dark_v2/ds", false, false), ("src:osm/x.pmtiles", false, true)]);
|
|
assert_eq!((listed[1].title.as_str(), listed[1].description.as_str(), listed[1].bytes), ("Dark V2 map", "Buildings.", 200));
|
|
assert_eq!(listed[2].title, "osm/x.pmtiles", "no title: the path");
|
|
assert!(listed.iter().all(|i| i.presence == Presence::Absent));
|
|
}
|
|
|
|
#[test]
|
|
fn an_item_the_store_does_not_carry_is_listed_but_never_asked_of_it() {
|
|
// The world height map source is 705 GB and arrives on a drive. Leaving it out of the
|
|
// catalogue hid the one thing an operator needs to know - that it exists at all - and
|
|
// listing it without this flag would have the installer ask the DHP for 2629 chunks it
|
|
// has never had.
|
|
let mut carrying = manifest();
|
|
carrying.src.as_mut().unwrap().files.push(
|
|
serde_json::from_value(serde_json::json!({
|
|
"path": "planet_heightmap.pmtiles", "size": 8, "chunks": ["e".repeat(64)],
|
|
"chunks_sha256": "f".repeat(64), "optional": true, "on_dhp": false, "title": "Terrain source"
|
|
}))
|
|
.unwrap(),
|
|
);
|
|
let dir = std::env::temp_dir().join(format!("odian-market-{}", uuid::Uuid::new_v4().simple()));
|
|
let items = items(&carrying, &dir, &BTreeSet::new());
|
|
let terrain = items.iter().find(|item| item.key == "src:planet_heightmap.pmtiles").expect("it is listed");
|
|
assert!(!terrain.on_dhp, "and it says where it does NOT come from");
|
|
assert_eq!(terrain.title, "Terrain source");
|
|
assert!(items.iter().all(|item| item.key == "src:planet_heightmap.pmtiles" || item.on_dhp));
|
|
|
|
// Chosen - off a drive, say - it is still left alone when the DHP is the source.
|
|
let chosen: BTreeSet<String> = ["src:planet_heightmap.pmtiles".to_string()].into_iter().collect();
|
|
let (_, from_dhp) = install_filter(&carrying, &chosen, true);
|
|
assert!(!from_dhp.contains("planet_heightmap.pmtiles"), "the DHP is never asked for it");
|
|
let (_, from_drive) = install_filter(&carrying, &chosen, false);
|
|
assert!(from_drive.contains("planet_heightmap.pmtiles"), "a drive is exactly where it comes from");
|
|
}
|
|
|
|
#[test]
|
|
fn finishing_a_download_takes_nothing_away() {
|
|
// Dark V2 is installed and NOT in `extras`: it was required by the version that put it
|
|
// there, so no choice about it was ever recorded (which is exactly what happened when
|
|
// Dark V2 and Dark swapped places). Applying `extras` as a selection would read as
|
|
// "remove Dark V2", which is what Finish did before `held_keys`.
|
|
let data = data_dir();
|
|
complete_tile_set(&data, "dark_v2", 'b', 2);
|
|
let extras: BTreeSet<String> = ["src:osm/x.pmtiles".to_string()].into_iter().collect();
|
|
let keep = held_keys(&manifest(), &data, &extras);
|
|
let dropped = to_remove(&manifest(), &data, &keep);
|
|
std::fs::remove_dir_all(&data).unwrap();
|
|
assert!(keep.contains("tiles:dark_v2/ds"), "what is on disk stays chosen");
|
|
assert!(keep.contains("src:osm/x.pmtiles"), "and so does what was asked for");
|
|
assert!(dropped.is_empty(), "so finishing removes nothing: {dropped:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn untick_still_means_remove_even_for_what_only_a_drive_replaces() {
|
|
// `selected` means "the operator asked for this", and `to_remove` finds what to delete
|
|
// by looking for present-and-not-selected. An item that always read as selected - which
|
|
// is what "installed counts as chosen" did to it - could never be removed at all.
|
|
let root = std::env::temp_dir().join(format!("odian-market-{}", uuid::Uuid::new_v4().simple()));
|
|
let src = root.join("src");
|
|
std::fs::create_dir_all(&src).unwrap();
|
|
std::fs::write(src.join("planet_heightmap.pmtiles"), b"12345678").unwrap();
|
|
std::fs::write(src.join("planet_heightmap.pmtiles.ok"), "f".repeat(64)).unwrap();
|
|
|
|
let mut carrying = manifest();
|
|
carrying.src.as_mut().unwrap().files.push(
|
|
serde_json::from_value(serde_json::json!({
|
|
"path": "planet_heightmap.pmtiles", "size": 8, "chunks": ["e".repeat(64)],
|
|
"chunks_sha256": "f".repeat(64), "optional": true, "on_dhp": false, "title": "Terrain source"
|
|
}))
|
|
.unwrap(),
|
|
);
|
|
let dropped = to_remove(&carrying, &root, &BTreeSet::new());
|
|
std::fs::remove_dir_all(&root).unwrap();
|
|
assert_eq!(
|
|
dropped.iter().map(|item| item.key.as_str()).collect::<Vec<_>>(),
|
|
["src:planet_heightmap.pmtiles"],
|
|
"unticked and on disk: it goes"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_install_fetches_the_required_items_and_the_chosen_ones() {
|
|
let (sets, paths) = install_filter(&manifest(), &BTreeSet::new(), true);
|
|
assert_eq!((sets.into_iter().collect::<Vec<_>>(), paths.len()), (vec!["dark/ds".to_string()], 0));
|
|
let (sets, paths) = install_filter(&manifest(), &["tiles:dark_v2/ds".to_string()].into_iter().collect(), true);
|
|
assert_eq!(sets.len(), 2);
|
|
assert!(paths.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn presence_follows_the_installers_own_markers() {
|
|
let data = data_dir();
|
|
let m = manifest();
|
|
complete_tile_set(&data, "dark_v2", 'b', 2);
|
|
complete_tile_set(&data, "dark", 'z', 2); // an older index: it resumes, it is not done
|
|
let src = data.join("src").join("osm").join("x.pmtiles");
|
|
std::fs::create_dir_all(src.parent().unwrap()).unwrap();
|
|
std::fs::write(&src, b"PMTi").unwrap();
|
|
let presence = |key: &str| items(&m, &data, &BTreeSet::new()).into_iter().find(|i| i.key == key).unwrap().presence;
|
|
assert_eq!(presence("tiles:dark_v2/ds"), Presence::Installed);
|
|
assert_eq!(presence("tiles:dark/ds"), Presence::Partial);
|
|
assert_eq!(presence("src:osm/x.pmtiles"), Presence::Partial, "no .ok marker yet");
|
|
std::fs::write(with_suffix(&src, ".ok"), "d".repeat(64)).unwrap();
|
|
assert_eq!(presence("src:osm/x.pmtiles"), Presence::Installed);
|
|
// A PC from before the marketplace keeps what it has.
|
|
assert_eq!(present_extras(&m, &data).into_iter().collect::<Vec<_>>(), ["src:osm/x.pmtiles", "tiles:dark_v2/ds"]);
|
|
std::fs::remove_dir_all(&data).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn unchosen_items_are_removed_and_chosen_missing_ones_added() {
|
|
let data = data_dir();
|
|
let m = manifest();
|
|
complete_tile_set(&data, "dark_v2", 'b', 2);
|
|
let src = data.join("src").join("osm").join("x.pmtiles");
|
|
let chosen: BTreeSet<String> = ["src:osm/x.pmtiles".to_string()].into_iter().collect();
|
|
let keys = |items: Vec<Item>| items.into_iter().map(|i| i.key).collect::<Vec<_>>();
|
|
assert_eq!(keys(to_remove(&m, &data, &chosen)), ["tiles:dark_v2/ds"]);
|
|
assert_eq!(keys(to_add(&m, &data, &chosen)), ["src:osm/x.pmtiles"]);
|
|
|
|
remove(&m, &data, "tiles:dark_v2/ds").unwrap();
|
|
assert!(!data.join("tiles").join("dark_v2").join("ds").exists());
|
|
std::fs::create_dir_all(src.parent().unwrap()).unwrap();
|
|
std::fs::write(&src, b"PMTi").unwrap();
|
|
std::fs::write(with_suffix(&src, ".chunks.json"), b"{}").unwrap();
|
|
remove(&m, &data, "src:osm/x.pmtiles").unwrap();
|
|
assert!(!src.exists() && !with_suffix(&src, ".chunks.json").exists());
|
|
assert!(remove(&m, &data, "tiles:nope/ds").is_err());
|
|
std::fs::remove_dir_all(&data).unwrap();
|
|
}
|
|
|
|
/// The marketplace a real published manifest gives a real install, after checking its
|
|
/// signature with the release key. Reads only. `ODIAN_MARKET_MANIFEST=<manifest.json>
|
|
/// ODIAN_MARKET_DATA=C:\ODIAN\data cargo test -p odian-launcher real_manifest -- --ignored --nocapture`
|
|
#[test]
|
|
#[ignore = "reads a real manifest named by ODIAN_MARKET_MANIFEST"]
|
|
fn show_the_marketplace_of_a_real_manifest() {
|
|
let raw = std::fs::read(std::env::var("ODIAN_MARKET_MANIFEST").unwrap()).unwrap();
|
|
let verified = odian_packs::VerifiedManifest::from_bytes(raw, odian_packs::bundled_public_key()).unwrap();
|
|
let data = std::env::var("ODIAN_MARKET_DATA").map(PathBuf::from).unwrap_or_default();
|
|
let manifest = verified.manifest();
|
|
for item in items(manifest, &data, &present_extras(manifest, &data)) {
|
|
println!(
|
|
"{:>7.1} GB {:<9} required={:<5} selected={:<5} {}",
|
|
item.bytes as f64 / 1e9,
|
|
format!("{:?}", item.presence),
|
|
item.required,
|
|
item.selected,
|
|
item.title
|
|
);
|
|
}
|
|
let (sets, paths) = install_filter(manifest, &BTreeSet::new(), true);
|
|
println!("a new PC installs: {sets:?} {paths:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn only_offered_optional_keys_are_kept() {
|
|
let keys = ["tiles:dark/ds", "tiles:dark_v2/ds", "src:gone.pmtiles"].map(str::to_string);
|
|
assert_eq!(optional_keys(&manifest(), keys).into_iter().collect::<Vec<_>>(), ["tiles:dark_v2/ds"]);
|
|
}
|
|
|
|
#[test]
|
|
fn a_version_from_before_the_catalog_is_still_chosen_from() {
|
|
let items = items(&old_manifest(), Path::new(""), &BTreeSet::new());
|
|
let required: Vec<&str> = items.iter().filter(|item| item.required).map(|item| item.key.as_str()).collect();
|
|
// Only the map ODIAN opens on comes with every install; the rest is the operator's to pick.
|
|
assert_eq!(required, vec!["tiles:dark/ds"]);
|
|
let offered: Vec<&str> = items.iter().filter(|item| !item.required).map(|item| item.key.as_str()).collect();
|
|
assert_eq!(offered, vec!["tiles:dark_v2/ds", "src:osm/x.pmtiles"]);
|
|
// Unchosen, an install of it brings the dark map alone.
|
|
let (tiles, src) = install_filter(&old_manifest(), &BTreeSet::new(), true);
|
|
assert_eq!(tiles, BTreeSet::from(["dark/ds".to_string()]));
|
|
assert!(src.is_empty());
|
|
// Chosen, they come along.
|
|
let chosen = BTreeSet::from(["src:osm/x.pmtiles".to_string()]);
|
|
let (_, src) = install_filter(&old_manifest(), &chosen, true);
|
|
assert_eq!(src, BTreeSet::from(["osm/x.pmtiles".to_string()]));
|
|
}
|
|
|
|
#[test]
|
|
fn a_manifest_that_flags_anything_is_taken_as_it_stands() {
|
|
// The DHP decides for a catalog version: `dark_v2` is optional there, and nothing else moves.
|
|
let items = items(&manifest(), Path::new(""), &BTreeSet::new());
|
|
let required: Vec<&str> = items.iter().filter(|item| item.required).map(|item| item.key.as_str()).collect();
|
|
assert_eq!(required, vec!["tiles:dark/ds"]);
|
|
assert!(!pre_catalog(&manifest()) && pre_catalog(&old_manifest()));
|
|
}
|
|
}
|