use std::path::PathBuf;
use clap::Parser;
use clap::builder::Styles;
use clap::builder::styling::AnsiColor;
use super::connections::Arguments;
use super::srv::SrvArgs;
use crate::MartinError::ConfigAndConnectionsError;
use crate::MartinResult;
#[cfg(feature = "postgres")]
use crate::config::args::PostgresArgs;
use crate::config::file::Config;
#[cfg(any(
feature = "unstable-cog",
feature = "mbtiles",
feature = "pmtiles",
feature = "sprites",
feature = "styles",
))]
use crate::config::file::FileConfigEnum;
#[cfg(feature = "fonts")]
use crate::config::file::fonts::FontConfig;
#[cfg(feature = "postgres")]
use crate::config::primitives::env::Env;
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, Debug, PartialEq, Default)]
#[command(
about,
version,
after_help = "Use RUST_LOG environment variable to control logging level, e.g. RUST_LOG=debug or RUST_LOG=martin=debug.\nUse RUST_LOG_FORMAT environment variable to control output format: json, full, compact (default), bare or pretty.\nSee https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html for more information.",
styles = HELP_STYLES
)]
pub struct Args {
#[command(flatten)]
pub meta: MetaArgs,
#[command(flatten)]
pub extras: ExtraArgs,
#[command(flatten)]
pub srv: SrvArgs,
#[cfg(feature = "postgres")]
#[command(flatten)]
pub pg: Option<PostgresArgs>,
}
#[derive(Parser, Debug, Clone, PartialEq, Default)]
#[command(about, version)]
pub struct MetaArgs {
#[arg(short, long)]
pub config: Option<PathBuf>,
#[arg(long)]
pub save_config: Option<PathBuf>,
pub connection: Vec<String>,
}
#[derive(Parser, Debug, Clone, PartialEq, Default)]
#[command()]
pub struct ExtraArgs {
#[arg(short = 's', long)]
#[cfg(feature = "sprites")]
pub sprite: Vec<PathBuf>,
#[arg(short, long)]
#[cfg(feature = "fonts")]
pub font: Vec<PathBuf>,
#[arg(short = 'S', long)]
#[cfg(feature = "styles")]
pub style: Vec<PathBuf>,
}
impl Args {
pub fn merge_into_config<'a>(
self,
config: &mut Config,
#[cfg(feature = "postgres")] env: &impl Env<'a>,
) -> MartinResult<()> {
if self.meta.config.is_some() && !self.meta.connection.is_empty() {
return Err(ConfigAndConnectionsError(self.meta.connection));
}
if self.srv.cache_size.is_some() {
config.cache_size_mb = self.srv.cache_size;
}
self.srv.merge_into_config(&mut config.srv);
#[cfg_attr(
not(feature = "_tiles"),
expect(
unused_mut,
reason = "postgres may modify the cli strings to process input params"
)
)]
let mut cli_strings = Arguments::new(self.meta.connection);
#[cfg(feature = "postgres")]
{
let pg_args = self.pg.unwrap_or_default();
if config.postgres.is_none() {
config.postgres = pg_args.into_config(&mut cli_strings, env);
} else {
pg_args.override_config(&mut config.postgres, env);
}
}
#[cfg(feature = "pmtiles")]
if !cli_strings.is_empty() {
config.pmtiles = parse_file_args(&mut cli_strings, &["pmtiles"], true);
}
#[cfg(feature = "mbtiles")]
if !cli_strings.is_empty() {
config.mbtiles = parse_file_args(&mut cli_strings, &["mbtiles"], false);
}
#[cfg(feature = "unstable-cog")]
if !cli_strings.is_empty() {
config.cog = parse_file_args(&mut cli_strings, &["tif", "tiff"], false);
}
#[cfg(feature = "styles")]
if !self.extras.style.is_empty() {
config.styles = FileConfigEnum::new(self.extras.style);
}
#[cfg(feature = "sprites")]
if !self.extras.sprite.is_empty() {
config.sprites = FileConfigEnum::new(self.extras.sprite);
}
#[cfg(feature = "fonts")]
if !self.extras.font.is_empty() {
config.fonts = FontConfig::new(self.extras.font);
}
cli_strings.check()
}
}
#[cfg(any(feature = "unstable-cog", feature = "mbtiles", feature = "pmtiles"))]
fn is_url(s: &str, extension: &[&str]) -> bool {
let Ok(url) = url::Url::parse(s) else {
return false;
};
match url.scheme() {
"s3" | "s3a" | "gs" | "az" | "adl" | "azure" | "abfs" | "abfss" => {
url.path().split('/').any(|segment| {
segment
.rsplit('.')
.next()
.is_some_and(|ext| extension.contains(&ext))
})
}
"http" | "https" | "file" => url
.path()
.rsplit('.')
.next()
.is_some_and(|ext| extension.contains(&ext)),
_ => false,
}
}
#[cfg(any(feature = "unstable-cog", feature = "mbtiles", feature = "pmtiles"))]
fn is_file_scheme_uri(s: &str, extensions: &[&str]) -> bool {
let Ok(url) = url::Url::parse(s) else {
return false;
};
if url.scheme() != "file" {
return false;
}
url.path()
.rsplit('.')
.next()
.is_some_and(|ext| extensions.contains(&ext))
}
#[cfg(any(feature = "unstable-cog", feature = "mbtiles", feature = "pmtiles"))]
pub fn parse_file_args<T: crate::config::file::ConfigurationLivecycleHooks>(
cli_strings: &mut Arguments,
extensions: &[&str],
allow_url: bool,
) -> FileConfigEnum<T> {
use super::State::{Ignore, Share, Take};
let paths = cli_strings.process(|s| {
let path = PathBuf::from(s);
if allow_url && is_url(s, extensions) {
Take(path)
} else if is_file_scheme_uri(s, extensions) {
Take(path)
} else if path.is_dir() {
Share(path)
} else if path.is_file()
&& extensions.iter().any(|&expected_ext| {
path.extension()
.is_some_and(|actual_ext| actual_ext == expected_ext)
})
{
Take(path)
} else {
Ignore
}
});
FileConfigEnum::new(paths)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::MartinError::UnrecognizableConnections;
use crate::config::args::PreferredEncoding;
#[cfg(feature = "postgres")]
use crate::config::primitives::env::FauxEnv;
fn parse(args: &[&str]) -> MartinResult<(Config, MetaArgs)> {
let args = Args::parse_from(args);
let meta = args.meta.clone();
let mut config = Config::default();
args.merge_into_config(
&mut config,
#[cfg(feature = "postgres")]
&FauxEnv::default(),
)?;
Ok((config, meta))
}
#[test]
fn cli_no_args() {
let args = parse(&["martin"]).unwrap();
let expected = (Config::default(), MetaArgs::default());
assert_eq!(args, expected);
}
#[cfg(feature = "postgres")]
#[test]
fn cli_with_config() {
use crate::config::file::postgres::PostgresConfig;
use crate::config::primitives::OptOneMany;
let args = parse(&["martin", "--config", "c.toml"]).unwrap();
let meta = MetaArgs {
config: Some(PathBuf::from("c.toml")),
..Default::default()
};
assert_eq!(args, (Config::default(), meta));
let args = parse(&["martin", "--config", "c.toml", "--save-config", "s.toml"]).unwrap();
let meta = MetaArgs {
config: Some(PathBuf::from("c.toml")),
save_config: Some(PathBuf::from("s.toml")),
..Default::default()
};
assert_eq!(args, (Config::default(), meta));
let args = parse(&["martin", "postgres://connection"]).unwrap();
let cfg = Config {
postgres: OptOneMany::One(PostgresConfig {
connection_string: Some("postgres://connection".to_string()),
..Default::default()
}),
..Default::default()
};
let meta = MetaArgs {
connection: vec!["postgres://connection".to_string()],
..Default::default()
};
assert_eq!(args, (cfg, meta));
}
#[test]
fn cli_encoding_arguments() {
let config1 = parse(&["martin", "--preferred-encoding", "brotli"]);
let config2 = parse(&["martin", "--preferred-encoding", "br"]);
let config3 = parse(&["martin", "--preferred-encoding", "gzip"]);
let config4 = parse(&["martin"]);
assert_eq!(
config1.unwrap().0.srv.preferred_encoding,
Some(PreferredEncoding::Brotli)
);
assert_eq!(
config2.unwrap().0.srv.preferred_encoding,
Some(PreferredEncoding::Brotli)
);
assert_eq!(
config3.unwrap().0.srv.preferred_encoding,
Some(PreferredEncoding::Gzip)
);
assert_eq!(config4.unwrap().0.srv.preferred_encoding, None);
}
#[cfg(any(feature = "unstable-cog", feature = "mbtiles", feature = "pmtiles"))]
#[test]
fn test_is_file_scheme_uri() {
assert!(is_file_scheme_uri("file:test.mbtiles", &["mbtiles"]));
assert!(is_file_scheme_uri(
"file:test.mbtiles?mode=memory&cache=shared",
&["mbtiles"]
));
assert!(is_file_scheme_uri(
"file:/path/to/test.mbtiles",
&["mbtiles"]
));
assert!(is_file_scheme_uri("file:data.pmtiles", &["pmtiles"]));
assert!(is_file_scheme_uri("file:image.tiff", &["tiff", "tif"]));
assert!(!is_file_scheme_uri(
"http://example.com/test.mbtiles",
&["mbtiles"]
));
assert!(!is_file_scheme_uri("test.mbtiles", &["mbtiles"]));
assert!(!is_file_scheme_uri("file:test.txt", &["mbtiles"]));
assert!(!is_file_scheme_uri("file:", &["mbtiles"]));
assert!(!is_file_scheme_uri("", &["mbtiles"]));
}
#[test]
fn cli_bad_arguments() {
for params in [
["martin", "--config", "c.toml", "--tmp"].as_slice(),
["martin", "--config", "c.toml", "-c", "t.toml"].as_slice(),
] {
let res = Args::try_parse_from(params);
assert!(res.is_err(), "Expected error, got: {res:?} for {params:?}");
}
}
#[test]
#[cfg(feature = "postgres")]
fn cli_bad_parsed_arguments() {
let args = Args::parse_from(["martin", "--config", "c.toml", "postgres://a"]);
let mut config = Config::default();
let err = args
.merge_into_config(&mut config, &FauxEnv::default())
.unwrap_err();
assert!(matches!(err, ConfigAndConnectionsError(..)));
}
#[test]
fn cli_unknown_con_str() {
let args = Args::parse_from(["martin", "foobar"]);
let mut config = Config::default();
let err = args
.merge_into_config(
&mut config,
#[cfg(feature = "postgres")]
&FauxEnv::default(),
)
.unwrap_err();
let bad = vec!["foobar".to_string()];
assert!(matches!(err, UnrecognizableConnections(v) if v == bad));
}
#[cfg(all(feature = "pmtiles", feature = "mbtiles", feature = "unstable-cog"))]
#[tokio::test]
async fn cli_multiple_extensions() {
use std::ffi::OsString;
let script = include_str!("../../../../tests/fixtures/mbtiles/json.sql");
let (_mbt, _conn, file) = mbtiles::temp_named_mbtiles("json.mbtiles", script).await;
let args = Args::parse_from([
OsString::from("martin"),
OsString::from("../tests/fixtures/pmtiles/png.pmtiles"),
file.as_os_str().to_owned(),
OsString::from("../tests/fixtures/cog/rgba_u8_nodata.tiff"),
OsString::from("../tests/fixtures/cog/rgba_u8.tif"),
]);
let mut config = Config::default();
args.merge_into_config(
&mut config,
#[cfg(feature = "postgres")]
&FauxEnv::default(),
)
.unwrap();
insta::assert_yaml_snapshot!(config, @r#"
pmtiles: "../tests/fixtures/pmtiles/png.pmtiles"
mbtiles: "file:json.mbtiles?mode=memory&cache=shared"
cog:
- "../tests/fixtures/cog/rgba_u8_nodata.tiff"
- "../tests/fixtures/cog/rgba_u8.tif"
"#);
}
#[cfg(all(feature = "pmtiles", feature = "mbtiles", feature = "unstable-cog"))]
#[test]
fn cli_directories_propagate() {
let args = Args::parse_from(["martin", "../tests/fixtures/"]);
let mut config = Config::default();
let err = args.merge_into_config(
&mut config,
#[cfg(feature = "postgres")]
&FauxEnv::default(),
);
assert!(err.is_ok());
insta::assert_yaml_snapshot!(config, @r#"
pmtiles: "../tests/fixtures/"
mbtiles: "../tests/fixtures/"
cog: "../tests/fixtures/"
"#);
}
}