Skip to main content

leviathan/
config.rs

1//! The field mapping: which parts of a record are its id, title, searchable
2//! text, group, date, filters and display fields.
3//!
4//! Resolution order: `leviathan.toml` (or inference from the data when there
5//! is no config), then CLI flags on top. The effective mapping is stored in
6//! the index, so later queries, upserts and the MCP server all use exactly
7//! the mapping the index was built with.
8
9use 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/// How results talk about the data: "3 tickets for customer acme".
26#[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    /// Singular noun for one record ("ticket", "log line", "incident").
32    pub record: String,
33    /// Singular noun for one group ("customer", "machine", "repo").
34    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    /// Files or directories, relative to the config file.
47    pub paths: Vec<String>,
48    pub format: Format,
49    /// Query for SQLite sources (default: the database's only table).
50    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    /// From the file extension; stdin is sniffed.
57    #[default]
58    Auto,
59    Jsonl,
60    Json,
61    Csv,
62    Tsv,
63    Sqlite,
64}
65
66/// Every field is a path into a record: `a.b` for nesting, `items[].name`
67/// (or just `items.name`) for every element of an array.
68#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
69#[serde(default, deny_unknown_fields)]
70pub struct Fields {
71    /// Unique record id. Without one, records are numbered `<file>:<line>`
72    /// and upserts cannot replace them.
73    pub id: Option<String>,
74    /// Short headline; the first path that is present wins.
75    #[serde(deserialize_with = "one_or_many")]
76    pub title: Vec<String>,
77    /// Searched text. Empty means every string in the record.
78    pub text: Vec<String>,
79    /// Scope searches to one entity (customer, machine, repo, host, ...).
80    pub group: Option<String>,
81    /// Human name of the group, used to resolve "the filler on line 3".
82    pub group_name: Option<String>,
83    /// Recency for ranking ties, `recent`, and `--since/--until`. First
84    /// present path wins.
85    #[serde(deserialize_with = "one_or_many")]
86    pub date: Vec<String>,
87    /// Exact-match filters (`--where status=open`), counted in `describe`.
88    pub filters: Vec<String>,
89    /// Shown on every result card, in this order.
90    pub display: Vec<String>,
91    /// Placeholder values treated as missing ("n/a", "done", "-").
92    pub empty_values: Vec<String>,
93}
94
95#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
96#[serde(default, deny_unknown_fields)]
97pub struct Rank {
98    /// BM25 weight of title matches relative to body text.
99    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/// Multiply relevance by `1 + weight` for records where any of `field` is
110/// present (non-empty, not a placeholder) or, with `equals`, has that value.
111#[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
176/// Plural of a configured noun, for headers like "33 log lines".
177pub 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/// Mapping overrides from the command line, applied on top of the config.
188#[derive(Debug, Clone, Default, clap::Args)]
189pub struct FieldFlags {
190    /// Config file [default: ./leviathan.toml when present]
191    #[arg(long, short = 'c', env = "LEVIATHAN_CONFIG")]
192    pub config: Option<std::path::PathBuf>,
193    /// Unique id field
194    #[arg(long, value_name = "PATH")]
195    pub id: Option<String>,
196    /// Title field(s); first present wins
197    #[arg(long, value_name = "PATH", value_delimiter = ',')]
198    pub title: Vec<String>,
199    /// Searched text fields (default: every string)
200    #[arg(long, value_name = "PATH", value_delimiter = ',')]
201    pub text: Vec<String>,
202    /// Group field for scoped search
203    #[arg(long, value_name = "PATH")]
204    pub group: Option<String>,
205    /// Human-readable group name field
206    #[arg(long, value_name = "PATH")]
207    pub group_name: Option<String>,
208    /// Date field(s); first present wins
209    #[arg(long, value_name = "PATH", value_delimiter = ',')]
210    pub date: Vec<String>,
211    /// Exact-match filter fields
212    #[arg(long = "filter", value_name = "PATH", value_delimiter = ',')]
213    pub filters: Vec<String>,
214    /// Fields shown on result cards
215    #[arg(long, value_name = "PATH", value_delimiter = ',')]
216    pub display: Vec<String>,
217    /// Placeholder values treated as missing
218    #[arg(long = "empty-value", value_name = "TEXT")]
219    pub empty_values: Vec<String>,
220    /// Input format
221    #[arg(long, value_enum)]
222    pub format: Option<Format>,
223    /// SQL query for SQLite sources
224    #[arg(long)]
225    pub sql: Option<String>,
226}
227
228impl FieldFlags {
229    /// The config file to use: explicit, else `./leviathan.toml` if it exists.
230    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}