use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::Path;
use serde::Deserialize;
use serde_json::Value;
use crate::mapserver::infra::bbox::normalize_bbox;
use crate::mapserver::publish::manifest::{GeoJsonArtifactManifest, PublishedLayerManifest};
use super::{ResolveRequest, ResolveResponse, prune_feature_properties};
#[derive(Debug, Deserialize)]
struct ChunkItemRecord {
bbox: [f64; 4],
feature: Value,
}
pub fn resolve_from_artifact(
manifest: &PublishedLayerManifest,
request: &ResolveRequest,
artifact_manifest_path: &Path,
) -> Result<ResolveResponse, String> {
if manifest.bbox_required && request.bbox.is_none() {
return Err("bbox is required for this layer".to_string());
}
let artifact = read_artifact_manifest(artifact_manifest_path)?;
let bbox = request.bbox.and_then(normalize_bbox);
let hard_limit = match request.limit {
Some(l) => l,
None => manifest.max_features,
};
let parsed_filter = request
.filter
.as_deref()
.map(super::filter_dsl::parse_filter)
.transpose()
.ok()
.flatten();
let artifact_root = artifact_manifest_path
.parent()
.ok_or_else(|| "artifact manifest missing parent directory".to_string())?;
let mut out = Vec::new();
let mut truncated = false;
for chunk in artifact.chunks.iter().filter(|chunk| {
bbox.map(|needle| intersects_bbox(chunk.bbox, needle))
.unwrap_or(true)
}) {
let chunk_path = artifact_root.join(&chunk.rel_path);
let file = File::open(&chunk_path)
.map_err(|err| format!("failed reading chunk {}: {err}", chunk_path.display()))?;
let reader = BufReader::new(file);
for line in reader.lines() {
let line = line.map_err(|err| {
format!("failed reading chunk line {}: {err}", chunk_path.display())
})?;
if line.trim().is_empty() {
continue;
}
let item = serde_json::from_str::<ChunkItemRecord>(&line)
.map_err(|err| format!("failed parsing chunk line: {err}"))?;
if bbox
.map(|needle| intersects_bbox(item.bbox, needle))
.unwrap_or(true)
{
if let Some(ref filter) = parsed_filter {
if !super::filter_dsl::matches_geojson_feature(filter, &item.feature) {
continue;
}
}
if out.len() < hard_limit {
out.push(prune_feature_properties(
item.feature,
&manifest.allowed_properties,
));
} else {
truncated = true;
break;
}
}
}
if truncated {
break;
}
}
Ok(ResolveResponse {
layer: manifest.layer_id.clone(),
count: out.len(),
truncated,
features: out,
})
}
fn read_artifact_manifest(path: &Path) -> Result<GeoJsonArtifactManifest, String> {
let raw = std::fs::read_to_string(path)
.map_err(|err| format!("failed reading artifact manifest {}: {err}", path.display()))?;
serde_json::from_str::<GeoJsonArtifactManifest>(&raw)
.map_err(|err| format!("failed parsing artifact manifest {}: {err}", path.display()))
}
fn intersects_bbox(a: [f64; 4], b: [f64; 4]) -> bool {
!(a[2] < b[0] || a[0] > b[2] || a[3] < b[1] || a[1] > b[3])
}
#[cfg(test)]
mod tests {
use std::env;
use std::fs;
use std::path::PathBuf;
use serde_json::json;
use sysinfo::{ProcessesToUpdate, System};
use crate::mapserver::publish::build::build_geojson_artifact;
use crate::mapserver::publish::manifest::{
ArtifactChunkRecord, GeoJsonArtifactManifest, SourceKind,
};
use super::*;
#[test]
fn resolve_from_artifact_filters_bbox_and_limit() {
let tmp = tempfile::tempdir().expect("tmp");
let chunk_dir = tmp.path().join("chunks");
fs::create_dir_all(&chunk_dir).expect("chunk dir");
fs::write(
chunk_dir.join("g0_0.ndjson"),
format!(
"{}\n{}\n",
serde_json::to_string(&json!({
"bbox": [106.0, -6.5, 107.0, -6.0],
"feature": {
"type": "Feature",
"properties": { "name": "A", "code": "1" },
"geometry": {
"type": "Polygon",
"coordinates": [[[106.0, -6.5], [107.0, -6.5], [107.0, -6.0], [106.0, -6.0], [106.0, -6.5]]]
}
}
}))
.unwrap(),
serde_json::to_string(&json!({
"bbox": [106.5, -6.4, 107.5, -5.9],
"feature": {
"type": "Feature",
"properties": { "name": "B", "code": "2" },
"geometry": {
"type": "Polygon",
"coordinates": [[[106.5, -6.4], [107.5, -6.4], [107.5, -5.9], [106.5, -5.9], [106.5, -6.4]]]
}
}
}))
.unwrap(),
),
)
.expect("chunk write");
fs::write(
tmp.path().join("manifest.json"),
serde_json::to_string(&GeoJsonArtifactManifest {
version: 1,
layer_id: "adm1".to_string(),
source_ref: "sample".to_string(),
chunk_grid_degrees: 1.0,
feature_count: 2,
chunk_count: 1,
bbox: Some([106.0, -6.5, 107.5, -5.9]),
chunks: vec![ArtifactChunkRecord {
chunk_id: "g0_0".to_string(),
rel_path: "chunks/g0_0.ndjson".to_string(),
bbox: [106.0, -6.5, 107.5, -5.9],
item_count: 2,
}],
})
.unwrap(),
)
.expect("manifest write");
let manifest = PublishedLayerManifest {
layer_id: "adm1".to_string(),
path: "/adm1".to_string(),
source_kind: SourceKind::GeoJsonArtifact,
source_ref: tmp.path().join("manifest.json").display().to_string(),
mode: "features".to_string(),
min_zoom: None,
max_zoom: None,
bbox_required: true,
max_features: 1,
allowed_properties: vec!["name".to_string()],
style: None,
filter: None,
function_slug: None,
cache_ttl_secs: None,
};
let req = ResolveRequest {
layer_id: "adm1".to_string(),
bbox: Some([106.0, -7.0, 108.0, -5.0]),
zoom: Some(6),
limit: Some(1), filter: None,
};
let out = resolve_from_artifact(&manifest, &req, &tmp.path().join("manifest.json"))
.expect("resolve");
assert_eq!(out.count, 1);
assert!(out.truncated);
assert_eq!(out.features[0]["properties"], json!({"name": "A"}));
let closed = PublishedLayerManifest {
allowed_properties: Vec::new(),
max_features: 10,
..manifest.clone()
};
let closed_req = ResolveRequest {
limit: Some(10),
..req.clone()
};
let out = resolve_from_artifact(&closed, &closed_req, &tmp.path().join("manifest.json"))
.expect("resolve");
assert_eq!(out.count, 2);
for feature in &out.features {
assert_eq!(feature["properties"], json!({}));
assert!(feature["geometry"].is_object());
}
}
#[test]
fn mapserver_artifact_villages_memory_benchmark() {
let Ok(source) = env::var("MAPSERVER_BENCH_SOURCE") else {
eprintln!("MAPSERVER_BENCH_SOURCE not set; skipping benchmark");
return;
};
let source = PathBuf::from(source);
if !source.exists() {
eprintln!("MAPSERVER_BENCH_SOURCE missing; skipping benchmark");
return;
}
let tmp = tempfile::tempdir().expect("tmp");
let artifact_dir = tmp.path().join("artifact");
let mut system = System::new();
let pid = sysinfo::get_current_pid().expect("pid");
let before = process_memory_kib(&mut system, pid);
let build = build_geojson_artifact(
&source,
"adm4_villages",
&artifact_dir,
"mapserver/.artifacts/default-mapserver/adm4_villages",
)
.expect("build artifact");
let after_build = process_memory_kib(&mut system, pid);
let manifest = PublishedLayerManifest {
layer_id: "adm4_villages".to_string(),
path: "/layers/adm4-villages".to_string(),
source_kind: SourceKind::GeoJsonArtifact,
source_ref: build.manifest_abs_path.display().to_string(),
mode: "features".to_string(),
min_zoom: None,
max_zoom: None,
bbox_required: true,
max_features: 50,
allowed_properties: vec![],
style: None,
filter: None,
function_slug: None,
cache_ttl_secs: None,
};
let req = ResolveRequest {
layer_id: "adm4_villages".to_string(),
bbox: Some([107.50, -7.10, 107.80, -6.80]),
zoom: Some(11),
limit: Some(50),
filter: None,
};
let resolved =
resolve_from_artifact(&manifest, &req, &build.manifest_abs_path).expect("resolve");
let after_query = process_memory_kib(&mut system, pid);
eprintln!(
"MAPSERVER_BENCH before_kib={before} after_build_kib={after_build} after_query_kib={after_query} chunks={} features={} returned={} truncated={}",
build.chunk_count, build.feature_count, resolved.count, resolved.truncated
);
}
fn process_memory_kib(system: &mut System, pid: sysinfo::Pid) -> u64 {
system.refresh_processes(ProcessesToUpdate::Some(&[pid]), true);
system.process(pid).map(|p| p.memory()).unwrap_or(0) / 1024
}
}