use std::path::Path;
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Deserializer, Serialize};
pub const DEFAULT_FILE: &str = "leviathan.toml";
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
pub about: About,
pub source: SourceConfig,
pub fields: Fields,
pub rank: Rank,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct About {
pub name: Option<String>,
pub description: Option<String>,
pub record: String,
pub group: String,
}
impl Default for About {
fn default() -> Self {
Self { name: None, description: None, record: "record".into(), group: "group".into() }
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct SourceConfig {
pub paths: Vec<String>,
pub format: Format,
pub sql: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "lowercase")]
pub enum Format {
#[default]
Auto,
Jsonl,
Json,
Csv,
Tsv,
Sqlite,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Fields {
pub id: Option<String>,
#[serde(deserialize_with = "one_or_many")]
pub title: Vec<String>,
pub text: Vec<String>,
pub group: Option<String>,
pub group_name: Option<String>,
#[serde(deserialize_with = "one_or_many")]
pub date: Vec<String>,
pub filters: Vec<String>,
pub display: Vec<String>,
pub empty_values: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Rank {
pub title_weight: f64,
pub boost: Vec<Boost>,
}
impl Default for Rank {
fn default() -> Self {
Self { title_weight: 2.0, boost: Vec::new() }
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Boost {
#[serde(deserialize_with = "one_or_many")]
pub field: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub equals: Option<String>,
pub weight: f64,
}
fn one_or_many<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<String>, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum OneOrMany {
One(String),
Many(Vec<String>),
}
Ok(match OneOrMany::deserialize(d)? {
OneOrMany::One(s) => vec![s],
OneOrMany::Many(v) => v,
})
}
impl Config {
pub fn load(path: &Path) -> Result<Self> {
let text = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
let mut cfg: Config = toml::from_str(&text).with_context(|| format!("parse {}", path.display()))?;
if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
for p in &mut cfg.source.paths {
if p != "-" && Path::new(p).is_relative() {
*p = dir.join(&*p).display().to_string();
}
}
}
cfg.validate()?;
Ok(cfg)
}
pub fn validate(&self) -> Result<()> {
let f = &self.fields;
let all = f.id.iter().chain(&f.title).chain(&f.text).chain(&f.group).chain(&f.group_name);
for path in all.chain(&f.date).chain(&f.filters).chain(&f.display) {
if path.trim().is_empty() {
bail!("empty field path in the mapping");
}
}
if f.group_name.is_some() && f.group.is_none() {
bail!("`group_name` needs `group`");
}
if !(self.rank.title_weight.is_finite() && self.rank.title_weight >= 0.0) {
bail!("`rank.title_weight` must be a non-negative number");
}
for b in &self.rank.boost {
if b.field.is_empty() || !(b.weight.is_finite() && b.weight > -1.0) {
bail!("each [[rank.boost]] needs a `field` and a `weight` above -1");
}
}
Ok(())
}
pub fn to_toml(&self) -> String {
toml::to_string(self).unwrap_or_default()
}
}
pub fn plural(noun: &str) -> String {
if noun.ends_with('s') || noun.ends_with("data") {
noun.to_string()
} else if let Some(stem) = noun.strip_suffix('y').filter(|s| !s.ends_with(['a', 'e', 'o', 'u'])) {
format!("{stem}ies")
} else {
format!("{noun}s")
}
}
#[derive(Debug, Clone, Default, clap::Args)]
pub struct FieldFlags {
#[arg(long, short = 'c', env = "LEVIATHAN_CONFIG")]
pub config: Option<std::path::PathBuf>,
#[arg(long, value_name = "PATH")]
pub id: Option<String>,
#[arg(long, value_name = "PATH", value_delimiter = ',')]
pub title: Vec<String>,
#[arg(long, value_name = "PATH", value_delimiter = ',')]
pub text: Vec<String>,
#[arg(long, value_name = "PATH")]
pub group: Option<String>,
#[arg(long, value_name = "PATH")]
pub group_name: Option<String>,
#[arg(long, value_name = "PATH", value_delimiter = ',')]
pub date: Vec<String>,
#[arg(long = "filter", value_name = "PATH", value_delimiter = ',')]
pub filters: Vec<String>,
#[arg(long, value_name = "PATH", value_delimiter = ',')]
pub display: Vec<String>,
#[arg(long = "empty-value", value_name = "TEXT")]
pub empty_values: Vec<String>,
#[arg(long, value_enum)]
pub format: Option<Format>,
#[arg(long)]
pub sql: Option<String>,
}
impl FieldFlags {
pub fn config_path(&self) -> Option<std::path::PathBuf> {
self.config.clone().or_else(|| {
let default = Path::new(DEFAULT_FILE);
default.exists().then(|| default.to_path_buf())
})
}
pub fn is_empty(&self) -> bool {
self.id.is_none()
&& self.title.is_empty()
&& self.text.is_empty()
&& self.group.is_none()
&& self.group_name.is_none()
&& self.date.is_empty()
&& self.filters.is_empty()
&& self.display.is_empty()
&& self.empty_values.is_empty()
}
pub fn apply(&self, cfg: &mut Config) {
let f = &mut cfg.fields;
if self.id.is_some() {
f.id.clone_from(&self.id);
}
if self.group.is_some() {
f.group.clone_from(&self.group);
}
if self.group_name.is_some() {
f.group_name.clone_from(&self.group_name);
}
for (dst, src) in [
(&mut f.title, &self.title),
(&mut f.text, &self.text),
(&mut f.date, &self.date),
(&mut f.filters, &self.filters),
(&mut f.display, &self.display),
(&mut f.empty_values, &self.empty_values),
] {
if !src.is_empty() {
dst.clone_from(src);
}
}
if let Some(format) = self.format {
cfg.source.format = format;
}
if self.sql.is_some() {
cfg.source.sql.clone_from(&self.sql);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_one_or_many_and_defaults() {
let cfg: Config = toml::from_str(
r#"
[fields]
id = "id"
title = "subject"
date = ["closed", "opened"]
[[rank.boost]]
field = "resolution"
weight = 0.15
"#,
)
.unwrap();
assert_eq!(cfg.fields.title, ["subject"]);
assert_eq!(cfg.fields.date, ["closed", "opened"]);
assert_eq!(cfg.about.record, "record");
assert_eq!(cfg.rank.title_weight, 2.0);
assert_eq!(cfg.rank.boost[0].field, ["resolution"]);
}
#[test]
fn rejects_unknown_keys() {
assert!(toml::from_str::<Config>("[fields]\nidd = \"x\"").is_err());
}
#[test]
fn plurals() {
assert_eq!(plural("log line"), "log lines");
assert_eq!(plural("entry"), "entries");
assert_eq!(plural("day"), "days");
assert_eq!(plural("logs"), "logs");
}
}