Skip to main content

datui_lib/formats/
registry.rs

1//! The specs on the search path, and which one a file picks.
2
3use super::*;
4
5/// A spec found on the search path, and the copies of the same name it hides.
6#[derive(Debug, Clone)]
7pub struct Found {
8    pub spec: Arc<Spec>,
9    pub overrides: Vec<PathBuf>,
10}
11
12/// Every spec on the search path, first of each name first.
13#[derive(Debug, Clone, Default)]
14pub struct Registry {
15    pub specs: Vec<Found>,
16    /// FIX dictionaries: QuickFIX XML files and `kind = "fix"` TOML files.
17    pub fix: Vec<FixFound>,
18    /// DBC files for CAN logs: `.dbc` files and `kind = "dbc"` TOML files, in the order
19    /// they are read.
20    pub dbc: Vec<DbcFound>,
21    /// Spec files that could not be read, each with why.
22    pub errors: Vec<SpecError>,
23}
24
25/// A DBC file found on the search path.
26#[derive(Debug, Clone)]
27pub struct DbcFound {
28    pub dbc: Arc<crate::formats::dbc::Dbc>,
29    /// The file it was found as: the `.dbc`, or the TOML that names it.
30    pub path: PathBuf,
31}
32
33/// A FIX dictionary found on the search path, and the copies of the same name it hides.
34#[derive(Debug, Clone)]
35pub struct FixFound {
36    pub dict: Arc<crate::formats::fix::dict::Dictionary>,
37    pub overrides: Vec<PathBuf>,
38}
39
40/// How a spec was chosen for a file.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum Chosen {
43    /// `--format FILE`.
44    SpecFile,
45    /// `--format NAME`, or picked in the view.
46    Named,
47    Glob,
48    Magic,
49}
50
51/// Why `spec` read a file, for the Notes tab: `matched by magic MKTD ยท version 1`
52/// when its glob or its magic chose it, `chosen by --format FILE` otherwise.
53pub fn chosen_words(spec: &Spec, by: Chosen) -> String {
54    let chips = match by {
55        Chosen::Glob | Chosen::Magic => spec.match_chips_chosen(by),
56        Chosen::SpecFile | Chosen::Named => Vec::new(),
57    };
58    if chips.is_empty() {
59        format!("chosen by {}", by.words())
60    } else {
61        format!("matched by {}", chips_plain(&chips))
62    }
63}
64
65impl Chosen {
66    pub fn words(self) -> &'static str {
67        match self {
68            Self::SpecFile => "--format FILE",
69            Self::Named => "its name",
70            Self::Glob => "its glob",
71            Self::Magic => "its magic",
72        }
73    }
74}
75
76/// The specs a file matches, by the first rule that matched any.
77#[derive(Debug, Clone)]
78pub struct Matched {
79    pub specs: Vec<Arc<Spec>>,
80    pub by: Chosen,
81}
82
83/// The directories and files searched for specs, in order: the config directory's
84/// `formats`, then `$DATUI_FORMATS_PATH`, then `[formats] path` from the config.
85pub fn search_path(
86    config_dir: Option<&Path>,
87    env: Option<std::ffi::OsString>,
88    configured: &[String],
89) -> Vec<PathBuf> {
90    let mut path = Vec::new();
91    if let Some(dir) = config_dir {
92        path.push(dir.join("formats"));
93    }
94    if let Some(env) = env {
95        path.extend(std::env::split_paths(&env).filter(|p| !p.as_os_str().is_empty()));
96    }
97    path.extend(
98        configured
99            .iter()
100            .filter(|p| !p.trim().is_empty())
101            .map(|p| crate::config::expand_path(p)),
102    );
103    path
104}
105
106/// The search path `config` asks for: the config directory's `formats`, then
107/// `$DATUI_FORMATS_PATH`, then its `[formats] path`.
108pub fn search_path_for(config: &crate::config::AppConfig) -> Vec<PathBuf> {
109    let config_dir = crate::config::ConfigManager::new(crate::APP_NAME)
110        .ok()
111        .map(|m| m.config_dir().to_path_buf());
112    search_path(
113        config_dir.as_deref(),
114        std::env::var_os(PATH_VAR),
115        &config.formats.path,
116    )
117}
118
119impl Registry {
120    /// Read every spec on `path`. A directory gives its `*.toml` files in name order; a
121    /// file gives itself. What cannot be read is kept as an error, not fatal. A TOML
122    /// file of `kind = "fix"`, or a QuickFIX XML file, is a FIX dictionary; a `.dbc`
123    /// file, or a TOML file of `kind = "dbc"`, is a DBC file.
124    pub fn load(path: &[PathBuf]) -> Self {
125        let mut registry = Self::default();
126        for entry in path {
127            let files: Vec<PathBuf> = if entry.is_dir() {
128                let Ok(listing) = std::fs::read_dir(entry) else {
129                    continue;
130                };
131                let mut files: Vec<PathBuf> = listing
132                    .flatten()
133                    .map(|e| e.path())
134                    .filter(|p| {
135                        p.is_file()
136                            && p.extension().is_some_and(|e| {
137                                e.eq_ignore_ascii_case("toml")
138                                    || e.eq_ignore_ascii_case("xml")
139                                    || e.eq_ignore_ascii_case("dbc")
140                            })
141                    })
142                    .collect();
143                files.sort();
144                files
145            } else if entry.is_file() {
146                vec![entry.clone()]
147            } else {
148                continue;
149            };
150            for file in files {
151                // A DBC file, or a TOML file of `kind = "dbc"` that names one.
152                let dbc_like = file.extension().is_some_and(|e| {
153                    e.eq_ignore_ascii_case("dbc") || e.eq_ignore_ascii_case("toml")
154                });
155                if dbc_like {
156                    match crate::formats::dbc::load(&file) {
157                        Ok(Some(dbc)) => {
158                            registry.dbc.push(DbcFound {
159                                dbc: Arc::new(dbc),
160                                path: file,
161                            });
162                            continue;
163                        }
164                        Err(e) => {
165                            registry.errors.push(e);
166                            continue;
167                        }
168                        Ok(None) => {}
169                    }
170                }
171                match crate::formats::fix::dict::Dictionary::load(&file) {
172                    Ok(Some(dict)) => {
173                        registry.add_fix(dict, file);
174                        continue;
175                    }
176                    Err(e) => {
177                        registry.errors.push(e);
178                        continue;
179                    }
180                    // An XML file that is not a FIX dictionary is not a spec either.
181                    Ok(None)
182                        if !file
183                            .extension()
184                            .is_some_and(|e| e.eq_ignore_ascii_case("toml")) =>
185                    {
186                        continue;
187                    }
188                    Ok(None) => {}
189                }
190                match Spec::load(&file) {
191                    Ok(spec) => registry.add(spec, file),
192                    Err(e) => registry.errors.push(e),
193                }
194            }
195        }
196        registry
197    }
198
199    fn add(&mut self, spec: Spec, file: PathBuf) {
200        if let Some(found) = self.specs.iter_mut().find(|f| f.spec.name == spec.name) {
201            found.overrides.push(file);
202        } else {
203            self.specs.push(Found {
204                spec: Arc::new(spec),
205                overrides: Vec::new(),
206            });
207        }
208    }
209
210    fn add_fix(&mut self, dict: crate::formats::fix::dict::Dictionary, file: PathBuf) {
211        if let Some(found) = self.fix.iter_mut().find(|f| f.dict.name == dict.name) {
212            found.overrides.push(file);
213        } else {
214            self.fix.push(FixFound {
215                dict: Arc::new(dict),
216                overrides: Vec::new(),
217            });
218        }
219    }
220
221    /// The FIX dictionary named `name`.
222    pub fn fix_dict(&self, name: &str) -> Option<&Arc<crate::formats::fix::dict::Dictionary>> {
223        self.fix
224            .iter()
225            .find(|f| f.dict.name == name)
226            .map(|f| &f.dict)
227    }
228
229    /// The registry of `specs`, for tests and hosts that have their specs in hand.
230    pub fn of(specs: Vec<Spec>) -> Self {
231        let mut registry = Self::default();
232        for spec in specs {
233            let file = spec.path.clone().unwrap_or_default();
234            registry.add(spec, file);
235        }
236        registry
237    }
238
239    pub fn get(&self, name: &str) -> Option<&Arc<Spec>> {
240        self.specs
241            .iter()
242            .find(|f| f.spec.name == name)
243            .map(|f| &f.spec)
244    }
245
246    pub fn is_empty(&self) -> bool {
247        self.specs.is_empty()
248    }
249
250    /// The spec that reads the file `file`, by its glob or else by its magic as an open
251    /// picks it, when it reads the file's records as several variants: the home screen
252    /// lists them inside the file. Its first bytes are read only when no glob names it.
253    pub fn variants_of(&self, file: &Path) -> Option<Arc<Spec>> {
254        let globbed = self.by_glob(file, false);
255        let spec = if globbed.is_empty() {
256            let wanted = unnamed_may(file, false, false)?;
257            let compression = crate::CompressionFormat::from_extension(file);
258            if compression.is_some() || !self.specs.iter().any(|f| !f.spec.magic.is_empty()) {
259                return None;
260            }
261            self.matching_among(file, false, wanted, |reach| spec_head(file, None, reach))?
262                .specs
263                .into_iter()
264                .next()?
265        } else {
266            globbed.into_iter().find(|s| !s.is_delimited())?
267        };
268        spec.lists_variants().then_some(spec)
269    }
270
271    /// The spec that reads a local file a listing looked inside, as an open with
272    /// nothing asked picks it: by glob, else by magic and `match.where`, compared
273    /// against `head`, the bytes the listing already read from its front (all of it
274    /// when `whole`). A spec whose match needs more than `head` holds is not asked.
275    pub fn listed(&self, path: &Path, head: &[u8], whole: bool) -> Option<Arc<Spec>> {
276        if self.is_empty() || crate::CompressionFormat::from_extension(path).is_some() {
277            return None;
278        }
279        let wanted = unnamed_may(path, false, false)?;
280        let held = head.len() as u64;
281        let within = |s: &Spec| wanted(s) && (whole || s.match_reach() <= held);
282        self.matching_among(path, false, within, |_| Some(head.to_vec()))?
283            .specs
284            .into_iter()
285            .next()
286    }
287
288    /// The specs whose globs match `path`, a file or (for the columns layout) a
289    /// directory, by name alone.
290    pub fn by_glob(&self, path: &Path, is_dir: bool) -> Vec<Arc<Spec>> {
291        self.specs
292            .iter()
293            .map(|f| &f.spec)
294            .filter(|s| s.reads_directory() == is_dir && s.glob_matches(path))
295            .cloned()
296            .collect()
297    }
298
299    /// The specs `path` matches: by glob, else by magic. A spec with `match.where`
300    /// matches only a file whose header holds those values. The front of the file is
301    /// read through `head`, once, and only when a magic or a header is to be compared.
302    pub fn matching(
303        &self,
304        path: &Path,
305        is_dir: bool,
306        head: impl FnOnce(u64) -> Option<Vec<u8>>,
307    ) -> Option<Matched> {
308        self.matching_among(path, is_dir, |_| true, head)
309    }
310
311    /// [`Self::matching`] among the specs `wanted` says may read `path`.
312    pub fn matching_among(
313        &self,
314        path: &Path,
315        is_dir: bool,
316        wanted: impl Fn(&Spec) -> bool,
317        head: impl FnOnce(u64) -> Option<Vec<u8>>,
318    ) -> Option<Matched> {
319        let mut globbed = self.by_glob(path, is_dir);
320        globbed.retain(|s| wanted(s));
321        let (candidates, by) = if !globbed.is_empty() {
322            (globbed, Chosen::Glob)
323        } else if is_dir {
324            return None;
325        } else {
326            let magic: Vec<Arc<Spec>> = self
327                .specs
328                .iter()
329                .map(|f| &f.spec)
330                .filter(|s| !s.reads_directory() && !s.magic.is_empty() && wanted(s))
331                .cloned()
332                .collect();
333            (magic, Chosen::Magic)
334        };
335        let reach = candidates
336            .iter()
337            .map(|s| match by {
338                Chosen::Magic => s.match_reach(),
339                _ if s.expect.is_empty() => 0,
340                _ => s.match_reach(),
341            })
342            .max()
343            .unwrap_or(0);
344        let head = if reach > 0 && !is_dir {
345            head(reach)
346        } else {
347            None
348        };
349        let specs: Vec<Arc<Spec>> = candidates
350            .into_iter()
351            .filter(|s| {
352                let Some(head) = &head else {
353                    // Nothing read: a glob match stands unless it asked about the header.
354                    return by == Chosen::Glob && s.expect.is_empty();
355                };
356                (by != Chosen::Magic || s.magic_matches(head)) && s.header_matches(head)
357            })
358            .collect();
359        (!specs.is_empty()).then_some(Matched { specs, by })
360    }
361
362    /// Text for `datui formats`: each spec, the file it came from, the copies it hides,
363    /// and the spec files that could not be read.
364    pub fn listing(&self, path: &[PathBuf]) -> String {
365        let mut out = String::new();
366        if self.specs.is_empty() {
367            out.push_str("No format specs found.\n");
368        }
369        for found in &self.specs {
370            let spec = &found.spec;
371            out.push_str(&spec.name);
372            let said = match_words(spec);
373            if spec.is_delimited() {
374                out.push_str(&format!("  (delimited; {said})"));
375            } else {
376                out.push_str(&format!("  ({said})"));
377            }
378            out.push('\n');
379            if let Some(description) = &spec.description {
380                out.push_str(&format!("  {description}\n"));
381            }
382            if let Some(file) = &spec.path {
383                out.push_str(&format!("  {}\n", file.display()));
384            }
385            for hidden in &found.overrides {
386                out.push_str(&format!("  overrides {}\n", hidden.display()));
387            }
388        }
389        if !self.fix.is_empty() {
390            out.push_str("\nDictionaries (FIX):\n");
391        }
392        for found in &self.fix {
393            let dict = &found.dict;
394            out.push_str(&dict.name);
395            let summary = dict.matcher.summary();
396            if !summary.is_empty() {
397                out.push_str(&format!("  ({summary})"));
398            }
399            out.push('\n');
400            if let Some(file) = &dict.path {
401                out.push_str(&format!("  {}\n", file.display()));
402            }
403            for hidden in &found.overrides {
404                out.push_str(&format!("  overrides {}\n", hidden.display()));
405            }
406        }
407        if !self.dbc.is_empty() {
408            out.push_str("\nDictionaries (DBC):\n");
409        }
410        for found in &self.dbc {
411            let dbc = &found.dbc;
412            out.push_str(&format!(
413                "{}  ({}{})\n  {}\n",
414                dbc.name,
415                crate::formats::text_formats::count(
416                    dbc.messages.len() as u64,
417                    "message",
418                    "messages"
419                ),
420                dbc.interface
421                    .as_ref()
422                    .map(|i| format!(", interface {i}"))
423                    .unwrap_or_default(),
424                found.path.display()
425            ));
426        }
427        if !self.errors.is_empty() {
428            out.push_str("\nCould not read:\n");
429            for e in &self.errors {
430                out.push_str(&format!("  {e}\n"));
431            }
432        }
433        out.push_str("\nSearched, in order:\n");
434        for entry in path {
435            out.push_str(&format!("  {}\n", entry.display()));
436        }
437        out
438    }
439}