Skip to main content

nu_protocol/config/
mod.rs

1//! Module containing the internal representation of user configuration
2
3use crate::config::reedline::{KeyIdentity, name_of};
4use crate::{self as nu_protocol, Filesize};
5use crate::{ConfigWarning, FromValue};
6use helper::*;
7use prelude::*;
8use std::collections::{BTreeSet, HashMap};
9
10pub use ansi_coloring::UseAnsiColoring;
11pub use clip::ClipConfig;
12pub use completions::{
13    CompletionAlgorithm, CompletionConfig, CompletionSort, ExternalCompleterConfig,
14};
15pub use datetime_format::DatetimeFormatConfig;
16pub use defaults::default_color_config;
17pub use display_errors::DisplayErrors;
18pub use duration_max_unit::DurationMaxUnit;
19pub use filesize::FilesizeConfig;
20pub use helper::extract_value;
21pub use hinter::HinterConfig;
22pub use history::{HistoryConfig, HistoryFileFormat, HistoryPath};
23pub use hooks::Hooks;
24pub use ls::LsConfig;
25pub use output::{BannerKind, ErrorStyle};
26pub use plugin_gc::{PluginGcConfig, PluginGcConfigs};
27pub use reedline::{CursorShapeConfig, EditBindings, NuCursorShape, ParsedKeybinding, ParsedMenu};
28pub use rm::RmConfig;
29pub use shell_integration::ShellIntegrationConfig;
30pub use table::{FooterMode, TableConfig, TableIndent, TableIndexMode, TableMode, TrimStrategy};
31
32mod ansi_coloring;
33mod clip;
34mod completions;
35mod datetime_format;
36mod defaults;
37mod display_errors;
38mod duration_max_unit;
39mod error;
40mod filesize;
41mod helper;
42mod hinter;
43mod history;
44mod hooks;
45mod ls;
46mod output;
47mod plugin_gc;
48mod prelude;
49mod reedline;
50mod rm;
51mod shell_integration;
52mod table;
53
54#[derive(Clone, Debug, IntoValue, Serialize, Deserialize)]
55pub struct Config {
56    pub filesize: FilesizeConfig,
57    pub table: TableConfig,
58    pub ls: LsConfig,
59    pub clip: ClipConfig,
60    pub color_config: HashMap<String, Value>,
61    pub footer_mode: FooterMode,
62    pub float_precision: i64,
63    pub recursion_limit: i64,
64    pub use_ansi_coloring: UseAnsiColoring,
65    pub completions: CompletionConfig,
66    pub edit_mode: EditBindings,
67    pub show_hints: bool,
68    pub hinter: HinterConfig,
69    pub history: HistoryConfig,
70    pub keybindings: Vec<ParsedKeybinding>,
71    pub abbreviations: HashMap<String, String>,
72    pub menus: Vec<ParsedMenu>,
73    pub hooks: Hooks,
74    pub rm: RmConfig,
75    pub shell_integration: ShellIntegrationConfig,
76    pub buffer_editor: Value,
77    pub show_banner: BannerKind,
78    pub bracketed_paste: bool,
79    pub render_right_prompt_on_last_line: bool,
80    pub explore: HashMap<String, Value>,
81    /// Styles for the `tui` command family (`$env.config.tui`).
82    pub tui: HashMap<String, Value>,
83    pub cursor_shape: CursorShapeConfig,
84    pub datetime_format: DatetimeFormatConfig,
85    pub error_style: ErrorStyle,
86    pub error_lines: i64,
87    pub display_errors: DisplayErrors,
88    pub use_kitty_protocol: bool,
89    pub highlight_resolved_externals: bool,
90    pub auto_cd_implicit: bool,
91    pub duration_max_unit: DurationMaxUnit,
92    /// Maximum estimated memory size of the interactive last-result payload (`$ans.last`).
93    ///
94    /// Measured with [`Value::memory_size`]. Default is `0` (no `.last` payload; opt-in).
95    /// Oversized results are truncated to fit this budget. The variable name itself is a code
96    /// constant (`LAST_RESULT_VAR_NAME`), not a config option. With a positive budget, `$ans`
97    /// is `{ last, exit_code, duration, command }`. With `0`, `$ans` still has `exit_code`,
98    /// `duration`, and `command` but omits `last` entirely.
99    pub max_last_result_size: Filesize,
100    /// Configuration for plugins.
101    ///
102    /// Users can provide configuration for a plugin through this entry.  The entry name must
103    /// match the registered plugin name so `plugin add nu_plugin_example` will be able to place
104    /// its configuration under a `nu_plugin_example` column.
105    pub plugins: HashMap<String, Value>,
106    /// Configuration for plugin garbage collection.
107    pub plugin_gc: PluginGcConfigs,
108}
109
110impl Default for Config {
111    fn default() -> Config {
112        Config {
113            show_banner: BannerKind::default(),
114
115            table: TableConfig::default(),
116            rm: RmConfig::default(),
117            ls: LsConfig::default(),
118
119            datetime_format: DatetimeFormatConfig::default(),
120
121            explore: defaults::default_explore(),
122            tui: defaults::default_tui(),
123
124            history: HistoryConfig::default(),
125
126            completions: CompletionConfig::default(),
127
128            recursion_limit: 50,
129
130            filesize: FilesizeConfig::default(),
131
132            cursor_shape: CursorShapeConfig::default(),
133
134            clip: ClipConfig::default(),
135
136            color_config: defaults::default_color_config(),
137            footer_mode: FooterMode::RowCount(25),
138            float_precision: 2,
139            buffer_editor: Value::nothing(Span::unknown()),
140            use_ansi_coloring: UseAnsiColoring::default(),
141            bracketed_paste: true,
142            edit_mode: EditBindings::default(),
143            show_hints: true,
144            hinter: HinterConfig::default(),
145
146            shell_integration: ShellIntegrationConfig::default(),
147
148            render_right_prompt_on_last_line: false,
149
150            hooks: Hooks::new(),
151
152            menus: defaults::default_menus(),
153
154            keybindings: defaults::default_keybindings(),
155            abbreviations: HashMap::new(),
156
157            error_style: ErrorStyle::default(),
158            error_lines: 1,
159            display_errors: DisplayErrors::default(),
160
161            use_kitty_protocol: false,
162            highlight_resolved_externals: false,
163
164            auto_cd_implicit: false,
165            duration_max_unit: DurationMaxUnit::default(),
166
167            // Opt-in for `.last` payload: 0 drops last, keeps exit_code/duration/command.
168            max_last_result_size: Filesize::ZERO,
169
170            plugins: HashMap::new(),
171            plugin_gc: PluginGcConfigs::default(),
172        }
173    }
174}
175
176impl UpdateFromValue for Config {
177    fn update<'a>(
178        &mut self,
179        value: &'a Value,
180        path: &mut ConfigPath<'a>,
181        errors: &mut ConfigErrors,
182    ) {
183        let Value::Record { val: record, .. } = value else {
184            errors.type_mismatch(path, Type::record(), value);
185            return;
186        };
187
188        for (col, val) in record.iter() {
189            let current_path = &mut path.push(col);
190
191            match col.as_str() {
192                "ls" => self.ls.update(val, current_path, errors),
193                "rm" => self.rm.update(val, current_path, errors),
194                "history" => self.history.update(val, current_path, errors),
195                "completions" => self.completions.update(val, current_path, errors),
196                "cursor_shape" => self.cursor_shape.update(val, current_path, errors),
197                "table" => self.table.update(val, current_path, errors),
198                "filesize" => self.filesize.update(val, current_path, errors),
199                "explore" => self.explore.update(val, current_path, errors),
200                "tui" => self.tui.update(val, current_path, errors),
201                "color_config" => self.color_config.update(val, current_path, errors),
202                "clip" => self.clip.update(val, current_path, errors),
203                "footer_mode" => self.footer_mode.update(val, current_path, errors),
204                "float_precision" => self.float_precision.update(val, current_path, errors),
205                "use_ansi_coloring" => self.use_ansi_coloring.update(val, current_path, errors),
206                "edit_mode" => self.edit_mode.update(val, current_path, errors),
207                "show_hints" => self.show_hints.update(val, current_path, errors),
208                "hinter" => self.hinter.update(val, current_path, errors),
209                "shell_integration" => self.shell_integration.update(val, current_path, errors),
210                "show_banner" => self.show_banner.update(val, current_path, errors),
211                "display_errors" => self.display_errors.update(val, current_path, errors),
212                "render_right_prompt_on_last_line" => {
213                    self.render_right_prompt_on_last_line
214                        .update(val, current_path, errors)
215                }
216                "bracketed_paste" => self.bracketed_paste.update(val, current_path, errors),
217                "use_kitty_protocol" => self.use_kitty_protocol.update(val, current_path, errors),
218                "highlight_resolved_externals" => {
219                    self.highlight_resolved_externals
220                        .update(val, current_path, errors)
221                }
222                "auto_cd_implicit" => self.auto_cd_implicit.update(val, current_path, errors),
223                "duration_max_unit" => self.duration_max_unit.update(val, current_path, errors),
224                "plugins" => self.plugins.update(val, current_path, errors),
225                "plugin_gc" => self.plugin_gc.update(val, current_path, errors),
226                "abbreviations" => self.abbreviations.update(val, current_path, errors),
227                "hooks" => self.hooks.update(val, current_path, errors),
228                "datetime_format" => self.datetime_format.update(val, current_path, errors),
229                "error_style" => self.error_style.update(val, current_path, errors),
230
231                "buffer_editor" => match val {
232                    Value::Nothing { .. } | Value::String { .. } => {
233                        self.buffer_editor = val.clone();
234                    }
235                    Value::List { vals: values, .. }
236                        if values
237                            .iter()
238                            .all(|list_element| matches!(list_element, Value::String { .. })) =>
239                    {
240                        self.buffer_editor = val.clone();
241                    }
242                    _ => errors.type_mismatch(
243                        current_path,
244                        Type::custom("string, list<string>, or nothing"),
245                        val,
246                    ),
247                },
248
249                "max_last_result_size" => {
250                    self.max_last_result_size.update(val, current_path, errors)
251                }
252
253                "menus" => match Vec::<ParsedMenu>::from_value(val.clone()) {
254                    Ok(menus) => {
255                        for menu in menus {
256                            let target_name = menu.name.to_expanded_string("", self);
257
258                            let found_index = self.menus.iter().position(|existing_menu| {
259                                existing_menu.name.to_expanded_string("", self) == target_name
260                            });
261
262                            if let Some(index) = found_index {
263                                self.menus[index] = menu;
264                            } else {
265                                self.menus.push(menu);
266                            }
267                        }
268                    }
269                    Err(error) => errors.error(error.into()),
270                },
271
272                "keybindings" => match Vec::<ParsedKeybinding>::from_value(val.clone()) {
273                    Ok(keybindings) => self.merge_keybindings(keybindings, val.span(), errors),
274                    Err(error) => errors.error(error.into()),
275                },
276
277                "error_lines" => match val.as_int() {
278                    Ok(integer) if integer >= 0 => self.error_lines = integer,
279                    Ok(_) => {
280                        errors.invalid_value(current_path, "an int greater than or equal to 0", val)
281                    }
282                    Err(_) => errors.type_mismatch(current_path, Type::Int, val),
283                },
284
285                "recursion_limit" => match val.as_int() {
286                    Ok(integer) if integer > 1 => self.recursion_limit = integer,
287                    Ok(_) => errors.invalid_value(current_path, "an int greater than 1", val),
288                    Err(_) => errors.type_mismatch(current_path, Type::Int, val),
289                },
290
291                _ => errors.unknown_option(current_path, val),
292            }
293        }
294    }
295}
296
297impl UpdateFromValue for Filesize {
298    fn update(&mut self, value: &Value, path: &mut ConfigPath, errors: &mut ConfigErrors) {
299        match value.as_filesize() {
300            Ok(size) if !size.is_negative() => *self = size,
301            Ok(_) => errors.invalid_value(path, "a non-negative filesize", value),
302            Err(_) => errors.type_mismatch(path, Type::Filesize, value),
303        }
304    }
305}
306
307impl Config {
308    /// Returns the configured last-result size budget in bytes (`0` disables `.last` only).
309    pub fn max_last_result_size_bytes(&self) -> usize {
310        self.max_last_result_size.get().max(0) as usize
311    }
312
313    pub fn update_from_value(
314        &mut self,
315        old: &Config,
316        value: &Value,
317    ) -> Result<Option<ShellWarning>, ShellError> {
318        self.update_from_value_with_options(old, value, false)
319    }
320
321    /// Like [`Config::update_from_value`], but allows callers to indicate that runtime-locked
322    /// options should refuse to change.
323    ///
324    /// `history_locked_after_startup` should be set to `true` once the REPL has finished
325    /// initializing reedline's history backend. After that point, changing any of the
326    /// startup-only history fields (`path`, `max_size`, `file_format`, `isolation`) has no
327    /// effect on the live history, so we reject the assignment with a clear error instead of
328    /// silently ignoring it.
329    pub fn update_from_value_with_options(
330        &mut self,
331        old: &Config,
332        value: &Value,
333        history_locked_after_startup: bool,
334    ) -> Result<Option<ShellWarning>, ShellError> {
335        // Current behaviour is that config errors are displayed, but do not prevent the rest
336        // of the config from being updated (fields with errors are skipped/not updated).
337        // Errors are simply collected one-by-one and wrapped into a ShellError variant at the end.
338        let mut errors =
339            ConfigErrors::new(old).with_history_locked_after_startup(history_locked_after_startup);
340        let mut path = ConfigPath::new();
341
342        self.update(value, &mut path, &mut errors);
343
344        errors.check()
345    }
346
347    fn merge_keybindings(
348        &mut self,
349        incoming: Vec<ParsedKeybinding>,
350        span: Span,
351        errors: &mut ConfigErrors,
352    ) {
353        if incoming.is_empty() {
354            self.keybindings.clear();
355            return;
356        }
357
358        let mut shared_names = BTreeSet::new();
359        let identities = incoming.into_iter().map(|kb| {
360            let name = name_of(&kb);
361            let id = KeyIdentity::of(&kb);
362            (kb, name, id)
363        });
364
365        // Snapshot of existing identities, kept in lockstep with the list.
366        // `claimed` marks entries already spoken for by this assignment, so a
367        // second incoming binding with the same name starts a new entry
368        // instead of re-keying its sibling.
369        struct Existing {
370            name: Option<String>,
371            id: KeyIdentity,
372            claimed: bool,
373        }
374        let mut ex_kbs: Vec<Existing> = self
375            .keybindings
376            .iter()
377            .map(|ex| Existing {
378                name: name_of(ex),
379                id: KeyIdentity::of(ex),
380                claimed: false,
381            })
382            .collect();
383
384        for (kb, name, id) in identities {
385            // The same binding (name and key): replace, claimed or not, so a
386            // re-sourced config stays idempotent and event updates land.
387            if let Some(i) = ex_kbs.iter().position(|ex| ex.name == name && ex.id == id) {
388                ex_kbs[i].claimed = true;
389                self.keybindings[i] = kb;
390                continue;
391            }
392
393            // A named binding with a new key re-keys the unclaimed entry of
394            // that name in place, keeping its position in the list.
395            if name.is_some()
396                && let Some(i) = ex_kbs.iter().position(|ex| ex.name == name && !ex.claimed)
397            {
398                ex_kbs[i].id = id;
399                ex_kbs[i].claimed = true;
400                self.keybindings[i] = kb;
401                continue;
402            }
403
404            // A new binding. If its name is already taken (necessarily by a
405            // claimed entry), both stay active; say so once.
406            if let Some(name) = &name
407                && ex_kbs.iter().any(|ex| ex.name.as_ref() == Some(name))
408            {
409                shared_names.insert(name.clone());
410            }
411            ex_kbs.push(Existing {
412                name,
413                id,
414                claimed: true,
415            });
416            self.keybindings.push(kb);
417        }
418        if !shared_names.is_empty() {
419            errors.warn(ConfigWarning::SharedKeybindingName {
420                names: shared_names.into_iter().collect::<Vec<_>>().join(", "),
421                span,
422            });
423        }
424    }
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430
431    /// A record-valued config field is a full-record replace on assignment (e.g.
432    /// `$env.config.keybindings = [...]`), but `update_from_value` must still merge
433    /// named defaults into place rather than silently dropping ones the caller
434    /// didn't mention.
435    #[test]
436    fn reassigning_a_named_list_field_keeps_unmentioned_defaults() {
437        let old = Config::default();
438        let mut new = old.clone();
439
440        let mut extra_menu = old.menus[0].clone();
441        extra_menu.name = Value::test_string("added_menu");
442        let mut extra_keybinding = old.keybindings[0].clone();
443        extra_keybinding.name = Some(Value::test_string("added_binding"));
444
445        let value = Value::test_record(record! {
446            "menus" => Value::test_list(vec![extra_menu.into_value(Span::test_data())]),
447            "keybindings" => Value::test_list(vec![extra_keybinding.into_value(Span::test_data())]),
448        });
449        new.update_from_value(&old, &value)
450            .expect("update should succeed");
451
452        for default_menu in &old.menus {
453            let name = default_menu.name.to_expanded_string("", &old);
454            assert!(
455                new.menus
456                    .iter()
457                    .any(|m| m.name.to_expanded_string("", &new) == name),
458                "default menu {name:?} was lost after reassigning `menus`"
459            );
460        }
461        for default_keybinding in &old.keybindings {
462            let Some(name) = default_keybinding
463                .name
464                .as_ref()
465                .map(|n| n.to_expanded_string("", &old))
466            else {
467                continue;
468            };
469            assert!(
470                new.keybindings.iter().any(|k| k
471                    .name
472                    .as_ref()
473                    .is_some_and(|n| n.to_expanded_string("", &new) == name)),
474                "default keybinding {name:?} was lost after reassigning `keybindings`"
475            );
476        }
477    }
478
479    /// Guards the unnamed case: with no `name` to merge on, every reassignment
480    /// used to append another copy.
481    #[test]
482    fn reassigning_an_unnamed_keybinding_does_not_duplicate_it() {
483        let old = Config::default();
484        let mut new = old.clone();
485
486        let mut unnamed = old.keybindings[0].clone();
487        unnamed.name = None;
488        unnamed.modifier = Value::test_string("alt");
489        unnamed.keycode = Value::test_string("char_j");
490        new.keybindings.push(unnamed);
491
492        let expected = new.keybindings.len();
493
494        // Feed the list back through `update_from_value` the way re-sourcing a
495        // config (or any `$env.config.keybindings = ...`) does.
496        for _ in 0..2 {
497            let value = Value::test_record(record! {
498                "keybindings" => Value::test_list(
499                    new.keybindings
500                        .iter()
501                        .map(|keybinding| keybinding.clone().into_value(Span::test_data()))
502                        .collect(),
503                ),
504            });
505            new.update_from_value(&old, &value)
506                .expect("update should succeed");
507        }
508
509        assert_eq!(
510            new.keybindings.len(),
511            expected,
512            "reassigning `keybindings` duplicated the unnamed binding"
513        );
514    }
515
516    // --- merge semantics: replace on same name+key, append+warn on shared name ---
517
518    fn keybinding(
519        name: Option<&str>,
520        modifier: &str,
521        keycode: &str,
522        mode: Value,
523    ) -> ParsedKeybinding {
524        ParsedKeybinding {
525            name: name.map(Value::test_string),
526            modifier: Value::test_string(modifier),
527            keycode: Value::test_string(keycode),
528            event: Value::test_nothing(),
529            mode,
530        }
531    }
532
533    /// Run one `$env.config.keybindings = [...]` assignment; returns the warning.
534    fn assign(
535        config: &mut Config,
536        old: &Config,
537        keybindings: Vec<ParsedKeybinding>,
538    ) -> Option<ShellWarning> {
539        let value = Value::test_record(record! {
540            "keybindings" => Value::test_list(
541                keybindings
542                    .into_iter()
543                    .map(|kb| kb.into_value(Span::test_data()))
544                    .collect(),
545            ),
546        });
547        config
548            .update_from_value(old, &value)
549            .expect("update should succeed")
550    }
551
552    fn count_named(config: &Config, name: &str) -> usize {
553        config
554            .keybindings
555            .iter()
556            .filter(|kb| {
557                kb.name
558                    .as_ref()
559                    .is_some_and(|n| n.to_expanded_string("", config) == name)
560            })
561            .count()
562    }
563
564    /// The atuin regression (nushell/nushell#18848): two bindings sharing a name
565    /// on different keys must both survive, with one warning.
566    #[test]
567    fn a_shared_name_on_different_keys_keeps_both_bindings_and_warns() {
568        let old = Config::default();
569        let mut new = old.clone();
570
571        let warning = assign(
572            &mut new,
573            &old,
574            vec![
575                keybinding(
576                    Some("atuin"),
577                    "control",
578                    "char_r",
579                    Value::test_string("emacs"),
580                ),
581                keybinding(Some("atuin"), "none", "up", Value::test_string("emacs")),
582            ],
583        );
584
585        assert_eq!(
586            count_named(&new, "atuin"),
587            2,
588            "one of the bindings was dropped"
589        );
590        assert!(warning.is_some(), "sharing a name should warn");
591    }
592
593    /// Re-sourcing the exact same binding is idempotent and silent.
594    #[test]
595    fn reassigning_the_same_binding_replaces_it_without_warning() {
596        let old = Config::default();
597        let mut new = old.clone();
598
599        let atuin = || {
600            keybinding(
601                Some("atuin"),
602                "control",
603                "char_r",
604                Value::test_string("emacs"),
605            )
606        };
607        assign(&mut new, &old, vec![atuin()]);
608        let len = new.keybindings.len();
609
610        let warning = assign(&mut new, &old, vec![atuin()]);
611        assert_eq!(
612            new.keybindings.len(),
613            len,
614            "re-sourcing duplicated the binding"
615        );
616        assert!(
617            warning.is_none(),
618            "an identical re-assignment must not warn"
619        );
620    }
621
622    /// Same name and key with a new event is the update case: replaced in place.
623    #[test]
624    fn a_new_event_on_the_same_key_replaces_the_binding() {
625        let old = Config::default();
626        let mut new = old.clone();
627
628        assign(
629            &mut new,
630            &old,
631            vec![keybinding(
632                Some("atuin"),
633                "control",
634                "char_r",
635                Value::test_string("emacs"),
636            )],
637        );
638        let len = new.keybindings.len();
639
640        let mut updated = keybinding(
641            Some("atuin"),
642            "control",
643            "char_r",
644            Value::test_string("emacs"),
645        );
646        updated.event = Value::test_string("marker");
647        assign(&mut new, &old, vec![updated]);
648
649        assert_eq!(new.keybindings.len(), len);
650        let event = new
651            .keybindings
652            .iter()
653            .rev()
654            .find(|kb| {
655                kb.name
656                    .as_ref()
657                    .is_some_and(|n| n.to_expanded_string("", &new) == "atuin")
658            })
659            .map(|kb| kb.event.clone());
660        assert_eq!(
661            event,
662            Some(Value::test_string("marker")),
663            "event was not updated"
664        );
665    }
666
667    /// `emacs` and `[emacs]` spell the same key, so the second assignment replaces.
668    #[test]
669    fn a_bare_mode_and_its_singleton_list_merge_into_one_binding() {
670        let old = Config::default();
671        let mut new = old.clone();
672
673        assign(
674            &mut new,
675            &old,
676            vec![keybinding(
677                Some("atuin"),
678                "control",
679                "char_r",
680                Value::test_string("emacs"),
681            )],
682        );
683        let warning = assign(
684            &mut new,
685            &old,
686            vec![keybinding(
687                Some("atuin"),
688                "control",
689                "char_r",
690                Value::test_list(vec![Value::test_string("emacs")]),
691            )],
692        );
693
694        assert_eq!(
695            count_named(&new, "atuin"),
696            1,
697            "the mode spellings did not merge"
698        );
699        assert!(warning.is_none());
700    }
701
702    /// The same new binding twice in one assignment collapses to one entry
703    /// (guards the identity snapshot staying in sync with the list).
704    #[test]
705    fn the_same_binding_twice_in_one_assignment_is_stored_once() {
706        let old = Config::default();
707        let mut new = old.clone();
708
709        let atuin = || {
710            keybinding(
711                Some("atuin"),
712                "control",
713                "char_r",
714                Value::test_string("emacs"),
715            )
716        };
717        assign(&mut new, &old, vec![atuin(), atuin()]);
718
719        assert_eq!(count_named(&new, "atuin"), 1, "the duplicate was appended");
720    }
721
722    /// Re-keying by name: assigning a named binding with a new key replaces the
723    /// existing binding of that name in place, keeping its list position
724    /// (`$env.config.keybindings.0.keycode = ...` depends on this).
725    #[test]
726    fn a_named_binding_with_a_new_key_replaces_in_place() {
727        let old = Config::default();
728        let mut new = old.clone();
729
730        assign(
731            &mut new,
732            &old,
733            vec![keybinding(
734                Some("atuin"),
735                "control",
736                "char_r",
737                Value::test_string("emacs"),
738            )],
739        );
740        let len = new.keybindings.len();
741        let index = new
742            .keybindings
743            .iter()
744            .position(|kb| {
745                kb.name
746                    .as_ref()
747                    .is_some_and(|n| n.to_expanded_string("", &new) == "atuin")
748            })
749            .expect("binding was added");
750
751        let warning = assign(
752            &mut new,
753            &old,
754            vec![keybinding(
755                Some("atuin"),
756                "none",
757                "up",
758                Value::test_string("emacs"),
759            )],
760        );
761
762        assert_eq!(new.keybindings.len(), len, "re-keying must not append");
763        assert_eq!(
764            new.keybindings[index].keycode,
765            Value::test_string("up"),
766            "the binding was not re-keyed in place"
767        );
768        assert!(warning.is_none(), "re-keying a lone name must not warn");
769    }
770
771    /// A changed mode set is a re-key too, not a sibling binding.
772    #[test]
773    fn a_named_binding_with_a_changed_mode_replaces_instead_of_appending() {
774        let old = Config::default();
775        let mut new = old.clone();
776
777        assign(
778            &mut new,
779            &old,
780            vec![keybinding(
781                Some("atuin"),
782                "control",
783                "char_r",
784                Value::test_string("emacs"),
785            )],
786        );
787        let warning = assign(
788            &mut new,
789            &old,
790            vec![keybinding(
791                Some("atuin"),
792                "control",
793                "char_r",
794                Value::test_list(vec![
795                    Value::test_string("vi_normal"),
796                    Value::test_string("vi_insert"),
797                ]),
798            )],
799        );
800
801        assert_eq!(count_named(&new, "atuin"), 1, "the mode change appended");
802        assert!(warning.is_none());
803    }
804
805    /// Assigning an empty list is the reset escape hatch.
806    #[test]
807    fn assigning_an_empty_list_clears_the_keybindings() {
808        let old = Config::default();
809        let mut new = old.clone();
810
811        assign(&mut new, &old, vec![]);
812        assert!(new.keybindings.is_empty(), "`= []` should reset the list");
813    }
814}