1use std::path::Path;
10
11use anyhow::{Context, Result, bail};
12use serde::{Deserialize, Deserializer, Serialize};
13
14pub const DEFAULT_FILE: &str = "leviathan.toml";
15
16#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
17#[serde(default, deny_unknown_fields)]
18pub struct Config {
19 pub about: About,
20 pub source: SourceConfig,
21 pub fields: Fields,
22 pub rank: Rank,
23}
24
25#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
27#[serde(default, deny_unknown_fields)]
28pub struct About {
29 pub name: Option<String>,
30 pub description: Option<String>,
31 pub record: String,
33 pub group: String,
35}
36
37impl Default for About {
38 fn default() -> Self {
39 Self { name: None, description: None, record: "record".into(), group: "group".into() }
40 }
41}
42
43#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
44#[serde(default, deny_unknown_fields)]
45pub struct SourceConfig {
46 pub paths: Vec<String>,
48 pub format: Format,
49 pub sql: Option<String>,
51}
52
53#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
54#[serde(rename_all = "lowercase")]
55pub enum Format {
56 #[default]
58 Auto,
59 Jsonl,
60 Json,
61 Csv,
62 Tsv,
63 Sqlite,
64}
65
66#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
69#[serde(default, deny_unknown_fields)]
70pub struct Fields {
71 pub id: Option<String>,
74 #[serde(deserialize_with = "one_or_many")]
76 pub title: Vec<String>,
77 pub text: Vec<String>,
79 pub group: Option<String>,
81 pub group_name: Option<String>,
83 #[serde(deserialize_with = "one_or_many")]
86 pub date: Vec<String>,
87 pub filters: Vec<String>,
89 pub display: Vec<String>,
91 pub empty_values: Vec<String>,
93}
94
95#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
96#[serde(default, deny_unknown_fields)]
97pub struct Rank {
98 pub title_weight: f64,
100 pub boost: Vec<Boost>,
101}
102
103impl Default for Rank {
104 fn default() -> Self {
105 Self { title_weight: 2.0, boost: Vec::new() }
106 }
107}
108
109#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
112#[serde(deny_unknown_fields)]
113pub struct Boost {
114 #[serde(deserialize_with = "one_or_many")]
115 pub field: Vec<String>,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub equals: Option<String>,
118 pub weight: f64,
119}
120
121fn one_or_many<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<String>, D::Error> {
122 #[derive(Deserialize)]
123 #[serde(untagged)]
124 enum OneOrMany {
125 One(String),
126 Many(Vec<String>),
127 }
128 Ok(match OneOrMany::deserialize(d)? {
129 OneOrMany::One(s) => vec![s],
130 OneOrMany::Many(v) => v,
131 })
132}
133
134impl Config {
135 pub fn load(path: &Path) -> Result<Self> {
136 let text = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
137 let mut cfg: Config = toml::from_str(&text).with_context(|| format!("parse {}", path.display()))?;
138 if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
139 for p in &mut cfg.source.paths {
140 if p != "-" && Path::new(p).is_relative() {
141 *p = dir.join(&*p).display().to_string();
142 }
143 }
144 }
145 cfg.validate()?;
146 Ok(cfg)
147 }
148
149 pub fn validate(&self) -> Result<()> {
150 let f = &self.fields;
151 let all = f.id.iter().chain(&f.title).chain(&f.text).chain(&f.group).chain(&f.group_name);
152 for path in all.chain(&f.date).chain(&f.filters).chain(&f.display) {
153 if path.trim().is_empty() {
154 bail!("empty field path in the mapping");
155 }
156 }
157 if f.group_name.is_some() && f.group.is_none() {
158 bail!("`group_name` needs `group`");
159 }
160 if !(self.rank.title_weight.is_finite() && self.rank.title_weight >= 0.0) {
161 bail!("`rank.title_weight` must be a non-negative number");
162 }
163 for b in &self.rank.boost {
164 if b.field.is_empty() || !(b.weight.is_finite() && b.weight > -1.0) {
165 bail!("each [[rank.boost]] needs a `field` and a `weight` above -1");
166 }
167 }
168 Ok(())
169 }
170
171 pub fn to_toml(&self) -> String {
172 toml::to_string(self).unwrap_or_default()
173 }
174}
175
176pub fn plural(noun: &str) -> String {
178 if noun.ends_with('s') || noun.ends_with("data") {
179 noun.to_string()
180 } else if let Some(stem) = noun.strip_suffix('y').filter(|s| !s.ends_with(['a', 'e', 'o', 'u'])) {
181 format!("{stem}ies")
182 } else {
183 format!("{noun}s")
184 }
185}
186
187#[derive(Debug, Clone, Default, clap::Args)]
189pub struct FieldFlags {
190 #[arg(long, short = 'c', env = "LEVIATHAN_CONFIG")]
192 pub config: Option<std::path::PathBuf>,
193 #[arg(long, value_name = "PATH")]
195 pub id: Option<String>,
196 #[arg(long, value_name = "PATH", value_delimiter = ',')]
198 pub title: Vec<String>,
199 #[arg(long, value_name = "PATH", value_delimiter = ',')]
201 pub text: Vec<String>,
202 #[arg(long, value_name = "PATH")]
204 pub group: Option<String>,
205 #[arg(long, value_name = "PATH")]
207 pub group_name: Option<String>,
208 #[arg(long, value_name = "PATH", value_delimiter = ',')]
210 pub date: Vec<String>,
211 #[arg(long = "filter", value_name = "PATH", value_delimiter = ',')]
213 pub filters: Vec<String>,
214 #[arg(long, value_name = "PATH", value_delimiter = ',')]
216 pub display: Vec<String>,
217 #[arg(long = "empty-value", value_name = "TEXT")]
219 pub empty_values: Vec<String>,
220 #[arg(long, value_enum)]
222 pub format: Option<Format>,
223 #[arg(long)]
225 pub sql: Option<String>,
226}
227
228impl FieldFlags {
229 pub fn config_path(&self) -> Option<std::path::PathBuf> {
231 self.config.clone().or_else(|| {
232 let default = Path::new(DEFAULT_FILE);
233 default.exists().then(|| default.to_path_buf())
234 })
235 }
236
237 pub fn is_empty(&self) -> bool {
238 self.id.is_none()
239 && self.title.is_empty()
240 && self.text.is_empty()
241 && self.group.is_none()
242 && self.group_name.is_none()
243 && self.date.is_empty()
244 && self.filters.is_empty()
245 && self.display.is_empty()
246 && self.empty_values.is_empty()
247 }
248
249 pub fn apply(&self, cfg: &mut Config) {
250 let f = &mut cfg.fields;
251 if self.id.is_some() {
252 f.id.clone_from(&self.id);
253 }
254 if self.group.is_some() {
255 f.group.clone_from(&self.group);
256 }
257 if self.group_name.is_some() {
258 f.group_name.clone_from(&self.group_name);
259 }
260 for (dst, src) in [
261 (&mut f.title, &self.title),
262 (&mut f.text, &self.text),
263 (&mut f.date, &self.date),
264 (&mut f.filters, &self.filters),
265 (&mut f.display, &self.display),
266 (&mut f.empty_values, &self.empty_values),
267 ] {
268 if !src.is_empty() {
269 dst.clone_from(src);
270 }
271 }
272 if let Some(format) = self.format {
273 cfg.source.format = format;
274 }
275 if self.sql.is_some() {
276 cfg.source.sql.clone_from(&self.sql);
277 }
278 }
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284
285 #[test]
286 fn parses_one_or_many_and_defaults() {
287 let cfg: Config = toml::from_str(
288 r#"
289 [fields]
290 id = "id"
291 title = "subject"
292 date = ["closed", "opened"]
293 [[rank.boost]]
294 field = "resolution"
295 weight = 0.15
296 "#,
297 )
298 .unwrap();
299 assert_eq!(cfg.fields.title, ["subject"]);
300 assert_eq!(cfg.fields.date, ["closed", "opened"]);
301 assert_eq!(cfg.about.record, "record");
302 assert_eq!(cfg.rank.title_weight, 2.0);
303 assert_eq!(cfg.rank.boost[0].field, ["resolution"]);
304 }
305
306 #[test]
307 fn rejects_unknown_keys() {
308 assert!(toml::from_str::<Config>("[fields]\nidd = \"x\"").is_err());
309 }
310
311 #[test]
312 fn plurals() {
313 assert_eq!(plural("log line"), "log lines");
314 assert_eq!(plural("entry"), "entries");
315 assert_eq!(plural("day"), "days");
316 assert_eq!(plural("logs"), "logs");
317 }
318}