use std::{fs, path::PathBuf};
use anyhow::{Context, bail};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use tocat_api::{DirectionSpec, PluginSpec, normalize};
use crate::{
cli::Cli,
endpoint::{Endpoint, EndpointSpec},
logging::{LogLevel, LogSinkSpec},
progress::ProgressMode,
};
const CONFIG_NAMES: &[&str] = &["tocat.toml", ".tocat.toml"];
pub use tocat_api::ByteSize;
pub const DEFAULT_BUFFER: ByteSize = ByteSize(256 * 1024);
#[derive(Debug)]
pub struct Settings {
pub source: EndpointSpec,
pub sink: EndpointSpec,
pub plugins: Vec<PluginSpec>,
pub buffer: usize,
pub progress: ProgressMode,
}
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
pub source: Option<Endpoint>,
pub sink: Option<Endpoint>,
#[serde(rename = "log-level")]
pub log_level: Option<LogLevel>,
#[serde(rename = "buffer-size")]
pub buffer_size: Option<ByteSize>,
#[serde(default)]
pub progress: Option<ProgressMode>,
#[serde(default)]
pub log: Vec<LogSinkSpec>,
#[serde(default, rename = "plugin")]
pub plugins: Vec<PluginSpec>,
}
impl Config {
pub fn merge_cli(&mut self, cli: &Cli) -> anyhow::Result<()> {
let layout = cli.layout();
self.source = layout
.source
.map(Endpoint::Raw)
.or_else(|| self.source.take());
self.sink = layout.sink.map(Endpoint::Raw).or_else(|| self.sink.take());
if let Some(size) = cli.buffer_size {
self.buffer_size = Some(size);
}
if let Some(progress) = cli.progress {
self.progress = Some(progress);
}
if cli.no_plugins {
self.plugins.clear();
}
for raw in layout.plugins.iter().chain(&cli.plugins) {
self.plugins
.push(parse_plugin_spec(raw).with_context(|| format!("invalid plugin {raw:?}"))?);
}
Ok(())
}
}
pub fn parse_plugin_spec(raw: &str) -> anyhow::Result<PluginSpec> {
let mut parts = raw.split(',');
let head = parts.next().unwrap_or_default().trim();
if head.is_empty() {
bail!("expected a plugin name");
}
let (name, direction) = match head.split_once(':') {
Some((name, dir)) => (
name,
dir.parse::<DirectionSpec>()
.map_err(|e| anyhow::anyhow!("{e}"))?,
),
None => (head, DirectionSpec::default()),
};
if name.is_empty() {
bail!("expected a plugin name");
}
let mut config = Map::new();
let mut detach = None;
let mut alias = None;
for opt in parts {
let opt = opt.trim();
if opt.is_empty() {
continue;
}
let (key, value) = match opt.split_once('=') {
Some((key, value)) => (key, coerce(value)),
None => (opt, Value::Bool(true)),
};
match normalize(key).as_str() {
"detach" => {
detach = value.as_bool();
}
"as" => {
alias = value.as_str().map(str::to_string);
}
_ => {
config.insert(key.to_string(), value);
}
}
}
Ok(PluginSpec {
name: name.to_string(),
direction,
alias,
detach,
config,
})
}
fn coerce(value: &str) -> Value {
match value {
"true" => Value::Bool(true),
"false" => Value::Bool(false),
_ => value
.parse::<i64>()
.map(Value::from)
.unwrap_or_else(|_| Value::String(value.to_string())),
}
}
pub fn load_config(
explicit: Option<PathBuf>,
no_config: bool,
) -> anyhow::Result<(Config, Option<PathBuf>)> {
if no_config {
return Ok((Config::default(), None));
}
if let Some(path) = explicit {
let text = fs::read_to_string(&path).context("Failed to read config file")?;
let config = toml::from_str(&text).context("Failed to parse toml file")?;
return Ok((config, Some(path)));
}
for name in CONFIG_NAMES {
match fs::read_to_string(name) {
Ok(text) => {
let config = toml::from_str(&text).context("Failed to parse toml file")?;
return Ok((config, Some(PathBuf::from(name))));
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => return Err(e).context("unknown error"),
}
}
Ok((Config::default(), None))
}
pub fn resolve(config: Config) -> anyhow::Result<Settings> {
fn spec(endpoint: Option<Endpoint>, field: &str) -> anyhow::Result<EndpointSpec> {
let Some(endpoint) = endpoint else {
bail!("no {field} given");
};
endpoint
.into_spec()
.with_context(|| format!("invalid {field}"))
}
Ok(Settings {
source: spec(config.source, "source")?,
sink: spec(config.sink, "sink")?,
plugins: config.plugins,
buffer: config.buffer_size.unwrap_or(DEFAULT_BUFFER).bytes(),
progress: config.progress.unwrap_or_default(),
})
}