Skip to main content

common_config/
tagged_seq.rs

1// Copyright 2023 Greptime Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Environment overrides for configuration keys holding a sequence of tagged tables.
16//!
17//! Such a sequence is what a `Vec` of externally tagged enums serializes to, for
18//! example the `region_engine` option:
19//!
20//! ```text
21//! [{"mito": {...}}, {"file": {...}}]
22//! ```
23//!
24//! Handing the plain [`Environment`] source to `config` cannot work for these keys:
25//! `config` writes every flat dotted key by walking down its path, and the walk resets
26//! any intermediate value that is not a table. Walking `region_engine.mito.index.aux_path`
27//! therefore destroys the default sequence before anything is set.
28//!
29//! This source instead addresses the entries by their tag name and merges the
30//! overrides onto the matching default entries:
31//!
32//! ```text
33//! GREPTIMEDB_STANDALONE__REGION_ENGINE__MITO__INDEX__AUX_PATH=...
34//! ```
35//!
36//! Entries that only the environment mentions are appended, and every field that is
37//! not mentioned keeps its default value. A configuration file that configures the
38//! whole sequence replaces the merged one, which makes these variables ineffective;
39//! the file has to spell out every entry it wants.
40
41use config::{ConfigError, Environment, Map, Source, Value, ValueKind};
42use serde_json::Value as JsonValue;
43
44use crate::error::{
45    Error, Result, TaggedSeqInvalidDefaultSnafu, TaggedSeqMissingFieldSnafu,
46    TaggedSeqMissingTagSnafu, TaggedSeqNotATableSnafu, TaggedSeqNumericTagSnafu,
47    TaggedSeqOverridesConflictSnafu,
48};
49
50/// A [`Source`] that merges environment overrides into sequences of tagged tables.
51///
52/// See the [module documentation](self) for the addressed syntax.
53#[derive(Debug)]
54pub(crate) struct TaggedSeqEnvSource {
55    /// The wrapped environment source. Everything it collects is kept as is, apart
56    /// from the entries that address one of the `targets`.
57    inner: Environment,
58    /// The keys whose entries are addressed by tag name.
59    targets: Vec<Target>,
60}
61
62/// One sequence of tagged tables that the environment may override.
63#[derive(Debug, Clone)]
64struct Target {
65    /// The dotted key of the sequence, e.g. `region_engine`.
66    key: String,
67    /// The default value of `key`, whose entries the overrides are merged into.
68    default: Option<JsonValue>,
69}
70
71impl TaggedSeqEnvSource {
72    /// Wraps `inner` so that the sequence at every key of `keys` accepts tag name
73    /// overrides from the environment, merged onto the value that the same key has
74    /// in `defaults`.
75    pub(crate) fn new(
76        inner: Environment,
77        keys: &'static [&'static str],
78        defaults: &JsonValue,
79    ) -> Self {
80        let targets = keys
81            .iter()
82            .map(|key| Target {
83                key: (*key).to_string(),
84                default: defaults.get(key).cloned(),
85            })
86            .collect();
87
88        Self { inner, targets }
89    }
90}
91
92impl Source for TaggedSeqEnvSource {
93    fn clone_into_box(&self) -> Box<dyn Source + Send + Sync> {
94        Box::new(Self {
95            inner: self.inner.clone(),
96            targets: self.targets.clone(),
97        })
98    }
99
100    fn collect(&self) -> std::result::Result<Map<String, Value>, ConfigError> {
101        let mut collected = self.inner.collect()?;
102
103        for target in &self.targets {
104            // Take the entries out first: `config` walks the collected map in an
105            // unspecified order, so a leftover `region_engine.mito.x` entry could be
106            // written after the sequence and destroy it again.
107            let entries = take_entries(&mut collected, &target.key);
108            let merged = merge(target, entries).map_err(|e| ConfigError::Message(e.to_string()))?;
109            collected.insert(target.key.clone(), merged);
110        }
111
112        Ok(collected)
113    }
114}
115
116/// Removes and returns the entries that address `key` itself or one of its children.
117fn take_entries(collected: &mut Map<String, Value>, key: &str) -> Vec<(String, Value)> {
118    let child_prefix = format!("{key}.");
119    let mut keys = collected
120        .keys()
121        .filter(|existing| *existing == key || existing.starts_with(&child_prefix))
122        .cloned()
123        .collect::<Vec<_>>();
124    // `Map` is unordered, sort so that a reported error is deterministic.
125    keys.sort();
126
127    keys.into_iter()
128        .filter_map(|key| collected.remove(&key).map(|value| (key, value)))
129        .collect()
130}
131
132/// Rebuilds the sequence of `target` with the environment overrides `entries` applied.
133fn merge(target: &Target, entries: Vec<(String, Value)>) -> Result<Value> {
134    let mut overrides = group_entries(target, entries)?;
135
136    let mut merged = Vec::with_capacity(overrides.len() + 1);
137    for entry in default_entries(target)? {
138        let mut entry = json_to_value(entry);
139        let tag = single_tag(target, &entry)?;
140
141        if let Some(value) = overrides.remove(&tag) {
142            apply_override(target, &tag, &mut entry, value)?;
143        }
144
145        merged.push(entry);
146    }
147
148    // Entries that the default sequence does not have are appended.
149    let mut appended = overrides.into_iter().collect::<Vec<_>>();
150    appended.sort_by(|a, b| a.0.cmp(&b.0));
151    for (tag, value) in appended {
152        let mut table = Map::new();
153        table.insert(tag, value);
154        merged.push(Value::new(None, ValueKind::Table(table)));
155    }
156
157    Ok(Value::new(None, ValueKind::Array(merged)))
158}
159
160/// Merges the override of the entry `tag` into `entry`, whose only key is `tag`.
161fn apply_override(target: &Target, tag: &str, entry: &mut Value, value: Value) -> Result<()> {
162    let ValueKind::Table(body) = &mut entry.kind else {
163        return invalid_default(target, "expected an array of tables");
164    };
165
166    let Some(field) = body.get_mut(tag) else {
167        return invalid_default(target, "expected each entry to hold exactly one tag");
168    };
169
170    // Both sides are tables, this only fails on a field whose default value cannot hold
171    // the sub-table that the environment sets.
172    deep_merge(field, value).map_err(|path| not_a_table(&entry_path(target, tag, &path)))
173}
174
175/// Returns the default entries of `target`, each one a single-key table `{tag: table}`.
176fn default_entries(target: &Target) -> Result<&[JsonValue]> {
177    match &target.default {
178        None | Some(JsonValue::Null) => Ok(&[]),
179        Some(JsonValue::Array(entries)) => Ok(entries),
180        Some(_) => invalid_default(target, "expected an array of tables"),
181    }
182}
183
184/// Returns the tag of `entry`, which must map one tag to a table.
185fn single_tag(target: &Target, entry: &Value) -> Result<String> {
186    let ValueKind::Table(table) = &entry.kind else {
187        return invalid_default(target, "expected an array of tables");
188    };
189
190    let mut keys = table.keys();
191    let (Some(tag), None) = (keys.next(), keys.next()) else {
192        return invalid_default(target, "expected each entry to hold exactly one tag");
193    };
194    if !matches!(&table[tag].kind, ValueKind::Table(_)) {
195        return invalid_default(target, "expected each entry to map its tag to a table");
196    }
197
198    Ok(tag.clone())
199}
200
201/// Groups the overrides by tag, nesting every field path into a table.
202fn group_entries(target: &Target, entries: Vec<(String, Value)>) -> Result<Map<String, Value>> {
203    // Every entry but `key` itself is addressed as `<key>.<tag>.<field>...`.
204    let offset = target.key.len() + 1;
205    let mut groups: Map<String, Value> = Map::new();
206
207    for (key, value) in entries {
208        if key == target.key {
209            return TaggedSeqMissingTagSnafu {
210                key,
211                target: target.key.clone(),
212            }
213            .fail();
214        }
215
216        let rest = key[offset..].to_string();
217        let Some((tag, field)) = rest.split_once('.') else {
218            return TaggedSeqMissingFieldSnafu {
219                key,
220                tag: rest,
221                target: target.key.clone(),
222            }
223            .fail();
224        };
225
226        if is_index_like(tag) {
227            return TaggedSeqNumericTagSnafu {
228                key,
229                tag: tag.to_string(),
230                target: target.key.clone(),
231            }
232            .fail();
233        }
234
235        let field = nest(field, value);
236        match groups.get_mut(tag) {
237            Some(group) => deep_merge(group, field)
238                .map_err(|path| overrides_conflict(&key, &entry_path(target, tag, &path)))?,
239            None => {
240                groups.insert(tag.to_string(), field);
241            }
242        }
243    }
244
245    Ok(groups)
246}
247
248/// Whether `tag` looks like a position in the sequence rather than an engine name.
249///
250/// `config` also accepts negative and floating point looking path segments (`[-1]`,
251/// `[1e3]`), so anything that starts like a number is reported as a misplaced index;
252/// otherwise those variables would only fail later with the generic `unknown variant`
253/// error from serde.
254fn is_index_like(tag: &str) -> bool {
255    let mut chars = tag.chars();
256    match chars.next() {
257        Some(first) if first.is_ascii_digit() => true,
258        Some('-') | Some('+') => {
259            matches!(chars.next(), Some(next) if next.is_ascii_digit() || next == '.')
260        }
261        _ => false,
262    }
263}
264
265/// Builds a table whose key path is the dotted `path`, e.g. `a.b` and `v` give
266/// `{a: {b: v}}`.
267fn nest(path: &str, value: Value) -> Value {
268    let mut table = Map::new();
269    match path.split_once('.') {
270        Some((head, tail)) => {
271            table.insert(head.to_string(), nest(tail, value));
272        }
273        None => {
274            table.insert(path.to_string(), value);
275        }
276    }
277
278    Value::new(None, ValueKind::Table(table))
279}
280
281/// Merges `incoming` into `base`, recursively for tables. Returns the dotted path of
282/// the field that cannot be merged into, when `base` holds a non-table value where
283/// `incoming` needs a table.
284fn deep_merge(base: &mut Value, incoming: Value) -> std::result::Result<(), String> {
285    merge_at(base, incoming, "")
286}
287
288fn merge_at(base: &mut Value, incoming: Value, path: &str) -> std::result::Result<(), String> {
289    match incoming.kind {
290        ValueKind::Table(incoming) => {
291            let ValueKind::Table(base) = &mut base.kind else {
292                return Err(path.to_string());
293            };
294            for (field, value) in incoming {
295                let field_path = if path.is_empty() {
296                    field.clone()
297                } else {
298                    format!("{path}.{field}")
299                };
300                // A non-table override replaces whatever the key already holds. It is inserted
301                // as is, so the value keeps the origin it was collected with.
302                if matches!(value.kind, ValueKind::Table(_)) {
303                    match base.get_mut(&field) {
304                        Some(existing) => merge_at(existing, value, &field_path)?,
305                        None => {
306                            base.insert(field, value);
307                        }
308                    }
309                } else {
310                    base.insert(field, value);
311                }
312            }
313            Ok(())
314        }
315        kind => {
316            // A non-table override replaces whatever the key already holds.
317            base.kind = kind;
318            Ok(())
319        }
320    }
321}
322
323fn invalid_default<T>(target: &Target, reason: &str) -> Result<T> {
324    TaggedSeqInvalidDefaultSnafu {
325        target: target.key.clone(),
326        reason: reason.to_string(),
327    }
328    .fail()
329}
330
331/// Builds the dotted path of `path` below the entry `tag` of `target`.
332fn entry_path(target: &Target, tag: &str, path: &str) -> String {
333    if path.is_empty() {
334        format!("{}.{}", target.key, tag)
335    } else {
336        format!("{}.{}.{}", target.key, tag, path)
337    }
338}
339
340/// Error for a field whose default value is not a table, so nothing can be set below it.
341fn not_a_table(path: &str) -> Error {
342    TaggedSeqNotATableSnafu {
343        path: path.to_string(),
344    }
345    .build()
346}
347
348/// Error for a key that sets a field below a path another key sets as a whole.
349fn overrides_conflict(key: &str, path: &str) -> Error {
350    TaggedSeqOverridesConflictSnafu {
351        key: key.to_string(),
352        path: path.to_string(),
353    }
354    .build()
355}
356
357/// Converts a default value parsed from the JSON serialization of the options.
358fn json_to_value(json: &JsonValue) -> Value {
359    let kind = match json {
360        JsonValue::Null => ValueKind::Nil,
361        JsonValue::Bool(value) => ValueKind::Boolean(*value),
362        JsonValue::Number(value) => match (value.as_i64(), value.as_u64()) {
363            (Some(value), _) => ValueKind::I64(value),
364            (None, Some(value)) => ValueKind::U64(value),
365            (None, None) => ValueKind::Float(value.as_f64().unwrap_or_default()),
366        },
367        JsonValue::String(value) => ValueKind::String(value.clone()),
368        JsonValue::Array(values) => ValueKind::Array(values.iter().map(json_to_value).collect()),
369        JsonValue::Object(values) => ValueKind::Table(
370            values
371                .iter()
372                .map(|(key, value)| (key.clone(), json_to_value(value)))
373                .collect(),
374        ),
375    };
376
377    Value::new(None, kind)
378}
379
380#[cfg(test)]
381mod tests {
382    use serde_json::json;
383
384    use super::*;
385
386    const KEY: &str = "region_engine";
387    const KEYS: &[&str] = &[KEY];
388
389    /// The default sequence used by the tests: two exported entries, `mito` before `file`,
390    /// with one nested table to override.
391    fn defaults() -> JsonValue {
392        json!({
393            "region_engine": [
394                {
395                    "mito": {
396                        "global_write_buffer_size": "64MB",
397                        "index": {
398                            "aux_path": "/default/aux",
399                            "cache_percent": 30
400                        }
401                    }
402                },
403                {"file": {"data_dir": "/default/data"}}
404            ],
405            "data_home": "/default/data_home"
406        })
407    }
408
409    /// A fixed set of variables, so the tests never touch the process environment and can
410    /// run in parallel.
411    fn env(vars: &[(&str, &str)]) -> Environment {
412        let mut vars_map = Map::new();
413        for (key, value) in vars {
414            vars_map.insert((*key).to_string(), (*value).to_string());
415        }
416
417        Environment::default()
418            .separator("__")
419            .try_parsing(true)
420            .source(Some(vars_map))
421    }
422
423    fn collect_with(vars: &[(&str, &str)], defaults: &JsonValue) -> Map<String, Value> {
424        TaggedSeqEnvSource::new(env(vars), KEYS, defaults)
425            .collect()
426            .unwrap()
427    }
428
429    fn collect(vars: &[(&str, &str)]) -> Map<String, Value> {
430        collect_with(vars, &defaults())
431    }
432
433    fn to_json(collected: Map<String, Value>) -> JsonValue {
434        let mut map = serde_json::Map::new();
435        for (key, value) in collected {
436            map.insert(key, value.try_deserialize().unwrap());
437        }
438
439        JsonValue::Object(map)
440    }
441
442    fn collect_json(vars: &[(&str, &str)]) -> JsonValue {
443        to_json(collect(vars))
444    }
445
446    fn collect_json_with(vars: &[(&str, &str)], defaults: &JsonValue) -> JsonValue {
447        to_json(collect_with(vars, defaults))
448    }
449
450    fn collect_error_with(vars: &[(&str, &str)], defaults: &JsonValue) -> String {
451        match TaggedSeqEnvSource::new(env(vars), KEYS, defaults).collect() {
452            Ok(_) => panic!("expected an error, got {vars:?}"),
453            Err(ConfigError::Message(message)) => message,
454            Err(other) => panic!("expected a message error, got: {other}"),
455        }
456    }
457
458    fn collect_error(vars: &[(&str, &str)]) -> String {
459        collect_error_with(vars, &defaults())
460    }
461
462    #[test]
463    fn test_collect_overrides_a_scalar_field() {
464        let collected = collect_json(&[("REGION_ENGINE__MITO__GLOBAL_WRITE_BUFFER_SIZE", "128MB")]);
465
466        let engines = collected["region_engine"].as_array().unwrap();
467        assert_eq!(engines.len(), 2);
468        assert_eq!(engines[0]["mito"]["global_write_buffer_size"], "128MB");
469        // The fields next to the override keep their default value.
470        assert_eq!(engines[0]["mito"]["index"]["aux_path"], "/default/aux");
471        assert_eq!(engines[1]["file"]["data_dir"], "/default/data");
472    }
473
474    #[test]
475    fn test_collect_overrides_a_nested_field() {
476        let collected = collect_json(&[("REGION_ENGINE__MITO__INDEX__AUX_PATH", "/tmp/aux")]);
477
478        assert_eq!(
479            collected["region_engine"][0]["mito"]["index"]["aux_path"],
480            "/tmp/aux"
481        );
482        assert_eq!(
483            collected["region_engine"][0]["mito"]["index"]["cache_percent"],
484            30
485        );
486        assert_eq!(
487            collected["region_engine"][0]["mito"]["global_write_buffer_size"],
488            "64MB"
489        );
490    }
491
492    #[test]
493    fn test_collect_merges_several_variables_of_one_entry() {
494        let collected = collect_json(&[
495            ("REGION_ENGINE__MITO__INDEX__AUX_PATH", "/tmp/aux"),
496            ("REGION_ENGINE__MITO__INDEX__CACHE_PERCENT", "50"),
497        ]);
498
499        assert_eq!(
500            collected["region_engine"][0]["mito"]["index"]["aux_path"],
501            "/tmp/aux"
502        );
503        assert_eq!(
504            collected["region_engine"][0]["mito"]["index"]["cache_percent"],
505            50
506        );
507    }
508
509    #[test]
510    fn test_collect_appends_unknown_tags_in_tag_order() {
511        let collected = collect_json(&[
512            (
513                "REGION_ENGINE__METRIC__FLUSH_METADATA_REGION_INTERVAL",
514                "1m",
515            ),
516            ("REGION_ENGINE__AAA__DATA_DIR", "/tmp/aaa"),
517        ]);
518
519        let engines = collected["region_engine"].as_array().unwrap();
520        assert_eq!(engines.len(), 4);
521        // The default entries stay first and keep their order.
522        assert_eq!(engines[0]["mito"]["global_write_buffer_size"], "64MB");
523        assert_eq!(engines[1]["file"]["data_dir"], "/default/data");
524        // Entries that only the environment mentions are appended, sorted by tag.
525        assert_eq!(engines[2], json!({"aaa": {"data_dir": "/tmp/aaa"}}));
526        assert_eq!(
527            engines[3],
528            json!({"metric": {"flush_metadata_region_interval": "1m"}})
529        );
530    }
531
532    #[test]
533    fn test_collect_keeps_the_default_sequence_without_overrides() {
534        let collected = collect_json(&[("DATA_HOME", "/srv/data")]);
535
536        assert_eq!(collected["region_engine"], defaults()["region_engine"]);
537        assert_eq!(collected["data_home"], "/srv/data");
538    }
539
540    #[test]
541    fn test_collect_removes_the_flat_keys_of_the_sequence() {
542        let collected = collect(&[
543            ("REGION_ENGINE__MITO__INDEX__AUX_PATH", "/tmp/aux"),
544            ("REGION_ENGINE_EXTRA__MITO__AUX_PATH", "/tmp/other"),
545        ]);
546
547        // A leftover `region_engine.x` key would be written after the sequence and destroy
548        // it again, so all of them must be consumed by the merge.
549        assert!(
550            !collected
551                .keys()
552                .any(|key| key.starts_with("region_engine.")),
553            "flat keys left in the collected map: {:?}",
554            collected.keys().collect::<Vec<_>>()
555        );
556        // A key that only shares the prefix is not part of the sequence.
557        assert!(collected.contains_key("region_engine_extra.mito.aux_path"));
558    }
559
560    #[test]
561    fn test_collect_without_default_entries() {
562        let collected = collect_json_with(
563            &[
564                (
565                    "REGION_ENGINE__METRIC__FLUSH_METADATA_REGION_INTERVAL",
566                    "1m",
567                ),
568                ("REGION_ENGINE__FILE__DATA_DIR", "/tmp/data"),
569            ],
570            &json!({}),
571        );
572
573        assert_eq!(
574            collected["region_engine"],
575            json!([
576                {"file": {"data_dir": "/tmp/data"}},
577                {"metric": {"flush_metadata_region_interval": "1m"}}
578            ])
579        );
580    }
581
582    #[test]
583    fn test_collect_with_null_default_entries() {
584        let collected = collect_json_with(
585            &[("REGION_ENGINE__FILE__DATA_DIR", "/tmp/data")],
586            &json!({"region_engine": null}),
587        );
588
589        assert_eq!(
590            collected["region_engine"],
591            json!([{"file": {"data_dir": "/tmp/data"}}])
592        );
593    }
594
595    #[test]
596    fn test_collect_rejects_setting_the_whole_sequence() {
597        let message = collect_error(&[("REGION_ENGINE", "mito")]);
598
599        assert!(
600            message.contains("cannot be overridden as a whole"),
601            "{message}"
602        );
603        assert!(
604            message.contains("region_engine__<TAG>__<FIELD>"),
605            "{message}"
606        );
607    }
608
609    #[test]
610    fn test_collect_rejects_a_numeric_index() {
611        let message = collect_error(&[("REGION_ENGINE__0__MITO__INDEX__AUX_PATH", "/tmp/aux")]);
612
613        assert!(message.contains("numeric index"), "{message}");
614        assert!(message.contains("addressed by tag name"), "{message}");
615    }
616
617    #[test]
618    fn test_collect_rejects_a_tag_without_field() {
619        let message = collect_error(&[("REGION_ENGINE__MITO", "mito")]);
620
621        assert!(message.contains("needs a field"), "{message}");
622    }
623
624    #[test]
625    fn test_collect_rejects_conflicting_overrides() {
626        let message = collect_error(&[
627            ("REGION_ENGINE__MITO__INDEX", "not-a-table"),
628            ("REGION_ENGINE__MITO__INDEX__AUX_PATH", "/tmp/aux"),
629        ]);
630
631        assert!(
632            message.contains("another environment variable sets 'region_engine.mito.index'"),
633            "{message}"
634        );
635    }
636
637    #[test]
638    fn test_collect_rejects_a_field_below_a_scalar_default() {
639        let message = collect_error(&[("REGION_ENGINE__FILE__DATA_DIR__SUB", "/tmp/sub")]);
640
641        assert!(message.contains("region_engine.file.data_dir"), "{message}");
642        assert!(
643            message.contains("default value is not a table"),
644            "{message}"
645        );
646    }
647
648    #[test]
649    fn test_collect_rejects_a_scalar_default_sequence() {
650        let message = collect_error_with(
651            &[("REGION_ENGINE__MITO__INDEX__AUX_PATH", "/tmp/aux")],
652            &json!({"region_engine": "not-an-array"}),
653        );
654
655        assert!(
656            message.contains("not a sequence of single-key tables"),
657            "{message}"
658        );
659    }
660
661    #[test]
662    fn test_collect_rejects_a_default_entry_with_two_tags() {
663        let message = collect_error_with(
664            &[("REGION_ENGINE__MITO__INDEX__AUX_PATH", "/tmp/aux")],
665            &json!({"region_engine": [{"mito": {}, "file": {}}]}),
666        );
667
668        assert!(message.contains("exactly one tag"), "{message}");
669    }
670
671    #[test]
672    fn test_take_entries_keeps_unrelated_keys() {
673        let mut collected = Map::new();
674        for key in [
675            "region_engine",
676            "region_engine.mito.index",
677            "region_engine_extra.mito",
678            "other",
679        ] {
680            collected.insert(
681                (*key).to_string(),
682                Value::new(None, ValueKind::String("v".to_string())),
683            );
684        }
685
686        let entries = take_entries(&mut collected, "region_engine");
687
688        assert_eq!(
689            entries
690                .iter()
691                .map(|(key, _)| key.as_str())
692                .collect::<Vec<_>>(),
693            vec!["region_engine", "region_engine.mito.index"]
694        );
695        assert!(!collected.contains_key("region_engine"));
696        assert!(!collected.contains_key("region_engine.mito.index"));
697        assert!(collected.contains_key("region_engine_extra.mito"));
698        assert!(collected.contains_key("other"));
699    }
700
701    #[test]
702    fn test_is_index_like() {
703        for tag in ["0", "12", "-1", "+1", "1e3", "-1.5"] {
704            assert!(is_index_like(tag), "{tag} should look like an index");
705        }
706
707        for tag in ["mito", "file", "metric", "", "m1", "-", "+"] {
708            assert!(!is_index_like(tag), "{tag} should not look like an index");
709        }
710    }
711}