use std::{env, path::PathBuf};
use anyhow::{Context, Result, bail};
use clap::{Parser, ValueEnum};
use serde::Deserialize;
use tracing::level_filters::LevelFilter;
#[derive(Clone, Debug, ValueEnum, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub(crate) enum Trace {
Stdout,
Stderr,
Tmp,
}
#[derive(Clone, Copy, Debug, ValueEnum, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub(crate) enum Level {
Trace,
Debug,
Info,
Warn,
Error,
Off,
}
impl From<Level> for LevelFilter {
fn from(value: Level) -> LevelFilter {
match value {
Level::Trace => LevelFilter::TRACE,
Level::Debug => LevelFilter::DEBUG,
Level::Info => LevelFilter::INFO,
Level::Warn => LevelFilter::WARN,
Level::Error => LevelFilter::ERROR,
Level::Off => LevelFilter::OFF,
}
}
}
const DEFAULT_HOST_ADDR: &str = "localhost";
const DEFAULT_HOST_PORT: u16 = 1965;
const DEFAULT_LOG_FILE: &str = "stderr";
const DEFAULT_LOG_LEVEL: &str = "info";
const DEFAULT_PAGE_INDEX: &str = "index.gmi";
#[derive(Debug, Default, Deserialize)]
struct ConfigFile {
#[serde(default)]
addr: Option<String>,
#[serde(default)]
port: Option<u16>,
#[serde(default)]
cert: Option<PathBuf>,
#[serde(default)]
key: Option<PathBuf>,
#[serde(default)]
trace: Option<Trace>,
#[serde(default)]
level: Option<Level>,
#[serde(default)]
root: Option<PathBuf>,
#[serde(default)]
index: Option<PathBuf>,
#[serde(default)]
badge: Option<String>,
#[serde(default)]
footer: Option<String>,
}
impl ConfigFile {
fn from_path(path: &PathBuf) -> Result<Self> {
let contents = std::fs::read_to_string(path)
.with_context(|| format!("failed to read config file: {}", path.display()))?;
toml::from_str(&contents)
.with_context(|| format!("failed to parse config file: {}", path.display()))
}
}
#[derive(Debug, Clone, Parser)]
#[command(
author,
about = "Serve Gemini capsules over TLS",
long_about = "Serve a Gemini capsule from a local directory over TLS. Configure it with CLI flags or a TOML config file.",
after_help = "Examples:\n zhuque --config config.toml\n zhuque --cert cert.pem --key key.pem --root gemcap"
)]
pub(crate) struct Args {
#[arg(
long = "config",
value_name = "FILE",
help = "path to a TOML config file; use this instead of passing every option on the command line"
)]
pub(crate) config: Option<PathBuf>,
#[arg(
short = 'a',
long = "addr",
value_name = "HOST",
help = format!("address or hostname to bind the server to [default: {DEFAULT_HOST_ADDR}]")
)]
pub(crate) addr: Option<String>,
#[arg(
short = 'p',
long = "port",
value_name = "PORT",
help = format!("port to listen on [default: {DEFAULT_HOST_PORT}]")
)]
pub(crate) port: Option<u16>,
#[arg(
short = 'c',
long = "cert",
value_name = "FILE",
help = "path to the TLS certificate file (.pem)"
)]
pub(crate) cert: Option<PathBuf>,
#[arg(
short = 'k',
long = "key",
value_name = "FILE",
help = "path to the TLS private key file (.pem)"
)]
pub(crate) key: Option<PathBuf>,
#[clap(
value_enum,
short = 't',
long = "trace",
help = format!("where to send trace output [default: {DEFAULT_LOG_FILE}]")
)]
pub(crate) trace: Option<Trace>,
#[clap(
value_enum,
short = 'l',
long = "level",
help = format!("minimum level for trace output [default: {DEFAULT_LOG_LEVEL}]")
)]
pub(crate) level: Option<Level>,
#[arg(
short = 'r',
long = "root",
value_name = "PATH",
help = "directory that contains the Gemini capsule contents"
)]
pub(crate) root: Option<PathBuf>,
#[arg(
short = 'i',
long = "index",
value_name = "FILE",
help = format!("default index file to serve for directory requests [default: {DEFAULT_PAGE_INDEX}]")
)]
pub(crate) index: Option<PathBuf>,
#[arg(
short = 'b',
long = "badge",
value_name = "TEXT",
help = "startup message to display when the server begins"
)]
pub(crate) badge: Option<String>,
#[arg(
short = 'f',
long = "footer",
value_name = "TEXT",
help = "text appended to Gemini responses when the MIME type is text/gemini"
)]
pub(crate) footer: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct ResolvedArgs {
pub(crate) addr: String,
pub(crate) port: u16,
pub(crate) cert: PathBuf,
pub(crate) key: PathBuf,
pub(crate) trace: Trace,
pub(crate) level: Level,
pub(crate) root: PathBuf,
pub(crate) index: PathBuf,
pub(crate) badge: Option<String>,
pub(crate) footer: Option<String>,
}
impl Args {
pub(crate) fn resolve(self) -> Result<ResolvedArgs> {
if let Some(path) = self.config {
if self.addr.is_some()
|| self.port.is_some()
|| self.cert.is_some()
|| self.key.is_some()
|| self.trace.is_some()
|| self.level.is_some()
|| self.root.is_some()
|| self.index.is_some()
{
bail!("--config cannot be used together with CLI options");
}
let config = ConfigFile::from_path(&path)?;
let root = config.root.context("missing root in config")?;
let root = if root.is_absolute() {
root
} else {
env::current_dir()?.join(root)
};
let cert = config.cert.context("missing cert in config")?;
let cert = if cert.is_absolute() {
cert
} else {
env::current_dir()?.join(cert)
};
let key = config.key.context("missing key in config")?;
let key = if key.is_absolute() {
key
} else {
env::current_dir()?.join(key)
};
return Ok(ResolvedArgs {
addr: config.addr.unwrap_or_else(|| "127.0.0.1".to_string()),
port: config.port.unwrap_or(1965),
cert,
key,
trace: config.trace.unwrap_or(Trace::Stderr),
level: config.level.unwrap_or(Level::Info),
root,
index: config.index.unwrap_or_else(|| PathBuf::from("index.gmi")),
badge: config.badge,
footer: config.footer,
});
}
let root = self.root.context("missing root")?;
let root = if root.is_absolute() {
root
} else {
env::current_dir()?.join(root)
};
let cert = self.cert.context("missing cert")?;
let cert = if cert.is_absolute() {
cert
} else {
env::current_dir()?.join(cert)
};
let key = self.key.context("missing key")?;
let key = if key.is_absolute() {
key
} else {
env::current_dir()?.join(key)
};
Ok(ResolvedArgs {
addr: self.addr.unwrap_or_else(|| "localhost".to_string()),
port: self.port.unwrap_or(1965),
cert,
key,
root,
trace: self.trace.unwrap_or(Trace::Stderr),
level: self.level.unwrap_or(Level::Info),
index: self.index.unwrap_or_else(|| PathBuf::from("index.gmi")),
badge: self.badge,
footer: self.footer,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_path(name: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("zhuque-{name}-{nanos}.toml"))
}
#[test]
fn config_file_can_be_loaded_from_toml() {
let path = temp_path("config");
fs::write(
&path,
r#"
addr = "127.0.0.1"
port = 1965
cert = "cert.pem"
key = "key.pem"
root = "gemcap"
trace = "stderr"
level = "info"
badge = "startup badge"
footer = "server footer"
"#,
)
.unwrap();
let cfg = ConfigFile::from_path(&path).unwrap();
assert_eq!(cfg.addr.as_deref(), Some("127.0.0.1"));
assert_eq!(cfg.port, Some(1965));
assert_eq!(cfg.cert.as_deref(), Some(Path::new("cert.pem")));
assert_eq!(cfg.key.as_deref(), Some(Path::new("key.pem")));
assert_eq!(cfg.root.as_deref(), Some(Path::new("gemcap")));
assert_eq!(cfg.trace, Some(Trace::Stderr));
assert_eq!(cfg.level, Some(Level::Info));
assert_eq!(cfg.badge.as_deref(), Some("startup badge"));
assert_eq!(cfg.footer.as_deref(), Some("server footer"));
let _ = fs::remove_file(path);
}
#[test]
fn config_file_accepts_hostnames() {
let path = temp_path("hostname");
fs::write(
&path,
"addr = 'localhost'\nroot = 'gemcap'\ncert = 'cert.pem'\nkey = 'key.pem'\n",
)
.unwrap();
let cfg = ConfigFile::from_path(&path).unwrap();
assert_eq!(cfg.addr.as_deref(), Some("localhost"));
let _ = fs::remove_file(path);
}
#[test]
fn config_file_rejects_cli_options() {
let path = temp_path("mixed");
fs::write(
&path,
"root = 'gemcap'\ncert = 'cert.pem'\nkey = 'key.pem'\n",
)
.unwrap();
let args = Args {
config: Some(path.clone()),
addr: Some("127.0.0.1".parse().unwrap()),
port: None,
cert: None,
key: None,
trace: None,
level: None,
root: None,
index: None,
badge: None,
footer: None,
};
let err = args.resolve().unwrap_err();
assert!(err.to_string().contains("--config cannot be used together"));
let _ = fs::remove_file(path);
}
}