use std::ffi::OsString;
use std::path::{Path, PathBuf};
use serde::Deserialize;
pub const CONFIG_ENV: &str = "FURNACE_CONFIG";
#[derive(Debug, Default, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct ConfigFile {
#[serde(default)]
pub server: Server,
#[serde(default)]
pub model: ModelSection,
#[serde(default)]
pub batching: Batching,
}
#[derive(Debug, Default, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Server {
pub host: Option<String>,
pub port: Option<u16>,
pub max_concurrent_requests: Option<usize>,
pub max_body_mb: Option<usize>,
pub request_timeout_ms: Option<u64>,
pub log_level: Option<String>,
pub threads: Option<u64>,
}
#[derive(Debug, Default, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct ModelSection {
pub name: Option<String>,
pub path: Option<PathBuf>,
pub weights: Option<PathBuf>,
pub backend: Option<String>,
}
#[derive(Debug, Default, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Batching {
pub max_batch_size: Option<usize>,
pub timeout_us: Option<u64>,
pub workers: Option<u64>,
}
impl ConfigFile {
pub fn load(path: &Path) -> Result<Self, String> {
let text = std::fs::read_to_string(path)
.map_err(|e| format!("cannot read {}: {e}", path.display()))?;
Self::parse(&text).map_err(|e| format!("{}: {e}", path.display()))
}
pub fn parse(text: &str) -> Result<Self, String> {
toml::from_str(text).map_err(|e| e.to_string())
}
pub fn to_args(&self, with_model: bool) -> Vec<OsString> {
let mut args = Vec::new();
let mut push = |flag: &str, value: Option<String>| {
if let Some(value) = value {
args.push(OsString::from(flag));
args.push(OsString::from(value));
}
};
let s = &self.server;
push("--host", s.host.clone());
push("--port", s.port.map(|v| v.to_string()));
push(
"--max-concurrent-requests",
s.max_concurrent_requests.map(|v| v.to_string()),
);
push("--max-body-mb", s.max_body_mb.map(|v| v.to_string()));
push(
"--request-timeout-ms",
s.request_timeout_ms.map(|v| v.to_string()),
);
push("--log-level", s.log_level.clone());
push("--threads", s.threads.map(|v| v.to_string()));
let m = &self.model;
if with_model {
push("--model-name", m.name.clone());
push("--model-path", m.path.as_deref().map(path_string));
}
push("--weights", m.weights.as_deref().map(path_string));
push("--backend", m.backend.clone());
let b = &self.batching;
push("--max-batch-size", b.max_batch_size.map(|v| v.to_string()));
push("--batch-timeout-us", b.timeout_us.map(|v| v.to_string()));
push("--batch-workers", b.workers.map(|v| v.to_string()));
args
}
}
fn path_string(path: &Path) -> String {
path.to_string_lossy().into_owned()
}
pub fn config_path(args: &[OsString]) -> Option<PathBuf> {
let mut it = args.iter();
while let Some(arg) = it.next() {
let arg = arg.to_string_lossy();
if arg == "--" {
break;
}
if arg == "--config" {
return it.next().map(PathBuf::from);
}
if let Some(path) = arg.strip_prefix("--config=") {
return Some(PathBuf::from(path));
}
}
std::env::var_os(CONFIG_ENV)
.filter(|v| !v.is_empty())
.map(PathBuf::from)
}
pub fn picks_model(args: &[OsString]) -> bool {
args.iter().map(|a| a.to_string_lossy()).any(|arg| {
arg == "-m"
|| (arg.starts_with("-m") && !arg.starts_with("--"))
|| arg == "--model-name"
|| arg.starts_with("--model-name=")
|| arg == "--model-path"
|| arg.starts_with("--model-path=")
})
}
#[cfg(test)]
mod tests {
use super::*;
fn args(list: &[&str]) -> Vec<OsString> {
list.iter().map(OsString::from).collect()
}
#[test]
fn example_file_parses_and_maps_to_flags() {
let config = ConfigFile::parse(include_str!("../furnace.example.toml")).unwrap();
let flags = config.to_args(true);
let flags: Vec<_> = flags
.iter()
.map(|f| f.to_string_lossy().into_owned())
.collect();
for flag in [
"--host",
"--port",
"--model-name",
"--backend",
"--max-batch-size",
] {
assert!(flags.iter().any(|f| f == flag), "{flag} missing: {flags:?}");
}
}
#[test]
fn empty_and_partial_files() {
assert_eq!(ConfigFile::parse("").unwrap(), ConfigFile::default());
let config = ConfigFile::parse("[server]\nport = 8080\n").unwrap();
assert_eq!(config.to_args(true), args(&["--port", "8080"]));
}
#[test]
fn unknown_keys_and_bad_types_are_errors() {
let typo = ConfigFile::parse("[server]\nprot = 8080\n").unwrap_err();
assert!(typo.contains("prot"), "{typo}");
let section = ConfigFile::parse("[serverr]\nport = 8080\n").unwrap_err();
assert!(section.contains("serverr"), "{section}");
assert!(ConfigFile::parse("[server]\nport = \"x\"\n").is_err());
assert!(ConfigFile::parse("[server]\nport = 70000\n").is_err());
}
#[test]
fn model_is_left_out_when_the_command_line_picks_one() {
let config =
ConfigFile::parse("[model]\nname = \"resnet18\"\nbackend = \"cpu\"\n").unwrap();
assert_eq!(config.to_args(false), args(&["--backend", "cpu"]),);
assert!(picks_model(&args(&["--model-path", "x"])));
assert!(picks_model(&args(&["-m", "mnist"])));
assert!(picks_model(&args(&["-mmnist"])));
assert!(picks_model(&args(&["--model-name=mnist"])));
assert!(!picks_model(&args(&[
"--port",
"3000",
"--max-body-mb",
"5"
])));
}
#[test]
fn config_path_from_flag() {
assert_eq!(
config_path(&args(&["--port", "1", "--config", "a.toml"])),
Some(PathBuf::from("a.toml"))
);
assert_eq!(
config_path(&args(&["--config=b.toml"])),
Some(PathBuf::from("b.toml"))
);
}
}