Skip to main content

prns_config/editing/
document.rs

1use std::fmt;
2use std::ops::Range;
3
4use crate::configobj::{ConfigDocument, ConfigError, Value};
5use crate::reference::keys::{interface as interface_key, section as section_key};
6use crate::{parse_and_plan_named, ConfigDiagnostic, ConfigErrors, InterfaceKind, SecretDisplay};
7
8use super::catalog::ConfiguredInterfaceSetting;
9use super::interface::{
10    render_bool, render_value, InterfaceConfigKey, InterfaceDefinition, InterfaceName,
11    InterfaceSetting, InterfaceSettingKey, RNodeMultiRadioDefinition,
12};
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct ConfiguredInterface {
16    name: InterfaceName,
17    configured_type: Option<String>,
18    kind: Option<InterfaceKind>,
19    enabled: Option<bool>,
20    settings: Vec<ConfiguredInterfaceSetting>,
21    rnode_multi_radios: Vec<RNodeMultiRadioDefinition>,
22}
23
24impl ConfiguredInterface {
25    pub fn name(&self) -> &InterfaceName {
26        &self.name
27    }
28
29    pub fn configured_type(&self) -> Option<&str> {
30        self.configured_type.as_deref()
31    }
32
33    pub const fn kind(&self) -> Option<InterfaceKind> {
34        self.kind
35    }
36
37    pub const fn enabled(&self) -> Option<bool> {
38        self.enabled
39    }
40
41    pub fn settings(&self) -> &[ConfiguredInterfaceSetting] {
42        &self.settings
43    }
44
45    pub fn rnode_multi_radios(&self) -> &[RNodeMultiRadioDefinition] {
46        &self.rnode_multi_radios
47    }
48}
49
50#[derive(Debug, Clone, PartialEq)]
51pub enum InterfaceSettingChange {
52    Set(InterfaceSetting),
53    Remove(InterfaceSettingKey),
54}
55
56#[derive(Debug, Clone, PartialEq)]
57pub enum ConfigEdit {
58    Add(InterfaceDefinition),
59    Replace {
60        current: InterfaceName,
61        replacement: InterfaceDefinition,
62    },
63    Rename {
64        current: InterfaceName,
65        replacement: InterfaceName,
66    },
67    SetEnabled {
68        name: InterfaceName,
69        enabled: bool,
70    },
71    SetType {
72        name: InterfaceName,
73        kind: InterfaceKind,
74    },
75    ChangeSettings {
76        name: InterfaceName,
77        changes: Vec<InterfaceSettingChange>,
78    },
79    ReplaceRNodeMultiRadios {
80        name: InterfaceName,
81        radios: Vec<RNodeMultiRadioDefinition>,
82    },
83    Remove(InterfaceName),
84    RemoveInterfaceValue {
85        name: InterfaceName,
86        key: InterfaceConfigKey,
87    },
88    Batch(Vec<ConfigEdit>),
89}
90
91#[derive(Debug, Clone, PartialEq)]
92pub struct EditedConfig {
93    original: String,
94    candidate: String,
95    warnings: Vec<ConfigDiagnostic>,
96}
97
98impl EditedConfig {
99    pub fn original(&self) -> &str {
100        &self.original
101    }
102
103    pub fn candidate(&self) -> &str {
104        &self.candidate
105    }
106
107    pub fn warnings(&self) -> &[ConfigDiagnostic] {
108        &self.warnings
109    }
110
111    pub fn diff(&self, secrets: SecretDisplay) -> String {
112        render_diff(&self.original, &self.candidate, secrets)
113    }
114}
115
116impl ConfigDocument {
117    pub fn interfaces(&self) -> Vec<ConfiguredInterface> {
118        self.child_section_names(&[section_key::INTERFACES])
119            .into_iter()
120            .filter_map(|name| {
121                let name = InterfaceName::new(name).ok()?;
122                let path = [section_key::INTERFACES, name.as_str()];
123                let configured_type = self
124                    .section_value(&path, interface_key::TYPE)
125                    .and_then(Value::as_scalar)
126                    .map(str::to_string);
127                let kind = configured_type.as_deref().and_then(InterfaceKind::parse);
128                let enabled = interface_enabled(self, &path);
129                let settings = kind
130                    .map(|kind| configured_settings(self, name.as_str(), kind))
131                    .unwrap_or_default();
132                let rnode_multi_radios = if kind == Some(InterfaceKind::RnodeMulti) {
133                    configured_radios(self, name.as_str())
134                } else {
135                    Vec::new()
136                };
137                Some(ConfiguredInterface {
138                    name,
139                    configured_type,
140                    kind,
141                    enabled,
142                    settings,
143                    rnode_multi_radios,
144                })
145            })
146            .collect()
147    }
148
149    pub fn edit(&self, edit: &ConfigEdit) -> Result<EditedConfig, ConfigEditError> {
150        self.edit_named("<edited config>", edit)
151    }
152
153    pub fn edit_named(
154        &self,
155        source_name: impl Into<String>,
156        edit: &ConfigEdit,
157    ) -> Result<EditedConfig, ConfigEditError> {
158        let candidate = mutate(self.source(), edit)?;
159        let report =
160            parse_and_plan_named(source_name, &candidate).map_err(ConfigEditError::Invalid)?;
161        Ok(EditedConfig {
162            original: self.source().to_string(),
163            candidate,
164            warnings: report.warnings,
165        })
166    }
167}
168
169#[derive(Debug)]
170pub enum ConfigEditError {
171    Syntax(ConfigError),
172    InterfaceNotFound(InterfaceName),
173    DuplicateInterface(InterfaceName),
174    Invalid(ConfigErrors),
175}
176
177impl fmt::Display for ConfigEditError {
178    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
179        match self {
180            Self::Syntax(error) => error.fmt(formatter),
181            Self::InterfaceNotFound(name) => write!(formatter, "interface {name} was not found"),
182            Self::DuplicateInterface(name) => {
183                write!(formatter, "interface {name} already exists")
184            }
185            Self::Invalid(errors) => errors.fmt(formatter),
186        }
187    }
188}
189
190impl std::error::Error for ConfigEditError {}
191
192fn mutate(source: &str, edit: &ConfigEdit) -> Result<String, ConfigEditError> {
193    match edit {
194        ConfigEdit::Batch(edits) => {
195            let mut candidate = source.to_string();
196            for edit in edits {
197                candidate = mutate(&candidate, edit)?;
198            }
199            Ok(candidate)
200        }
201        _ => mutate_one(
202            ConfigDocument::parse(source).map_err(ConfigEditError::Syntax)?,
203            edit,
204        ),
205    }
206}
207
208fn mutate_one(document: ConfigDocument, edit: &ConfigEdit) -> Result<String, ConfigEditError> {
209    match edit {
210        ConfigEdit::Add(definition) => add_interface(&document, definition),
211        ConfigEdit::Replace {
212            current,
213            replacement,
214        } => replace_interface(&document, current, replacement),
215        ConfigEdit::Rename {
216            current,
217            replacement,
218        } => rename_interface(&document, current, replacement),
219        ConfigEdit::SetEnabled { name, enabled } => set_enabled(&document, name, *enabled),
220        ConfigEdit::SetType { name, kind } => set_type(&document, name, *kind),
221        ConfigEdit::ChangeSettings { name, changes } => change_settings(&document, name, changes),
222        ConfigEdit::ReplaceRNodeMultiRadios { name, radios } => {
223            replace_rnode_multi_radios(&document, name, radios)
224        }
225        ConfigEdit::Remove(name) => remove_interface(&document, name),
226        ConfigEdit::RemoveInterfaceValue { name, key } => {
227            remove_interface_value(&document, name, key)
228        }
229        ConfigEdit::Batch(edits) => {
230            let mut candidate = document.source().to_string();
231            for edit in edits {
232                candidate = mutate(&candidate, edit)?;
233            }
234            Ok(candidate)
235        }
236    }
237}
238
239fn add_interface(
240    document: &ConfigDocument,
241    definition: &InterfaceDefinition,
242) -> Result<String, ConfigEditError> {
243    ensure_interface_absent(document, definition.name())?;
244    let newline = document.newline();
245    let rendered = definition.render(newline);
246    let Some(interfaces) = document.section_range(&[section_key::INTERFACES]) else {
247        let mut candidate = document.source().to_string();
248        ensure_trailing_newline(&mut candidate, newline);
249        candidate.push_str(&format!("[{}]{newline}{rendered}", section_key::INTERFACES));
250        return Ok(candidate);
251    };
252    let insertion = interfaces.end;
253    let mut block = String::new();
254    if insertion > 0 && !document.source()[..insertion].ends_with(['\n', '\r']) {
255        block.push_str(newline);
256    }
257    block.push_str(&rendered);
258    Ok(replace_range(
259        document.source(),
260        insertion..insertion,
261        &block,
262    ))
263}
264
265fn replace_interface(
266    document: &ConfigDocument,
267    current: &InterfaceName,
268    replacement: &InterfaceDefinition,
269) -> Result<String, ConfigEditError> {
270    let range = interface_range(document, current)?;
271    if current != replacement.name() {
272        ensure_interface_absent(document, replacement.name())?;
273    }
274    Ok(replace_range(
275        document.source(),
276        range,
277        &replacement.render(document.newline()),
278    ))
279}
280
281fn rename_interface(
282    document: &ConfigDocument,
283    current: &InterfaceName,
284    replacement: &InterfaceName,
285) -> Result<String, ConfigEditError> {
286    ensure_interface_absent(document, replacement)?;
287    let header = document
288        .section_header_range(&[section_key::INTERFACES, current.as_str()])
289        .ok_or_else(|| ConfigEditError::InterfaceNotFound(current.clone()))?;
290    let original = &document.source()[header.clone()];
291    let indent = original.len() - original.trim_start().len();
292    let suffix = section_header_suffix(original, 2);
293    let rendered = format!("{}[[{}]]{}", &original[..indent], replacement, suffix,);
294    Ok(replace_range(document.source(), header, &rendered))
295}
296
297fn set_enabled(
298    document: &ConfigDocument,
299    name: &InterfaceName,
300    enabled: bool,
301) -> Result<String, ConfigEditError> {
302    interface_range(document, name)?;
303    let explicit_path = [
304        section_key::INTERFACES,
305        name.as_str(),
306        interface_key::INTERFACE_ENABLED,
307    ];
308    let shorthand_path = [
309        section_key::INTERFACES,
310        name.as_str(),
311        interface_key::ENABLED,
312    ];
313    let mut replacements = Vec::new();
314    match document.key_range(&explicit_path) {
315        Some(range) => replacements.push((
316            range.clone(),
317            render_key_line(
318                document.source(),
319                range,
320                interface_key::INTERFACE_ENABLED,
321                render_bool(enabled),
322                document.newline(),
323            ),
324        )),
325        None => {
326            let insertion = key_insertion(document, name)?;
327            replacements.push((
328                insertion..insertion,
329                format!(
330                    "    {} = {}{}",
331                    interface_key::INTERFACE_ENABLED,
332                    render_bool(enabled),
333                    document.newline(),
334                ),
335            ));
336        }
337    }
338    if let Some(range) = document.key_range(&shorthand_path) {
339        replacements.push((range, String::new()));
340    }
341    Ok(apply_replacements(document.source(), replacements))
342}
343
344fn set_type(
345    document: &ConfigDocument,
346    name: &InterfaceName,
347    kind: InterfaceKind,
348) -> Result<String, ConfigEditError> {
349    interface_range(document, name)?;
350    let path = [section_key::INTERFACES, name.as_str(), interface_key::TYPE];
351    let replacement = match document.key_range(&path) {
352        Some(range) => {
353            let line = render_key_line(
354                document.source(),
355                range.clone(),
356                interface_key::TYPE,
357                kind.canonical_name(),
358                document.newline(),
359            );
360            replace_range(document.source(), range, &line)
361        }
362        None => {
363            let insertion = key_insertion(document, name)?;
364            replace_range(
365                document.source(),
366                insertion..insertion,
367                &format!(
368                    "    {} = {}{}",
369                    interface_key::TYPE,
370                    kind.canonical_name(),
371                    document.newline()
372                ),
373            )
374        }
375    };
376    Ok(replacement)
377}
378
379fn change_settings(
380    document: &ConfigDocument,
381    name: &InterfaceName,
382    changes: &[InterfaceSettingChange],
383) -> Result<String, ConfigEditError> {
384    interface_range(document, name)?;
385    let mut candidate = document.source().to_string();
386    for change in changes {
387        let parsed = ConfigDocument::parse(&candidate).map_err(ConfigEditError::Syntax)?;
388        match change {
389            InterfaceSettingChange::Set(setting) => {
390                let key = setting.key().canonical();
391                candidate = remove_aliases(&parsed, name, key, false);
392                let parsed = ConfigDocument::parse(&candidate).map_err(ConfigEditError::Syntax)?;
393                let path = [section_key::INTERFACES, name.as_str(), key.as_str()];
394                let value = render_value(setting.value());
395                candidate = match parsed.key_range(&path) {
396                    Some(range) => {
397                        let line = render_key_line(
398                            parsed.source(),
399                            range.clone(),
400                            key.as_str(),
401                            &value,
402                            parsed.newline(),
403                        );
404                        replace_range(parsed.source(), range, &line)
405                    }
406                    None => {
407                        let insertion = key_insertion(&parsed, name)?;
408                        replace_range(
409                            parsed.source(),
410                            insertion..insertion,
411                            &format!("    {} = {}{}", key.as_str(), value, parsed.newline()),
412                        )
413                    }
414                };
415            }
416            InterfaceSettingChange::Remove(key) => {
417                candidate = remove_aliases(&parsed, name, key.canonical(), true);
418            }
419        }
420    }
421    Ok(candidate)
422}
423
424fn remove_aliases(
425    document: &ConfigDocument,
426    name: &InterfaceName,
427    key: InterfaceSettingKey,
428    include_canonical: bool,
429) -> String {
430    let aliases = key.aliases();
431    let keys = if aliases.is_empty() {
432        vec![key.as_str()]
433    } else {
434        aliases.to_vec()
435    };
436    let replacements = keys
437        .into_iter()
438        .filter(|candidate| include_canonical || *candidate != key.as_str())
439        .filter_map(|candidate| {
440            document
441                .key_range(&[section_key::INTERFACES, name.as_str(), candidate])
442                .map(|range| (range, String::new()))
443        })
444        .collect();
445    apply_replacements(document.source(), replacements)
446}
447
448fn remove_interface_value(
449    document: &ConfigDocument,
450    name: &InterfaceName,
451    key: &InterfaceConfigKey,
452) -> Result<String, ConfigEditError> {
453    interface_range(document, name)?;
454    let path = [section_key::INTERFACES, name.as_str(), key.as_str()];
455    Ok(match document.key_range(&path) {
456        Some(range) => replace_range(document.source(), range, ""),
457        None => document.source().to_string(),
458    })
459}
460
461fn configured_settings(
462    document: &ConfigDocument,
463    name: &str,
464    kind: InterfaceKind,
465) -> Vec<ConfiguredInterfaceSetting> {
466    let Some(section) = document
467        .root()
468        .section(section_key::INTERFACES)
469        .and_then(|interfaces| interfaces.section(name))
470    else {
471        return Vec::new();
472    };
473    kind.setting_specs()
474        .into_iter()
475        .filter_map(|spec| {
476            let key = spec.key();
477            let aliases = key.aliases();
478            let keys = if aliases.is_empty() {
479                vec![key.as_str()]
480            } else {
481                aliases.to_vec()
482            };
483            keys.into_iter().find_map(|source_key| {
484                let value = section.get(source_key)?;
485                let value = match value {
486                    Value::Scalar(value) => value.clone(),
487                    Value::List(values) => values.join(", "),
488                };
489                Some(ConfiguredInterfaceSetting::new(
490                    spec,
491                    source_key.to_string(),
492                    value,
493                ))
494            })
495        })
496        .collect()
497}
498
499fn configured_radios(document: &ConfigDocument, name: &str) -> Vec<RNodeMultiRadioDefinition> {
500    let Some(section) = document
501        .root()
502        .section(section_key::INTERFACES)
503        .and_then(|interfaces| interfaces.section(name))
504    else {
505        return Vec::new();
506    };
507    section
508        .sections
509        .iter()
510        .filter_map(|(name, radio)| {
511            let scalar = |key: &str| radio.get(key).and_then(Value::as_scalar);
512            let enabled = scalar(interface_key::INTERFACE_ENABLED)
513                .or_else(|| scalar(interface_key::ENABLED))
514                .and_then(|value| match value.trim().to_ascii_lowercase().as_str() {
515                    "yes" | "true" | "on" | "1" => Some(true),
516                    "no" | "false" | "off" | "0" => Some(false),
517                    _ => None,
518                })
519                .unwrap_or(true);
520            if !enabled {
521                return None;
522            }
523            RNodeMultiRadioDefinition::new(
524                InterfaceName::new(name.clone()).ok()?,
525                scalar(interface_key::VPORT)?.parse().ok()?,
526                scalar(interface_key::FREQUENCY)?.parse().ok()?,
527                scalar(interface_key::BANDWIDTH)?.parse().ok()?,
528                scalar(interface_key::TXPOWER)?.parse().ok()?,
529                scalar(interface_key::SPREADINGFACTOR)?.parse().ok()?,
530                scalar(interface_key::CODINGRATE)?.parse().ok()?,
531            )
532            .ok()
533        })
534        .collect()
535}
536
537fn remove_interface(
538    document: &ConfigDocument,
539    name: &InterfaceName,
540) -> Result<String, ConfigEditError> {
541    let range = interface_range(document, name)?;
542    Ok(replace_range(document.source(), range, ""))
543}
544
545fn replace_rnode_multi_radios(
546    document: &ConfigDocument,
547    name: &InterfaceName,
548    radios: &[RNodeMultiRadioDefinition],
549) -> Result<String, ConfigEditError> {
550    let parent_path = [section_key::INTERFACES, name.as_str()];
551    let parent = document
552        .section_range(&parent_path)
553        .ok_or_else(|| ConfigEditError::InterfaceNotFound(name.clone()))?;
554    let child_ranges = document
555        .child_section_names(&parent_path)
556        .into_iter()
557        .filter_map(|child| {
558            document.section_range(&[section_key::INTERFACES, name.as_str(), child])
559        })
560        .collect::<Vec<_>>();
561    let replacement = child_ranges
562        .first()
563        .zip(child_ranges.last())
564        .map_or(parent.end..parent.end, |(first, last)| {
565            first.start..last.end
566        });
567    let rendered = radios
568        .iter()
569        .map(|radio| radio.render(document.newline()))
570        .collect::<String>();
571    Ok(replace_range(document.source(), replacement, &rendered))
572}
573
574fn interface_range(
575    document: &ConfigDocument,
576    name: &InterfaceName,
577) -> Result<Range<usize>, ConfigEditError> {
578    document
579        .section_range(&[section_key::INTERFACES, name.as_str()])
580        .ok_or_else(|| ConfigEditError::InterfaceNotFound(name.clone()))
581}
582
583fn ensure_interface_absent(
584    document: &ConfigDocument,
585    name: &InterfaceName,
586) -> Result<(), ConfigEditError> {
587    if document
588        .section_range(&[section_key::INTERFACES, name.as_str()])
589        .is_some()
590    {
591        return Err(ConfigEditError::DuplicateInterface(name.clone()));
592    }
593    Ok(())
594}
595
596fn key_insertion(
597    document: &ConfigDocument,
598    name: &InterfaceName,
599) -> Result<usize, ConfigEditError> {
600    let path = [section_key::INTERFACES, name.as_str()];
601    let section = document
602        .section_range(&path)
603        .ok_or_else(|| ConfigEditError::InterfaceNotFound(name.clone()))?;
604    Ok(document
605        .first_child_section_start(&path)
606        .unwrap_or(section.end))
607}
608
609fn replace_range(source: &str, range: Range<usize>, replacement: &str) -> String {
610    let mut candidate = String::with_capacity(source.len() + replacement.len());
611    candidate.push_str(&source[..range.start]);
612    candidate.push_str(replacement);
613    candidate.push_str(&source[range.end..]);
614    candidate
615}
616
617fn apply_replacements(source: &str, mut replacements: Vec<(Range<usize>, String)>) -> String {
618    replacements.sort_by_key(|replacement| std::cmp::Reverse(replacement.0.start));
619    let mut candidate = source.to_string();
620    for (range, replacement) in replacements {
621        candidate.replace_range(range, &replacement);
622    }
623    candidate
624}
625
626fn render_key_line(
627    source: &str,
628    range: Range<usize>,
629    key: &str,
630    value: &str,
631    newline: &str,
632) -> String {
633    let line = &source[range];
634    let indent_end = line.len() - line.trim_start().len();
635    let suffix = inline_comment(line).unwrap_or_default();
636    format!(
637        "{}{} = {}{}{}",
638        &line[..indent_end],
639        key,
640        value,
641        suffix,
642        newline
643    )
644}
645
646fn inline_comment(line: &str) -> Option<&str> {
647    let mut quote = None;
648    for (index, character) in line.char_indices() {
649        match (quote, character) {
650            (None, '\'' | '"') => quote = Some(character),
651            (Some(open), close) if open == close => quote = None,
652            (None, '#') => {
653                let start = line[..index]
654                    .char_indices()
655                    .rev()
656                    .take_while(|(_, character)| character.is_whitespace())
657                    .last()
658                    .map_or(index, |(position, _)| position);
659                return Some(line[start..].trim_end_matches(['\r', '\n']));
660            }
661            _ => {}
662        }
663    }
664    None
665}
666
667fn section_header_suffix(header: &str, depth: usize) -> &str {
668    let close = "]".repeat(depth);
669    header
670        .find(&close)
671        .map(|position| &header[position + depth..])
672        .unwrap_or_default()
673}
674
675fn interface_enabled(document: &ConfigDocument, path: &[&str]) -> Option<bool> {
676    [interface_key::INTERFACE_ENABLED, interface_key::ENABLED]
677        .into_iter()
678        .filter_map(|key| document.section_value(path, key))
679        .filter_map(Value::as_scalar)
680        .filter_map(parse_bool)
681        .reduce(|left, right| left || right)
682}
683
684fn parse_bool(value: &str) -> Option<bool> {
685    match value.trim().to_ascii_lowercase().as_str() {
686        "yes" | "true" | "on" | "1" => Some(true),
687        "no" | "false" | "off" | "0" => Some(false),
688        _ => None,
689    }
690}
691
692fn ensure_trailing_newline(source: &mut String, newline: &str) {
693    if !source.is_empty() && !source.ends_with(['\n', '\r']) {
694        source.push_str(newline);
695    }
696}
697
698fn render_diff(original: &str, candidate: &str, secrets: SecretDisplay) -> String {
699    if original == candidate {
700        return String::new();
701    }
702    let before = original.lines().collect::<Vec<_>>();
703    let after = candidate.lines().collect::<Vec<_>>();
704    let prefix = before
705        .iter()
706        .zip(&after)
707        .take_while(|(left, right)| left == right)
708        .count();
709    let suffix = before[prefix..]
710        .iter()
711        .rev()
712        .zip(after[prefix..].iter().rev())
713        .take_while(|(left, right)| left == right)
714        .count();
715    let before_end = before.len().saturating_sub(suffix);
716    let after_end = after.len().saturating_sub(suffix);
717    let mut rendered = String::from("--- config\n+++ config\n");
718    for line in display_lines(&before[prefix..before_end], secrets) {
719        rendered.push('-');
720        rendered.push_str(&line);
721        rendered.push('\n');
722    }
723    for line in display_lines(&after[prefix..after_end], secrets) {
724        rendered.push('+');
725        rendered.push_str(&line);
726        rendered.push('\n');
727    }
728    rendered
729}
730
731fn display_lines(lines: &[&str], secrets: SecretDisplay) -> Vec<String> {
732    if secrets == SecretDisplay::Revealed {
733        return lines.iter().map(|line| (*line).to_string()).collect();
734    }
735    let mut multiline_secret = None;
736    let mut displayed = Vec::with_capacity(lines.len());
737    for line in lines {
738        if let Some(delimiter) = multiline_secret {
739            displayed.push("<redacted>".to_string());
740            if line.contains(delimiter) {
741                multiline_secret = None;
742            }
743            continue;
744        }
745        let Some((key, value)) = line.split_once('=') else {
746            displayed.push((*line).to_string());
747            continue;
748        };
749        if !matches!(key.trim(), "pass_phrase" | "passphrase" | "rpc_key") {
750            displayed.push((*line).to_string());
751            continue;
752        }
753        let value = value.trim_start();
754        multiline_secret = ["\"\"\"", "'''"].into_iter().find(|delimiter| {
755            value.starts_with(delimiter) && value.matches(delimiter).count() == 1
756        });
757        displayed.push(format!("{} = <redacted>", key.trim_end()));
758    }
759    displayed
760}