use std::path::{Path, PathBuf};
use clap::builder::Styles;
use clap::builder::styling::AnsiColor;
use clap::{Parser, Subcommand, ValueEnum};
use enum_display::EnumDisplay;
use log::error;
use mbtiles::{
AggHashType, CopyDuplicateMode, CopyType, IntegrityCheckType, MbtResult, MbtTypeCli, Mbtiles,
MbtilesCopier, PatchTypeCli, UpdateZoomType, apply_patch,
};
use serde::{Deserialize, Serialize};
use tilejson::Bounds;
const HELP_STYLES: Styles = Styles::styled()
.header(AnsiColor::Blue.on_default().bold())
.usage(AnsiColor::Blue.on_default().bold())
.literal(AnsiColor::White.on_default())
.placeholder(AnsiColor::Green.on_default());
#[derive(Parser, PartialEq, Debug)]
#[command(
version,
name = "mbtiles",
about = "A utility to work with .mbtiles file content",
after_help = "Use RUST_LOG environment variable to control logging level, e.g. RUST_LOG=debug or RUST_LOG=mbtiles=debug. See https://docs.rs/env_logger/latest/env_logger/index.html#enabling-logging for more information.",
styles = HELP_STYLES
)]
pub struct Args {
#[arg(short, long, hide = true)]
verbose: bool,
#[command(subcommand)]
command: Commands,
}
#[derive(
Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, EnumDisplay, ValueEnum,
)]
#[enum_display(case = "Kebab")]
pub enum OutputFormat {
#[default]
Text,
Json,
#[value(alias("pretty-json"))]
JsonPretty,
}
#[derive(Subcommand, PartialEq, Debug)]
enum Commands {
#[command(name = "summary", alias = "info")]
Summary {
file: PathBuf,
#[arg(short, long, value_enum, default_value_t=OutputFormat::default())]
format: OutputFormat,
},
#[command(name = "meta-all")]
MetaAll {
file: PathBuf,
},
#[command(name = "meta-get", alias = "get-meta")]
MetaGetValue {
file: PathBuf,
key: String,
},
#[command(name = "meta-set", alias = "set-meta")]
MetaSetValue {
file: PathBuf,
key: String,
value: Option<String>,
},
#[command(name = "diff")]
Diff(DiffArgs),
#[command(name = "copy", alias = "cp")]
Copy(CopyArgs),
#[command(name = "apply-patch", alias = "apply-diff")]
ApplyPatch {
base_file: PathBuf,
patch_file: PathBuf,
#[arg(short, long)]
force: bool,
},
#[command(name = "meta-update", alias = "update-meta")]
UpdateMetadata {
file: PathBuf,
#[arg(long, value_enum, default_value_t=UpdateZoomType::default())]
update_zoom: UpdateZoomType,
},
#[command(name = "validate", alias = "check", alias = "verify")]
Validate {
file: PathBuf,
#[arg(long, value_enum, default_value_t=IntegrityCheckType::default())]
integrity_check: IntegrityCheckType,
#[arg(long, hide = true)]
update_agg_tiles_hash: bool,
#[arg(long, value_enum)]
agg_hash: Option<AggHashType>,
},
}
#[derive(Clone, Default, PartialEq, Debug, clap::Args)]
pub struct CopyArgs {
src_file: PathBuf,
dst_file: PathBuf,
#[command(flatten)]
pub options: SharedCopyOpts,
#[arg(long, conflicts_with("apply_patch"))]
diff_with_file: Option<PathBuf>,
#[arg(long, conflicts_with("diff_with_file"))]
apply_patch: Option<PathBuf>,
#[arg(long, requires("diff_with_file"), default_value_t=PatchTypeCli::default())]
patch_type: PatchTypeCli,
}
#[derive(Clone, Default, PartialEq, Debug, clap::Args)]
pub struct DiffArgs {
file1: PathBuf,
file2: PathBuf,
diff: PathBuf,
#[arg(long, default_value_t=PatchTypeCli::default())]
patch_type: PatchTypeCli,
#[command(flatten)]
pub options: SharedCopyOpts,
}
#[expect(
clippy::doc_markdown,
reason = "for command line arguments, formatting `TileJSON` is awkward"
)]
#[derive(Clone, Default, PartialEq, Debug, clap::Args)]
#[expect(clippy::struct_excessive_bools, reason = "CLI interface")]
pub struct SharedCopyOpts {
#[arg(long, value_name = "TYPE", default_value_t=CopyType::default())]
copy: CopyType,
#[arg(long)]
strict: bool,
#[arg(long, alias = "dst-type", alias = "dst_type", value_name = "SCHEMA")]
mbtiles_type: Option<MbtTypeCli>,
#[arg(long, value_enum)]
on_duplicate: Option<CopyDuplicateMode>,
#[arg(long, conflicts_with("zoom_levels"))]
min_zoom: Option<u8>,
#[arg(long, conflicts_with("zoom_levels"))]
max_zoom: Option<u8>,
#[arg(long, value_delimiter = ',')]
zoom_levels: Vec<u8>,
#[arg(long)]
bbox: Vec<Bounds>,
#[arg(long)]
skip_agg_tiles_hash: bool,
#[arg(short, long)]
force: bool,
#[arg(long)]
validate: bool,
}
impl SharedCopyOpts {
#[must_use]
pub fn into_copier(
self,
src_file: PathBuf,
dst_file: PathBuf,
diff_with_file: Option<PathBuf>,
apply_patch: Option<PathBuf>,
patch_type: PatchTypeCli,
) -> MbtilesCopier {
MbtilesCopier {
src_file,
dst_file,
diff_with_file: diff_with_file.map(|p| (p, patch_type.into())),
apply_patch,
copy: self.copy,
dst_type_cli: self.mbtiles_type,
on_duplicate: self.on_duplicate,
min_zoom: self.min_zoom,
max_zoom: self.max_zoom,
zoom_levels: self.zoom_levels,
bbox: self.bbox,
skip_agg_tiles_hash: self.skip_agg_tiles_hash,
force: self.force,
validate: self.validate,
strict: self.strict,
dst_type: None, }
}
}
#[tokio::main]
async fn main() {
let env = env_logger::Env::default().default_filter_or("mbtiles=info");
env_logger::Builder::from_env(env)
.format_indent(None)
.format_module_path(false)
.format_target(false)
.format_timestamp(None)
.init();
if let Err(err) = main_int().await {
error!("{err}");
std::process::exit(1);
}
}
async fn main_int() -> anyhow::Result<()> {
let args = Args::parse();
match args.command {
Commands::MetaAll { file } => {
meta_print_all(file.as_path()).await?;
}
Commands::MetaGetValue { file, key } => {
meta_get_value(file.as_path(), &key).await?;
}
Commands::MetaSetValue { file, key, value } => {
meta_set_value(file.as_path(), &key, value.as_deref()).await?;
}
Commands::Copy(args) => {
let copier = args.options.into_copier(
args.src_file,
args.dst_file,
args.diff_with_file,
args.apply_patch,
args.patch_type,
);
copier.run().await?;
}
Commands::Diff(args) => {
let copier = args.options.into_copier(
args.file1,
args.diff,
Some(args.file2),
None,
args.patch_type,
);
copier.run().await?;
}
Commands::ApplyPatch {
base_file,
patch_file,
force,
} => {
apply_patch(base_file, patch_file, force).await?;
}
Commands::UpdateMetadata { file, update_zoom } => {
let mbt = Mbtiles::new(file.as_path())?;
let mut conn = mbt.open().await?;
mbt.update_metadata(&mut conn, update_zoom).await?;
}
Commands::Validate {
file,
integrity_check,
update_agg_tiles_hash,
agg_hash,
} => {
if update_agg_tiles_hash && agg_hash.is_some() {
anyhow::bail!("Cannot use both --agg-hash and --update-agg-tiles-hash");
}
let agg_hash = agg_hash.unwrap_or_else(|| {
if update_agg_tiles_hash {
AggHashType::Update
} else {
AggHashType::default()
}
});
let mbt = Mbtiles::new(file.as_path())?;
mbt.open_and_validate(integrity_check, agg_hash).await?;
}
Commands::Summary { file, format } => {
let mbt = Mbtiles::new(file.as_path())?;
let mut conn = mbt.open_readonly().await?;
let summary = mbt.summary(&mut conn).await?;
match format {
OutputFormat::Text => println!("{summary}"),
OutputFormat::Json => println!("{}", serde_json::to_string(&summary)?),
OutputFormat::JsonPretty => println!("{}", serde_json::to_string_pretty(&summary)?),
}
}
}
Ok(())
}
async fn meta_print_all(file: &Path) -> anyhow::Result<()> {
let mbt = Mbtiles::new(file)?;
let mut conn = mbt.open_readonly().await?;
let metadata = mbt.get_metadata(&mut conn).await?;
print!("{}", serde_yaml::to_string(&metadata)?);
let tile_info = mbt.detect_format(&metadata.tilejson, &mut conn).await?;
if let Some(tile_info) = tile_info {
let encoding = tile_info.encoding.compression().unwrap_or("''");
println!("tile_info:");
println!(" format: {}", tile_info.format);
println!(" encoding: {encoding}");
} else {
println!("tile_info: null");
}
Ok(())
}
async fn meta_get_value(file: &Path, key: &str) -> MbtResult<()> {
let mbt = Mbtiles::new(file)?;
let mut conn = mbt.open_readonly().await?;
if let Some(s) = mbt.get_metadata_value(&mut conn, key).await? {
println!("{s}");
}
Ok(())
}
async fn meta_set_value(file: &Path, key: &str, value: Option<&str>) -> MbtResult<()> {
let mbt = Mbtiles::new(file)?;
let mut conn = mbt.open().await?;
if let Some(value) = value {
mbt.set_metadata_value(&mut conn, key, value).await
} else {
mbt.delete_metadata_value(&mut conn, key).await
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use clap::Parser as _;
use clap::error::ErrorKind;
use mbtiles::CopyDuplicateMode;
use super::*;
use crate::Commands::{ApplyPatch, Copy, Diff, MetaGetValue, MetaSetValue, Validate};
use crate::{Args, IntegrityCheckType};
#[test]
fn test_copy_no_arguments() {
assert_eq!(
Args::try_parse_from(["mbtiles", "copy"])
.unwrap_err()
.kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_copy_minimal_arguments() {
assert_eq!(
Args::parse_from(["mbtiles", "copy", "src_file", "dst_file"]),
Args {
verbose: false,
command: Copy(CopyArgs {
src_file: PathBuf::from("src_file"),
dst_file: PathBuf::from("dst_file"),
..Default::default()
})
}
);
}
#[test]
fn test_copy_min_max_zoom_arguments() {
let args = Args::parse_from([
"mbtiles",
"copy",
"src_file",
"dst_file",
"--max-zoom",
"100",
"--min-zoom",
"1",
]);
assert_eq!(
args,
Args {
verbose: false,
command: Copy(CopyArgs {
src_file: PathBuf::from("src_file"),
dst_file: PathBuf::from("dst_file"),
options: SharedCopyOpts {
min_zoom: Some(1),
max_zoom: Some(100),
..Default::default()
},
..Default::default()
})
}
);
}
#[test]
fn test_copy_strict_argument() {
assert_eq!(
Args::parse_from(["mbtiles", "copy", "src_file", "dst_file", "--strict"]),
Args {
verbose: false,
command: Copy(CopyArgs {
src_file: PathBuf::from("src_file"),
dst_file: PathBuf::from("dst_file"),
options: SharedCopyOpts {
strict: true,
..Default::default()
},
..Default::default()
})
}
);
}
#[test]
fn test_copy_min_max_zoom_no_arguments() {
assert_eq!(
Args::try_parse_from([
"mbtiles",
"copy",
"src_file",
"dst_file",
"--max-zoom",
"--min-zoom",
])
.unwrap_err()
.kind(),
ErrorKind::InvalidValue
);
}
#[test]
fn test_copy_min_max_zoom_with_zoom_levels_arguments() {
assert_eq!(
Args::try_parse_from([
"mbtiles",
"copy",
"src_file",
"dst_file",
"--max-zoom",
"100",
"--min-zoom",
"1",
"--zoom-levels",
"3,7,1"
])
.unwrap_err()
.kind(),
ErrorKind::ArgumentConflict
);
}
#[test]
fn test_copy_zoom_levels_arguments() {
assert_eq!(
Args::parse_from([
"mbtiles",
"copy",
"src_file",
"dst_file",
"--zoom-levels",
"3,7,1"
]),
Args {
verbose: false,
command: Copy(CopyArgs {
src_file: PathBuf::from("src_file"),
dst_file: PathBuf::from("dst_file"),
options: SharedCopyOpts {
zoom_levels: vec![3, 7, 1],
..Default::default()
},
..Default::default()
})
}
);
}
#[test]
fn test_copy_diff_with_file_arguments() {
assert_eq!(
Args::parse_from([
"mbtiles",
"copy",
"src_file",
"dst_file",
"--diff-with-file",
"no_file",
]),
Args {
verbose: false,
command: Copy(CopyArgs {
src_file: PathBuf::from("src_file"),
dst_file: PathBuf::from("dst_file"),
diff_with_file: Some(PathBuf::from("no_file")),
..Default::default()
})
}
);
}
#[test]
fn test_copy_diff_with_override_copy_duplicate_mode() {
assert_eq!(
Args::parse_from([
"mbtiles",
"copy",
"src_file",
"dst_file",
"--on-duplicate",
"override"
]),
Args {
verbose: false,
command: Copy(CopyArgs {
src_file: PathBuf::from("src_file"),
dst_file: PathBuf::from("dst_file"),
options: SharedCopyOpts {
on_duplicate: Some(CopyDuplicateMode::Override),
..Default::default()
},
..Default::default()
})
}
);
}
#[test]
fn test_copy_limit() {
assert_eq!(
Args::parse_from([
"mbtiles", "copy", "src_file", "dst_file", "--copy", "metadata"
]),
Args {
verbose: false,
command: Copy(CopyArgs {
src_file: PathBuf::from("src_file"),
dst_file: PathBuf::from("dst_file"),
options: SharedCopyOpts {
copy: CopyType::Metadata,
..Default::default()
},
..Default::default()
})
}
);
}
#[test]
fn test_diff() {
assert_eq!(
Args::parse_from([
"mbtiles",
"diff",
"file1.mbtiles",
"file2.mbtiles",
"../delta.mbtiles",
"--on-duplicate",
"override"
]),
Args {
verbose: false,
command: Diff(DiffArgs {
file1: PathBuf::from("file1.mbtiles"),
file2: PathBuf::from("file2.mbtiles"),
diff: PathBuf::from("../delta.mbtiles"),
patch_type: PatchTypeCli::Whole,
options: SharedCopyOpts {
on_duplicate: Some(CopyDuplicateMode::Override),
..Default::default()
},
})
}
);
}
#[test]
fn test_meta_get_no_arguments() {
assert_eq!(
Args::try_parse_from(["mbtiles", "meta-get"])
.unwrap_err()
.kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_meta_get_with_arguments() {
assert_eq!(
Args::parse_from(["mbtiles", "meta-get", "src_file", "key"]),
Args {
verbose: false,
command: MetaGetValue {
file: PathBuf::from("src_file"),
key: "key".to_string(),
}
}
);
}
#[test]
fn test_meta_set_no_arguments() {
assert_eq!(
Args::try_parse_from(["mbtiles", "meta-get"])
.unwrap_err()
.kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_meta_set_no_value_argument() {
assert_eq!(
Args::parse_from(["mbtiles", "meta-set", "src_file", "key"]),
Args {
verbose: false,
command: MetaSetValue {
file: PathBuf::from("src_file"),
key: "key".to_string(),
value: None
}
}
);
}
#[test]
fn test_meta_get_with_all_arguments() {
assert_eq!(
Args::parse_from(["mbtiles", "meta-set", "src_file", "key", "value"]),
Args {
verbose: false,
command: MetaSetValue {
file: PathBuf::from("src_file"),
key: "key".to_string(),
value: Some("value".to_string())
}
}
);
}
#[test]
fn test_apply_diff_with_arguments() {
assert_eq!(
Args::parse_from(["mbtiles", "apply-diff", "src_file", "diff_file"]),
Args {
verbose: false,
command: ApplyPatch {
base_file: PathBuf::from("src_file"),
patch_file: PathBuf::from("diff_file"),
force: false,
}
}
);
}
#[test]
fn test_validate() {
assert_eq!(
Args::parse_from(["mbtiles", "validate", "src_file", "--agg-hash", "off"]),
Args {
verbose: false,
command: Validate {
file: PathBuf::from("src_file"),
integrity_check: IntegrityCheckType::Quick,
update_agg_tiles_hash: false,
agg_hash: Some(AggHashType::Off),
}
}
);
}
}