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" => {
201                    self.tui.update(val, current_path, errors);
202                    check_tui_border_type(&mut self.tui, current_path, errors);
203                }
204                "color_config" => self.color_config.update(val, current_path, errors),
205                "clip" => self.clip.update(val, current_path, errors),
206                "footer_mode" => self.footer_mode.update(val, current_path, errors),
207                "float_precision" => self.float_precision.update(val, current_path, errors),
208                "use_ansi_coloring" => self.use_ansi_coloring.update(val, current_path, errors),
209                "edit_mode" => self.edit_mode.update(val, current_path, errors),
210                "show_hints" => self.show_hints.update(val, current_path, errors),
211                "hinter" => self.hinter.update(val, current_path, errors),
212                "shell_integration" => self.shell_integration.update(val, current_path, errors),
213                "show_banner" => self.show_banner.update(val, current_path, errors),
214                "display_errors" => self.display_errors.update(val, current_path, errors),
215                "render_right_prompt_on_last_line" => {
216                    self.render_right_prompt_on_last_line
217                        .update(val, current_path, errors)
218                }
219                "bracketed_paste" => self.bracketed_paste.update(val, current_path, errors),
220                "use_kitty_protocol" => self.use_kitty_protocol.update(val, current_path, errors),
221                "highlight_resolved_externals" => {
222                    self.highlight_resolved_externals
223                        .update(val, current_path, errors)
224                }
225                "auto_cd_implicit" => self.auto_cd_implicit.update(val, current_path, errors),
226                "duration_max_unit" => self.duration_max_unit.update(val, current_path, errors),
227                "plugins" => self.plugins.update(val, current_path, errors),
228                "plugin_gc" => self.plugin_gc.update(val, current_path, errors),
229                "abbreviations" => self.abbreviations.update(val, current_path, errors),
230                "hooks" => self.hooks.update(val, current_path, errors),
231                "datetime_format" => self.datetime_format.update(val, current_path, errors),
232                "error_style" => self.error_style.update(val, current_path, errors),
233
234                "buffer_editor" => match val {
235                    Value::Nothing { .. } | Value::String { .. } => {
236                        self.buffer_editor = val.clone();
237                    }
238                    Value::List { vals: values, .. }
239                        if values
240                            .iter()
241                            .all(|list_element| matches!(list_element, Value::String { .. })) =>
242                    {
243                        self.buffer_editor = val.clone();
244                    }
245                    _ => errors.type_mismatch(
246                        current_path,
247                        Type::custom("string, list<string>, or nothing"),
248                        val,
249                    ),
250                },
251
252                "max_last_result_size" => {
253                    self.max_last_result_size.update(val, current_path, errors)
254                }
255
256                "menus" => match Vec::<ParsedMenu>::from_value(val.clone()) {
257                    Ok(menus) => {
258                        for menu in menus {
259                            let target_name = menu.name.to_expanded_string("", self);
260
261                            let found_index = self.menus.iter().position(|existing_menu| {
262                                existing_menu.name.to_expanded_string("", self) == target_name
263                            });
264
265                            if let Some(index) = found_index {
266                                self.menus[index] = menu;
267                            } else {
268                                self.menus.push(menu);
269                            }
270                        }
271                    }
272                    Err(error) => errors.error(error.into()),
273                },
274
275                "keybindings" => match Vec::<ParsedKeybinding>::from_value(val.clone()) {
276                    Ok(keybindings) => self.merge_keybindings(keybindings, val.span(), errors),
277                    Err(error) => errors.error(error.into()),
278                },
279
280                "error_lines" => match val.as_int() {
281                    Ok(integer) if integer >= 0 => self.error_lines = integer,
282                    Ok(_) => {
283                        errors.invalid_value(current_path, "an int greater than or equal to 0", val)
284                    }
285                    Err(_) => errors.type_mismatch(current_path, Type::Int, val),
286                },
287
288                "recursion_limit" => match val.as_int() {
289                    Ok(integer) if integer > 1 => self.recursion_limit = integer,
290                    Ok(_) => errors.invalid_value(current_path, "an int greater than 1", val),
291                    Err(_) => errors.type_mismatch(current_path, Type::Int, val),
292                },
293
294                _ => errors.unknown_option(current_path, val),
295            }
296        }
297    }
298}
299
300/// `$env.config.tui` is a free-form map of colors, except `border_type`,
301/// which must name a tui border (see [`TableMode::from_tui_border`]). A bad
302/// value is reported here, where it is set, and the previous one is kept,
303/// like a typed field that fails to update.
304fn check_tui_border_type(
305    tui: &mut HashMap<String, Value>,
306    path: &mut ConfigPath,
307    errors: &mut ConfigErrors,
308) {
309    const KEY: &str = "border_type";
310    let Some(value) = tui.get(KEY) else {
311        return;
312    };
313    let path = &mut path.push(KEY);
314    match value.as_str() {
315        Ok(name) if TableMode::from_tui_border(name).is_some() => return,
316        Ok(_) => {
317            let names = TableMode::tui_border_names().collect::<Vec<_>>().join(", ");
318            errors.invalid_value(path, format!("one of {names}"), value);
319        }
320        Err(_) => errors.type_mismatch(path, Type::String, value),
321    }
322    match errors.config().tui.get(KEY).cloned() {
323        Some(old) => tui.insert(KEY.into(), old),
324        None => tui.remove(KEY),
325    };
326}
327
328impl UpdateFromValue for Filesize {
329    fn update(&mut self, value: &Value, path: &mut ConfigPath, errors: &mut ConfigErrors) {
330        match value.as_filesize() {
331            Ok(size) if !size.is_negative() => *self = size,
332            Ok(_) => errors.invalid_value(path, "a non-negative filesize", value),
333            Err(_) => errors.type_mismatch(path, Type::Filesize, value),
334        }
335    }
336}
337
338impl Config {
339    /// Returns the configured last-result size budget in bytes (`0` disables `.last` only).
340    pub fn max_last_result_size_bytes(&self) -> usize {
341        self.max_last_result_size.get().max(0) as usize
342    }
343
344    pub fn update_from_value(
345        &mut self,
346        old: &Config,
347        value: &Value,
348    ) -> Result<Option<ShellWarning>, ShellError> {
349        self.update_from_value_with_options(old, value, false)
350    }
351
352    /// Like [`Config::update_from_value`], but allows callers to indicate that runtime-locked
353    /// options should refuse to change.
354    ///
355    /// `history_locked_after_startup` should be set to `true` once the REPL has finished
356    /// initializing reedline's history backend. After that point, changing any of the
357    /// startup-only history fields (`path`, `max_size`, `file_format`, `isolation`) has no
358    /// effect on the live history, so we reject the assignment with a clear error instead of
359    /// silently ignoring it.
360    pub fn update_from_value_with_options(
361        &mut self,
362        old: &Config,
363        value: &Value,
364        history_locked_after_startup: bool,
365    ) -> Result<Option<ShellWarning>, ShellError> {
366        // Current behaviour is that config errors are displayed, but do not prevent the rest
367        // of the config from being updated (fields with errors are skipped/not updated).
368        // Errors are simply collected one-by-one and wrapped into a ShellError variant at the end.
369        let mut errors =
370            ConfigErrors::new(old).with_history_locked_after_startup(history_locked_after_startup);
371        let mut path = ConfigPath::new();
372
373        self.update(value, &mut path, &mut errors);
374
375        errors.check()
376    }
377
378    fn merge_keybindings(
379        &mut self,
380        incoming: Vec<ParsedKeybinding>,
381        span: Span,
382        errors: &mut ConfigErrors,
383    ) {
384        if incoming.is_empty() {
385            self.keybindings.clear();
386            return;
387        }
388
389        let mut shared_names = BTreeSet::new();
390        let identities = incoming.into_iter().map(|kb| {
391            let name = name_of(&kb);
392            let id = KeyIdentity::of(&kb);
393            (kb, name, id)
394        });
395
396        // Snapshot of existing identities, kept in lockstep with the list.
397        // `claimed` marks entries already spoken for by this assignment, so a
398        // second incoming binding with the same name starts a new entry
399        // instead of re-keying its sibling.
400        struct Existing {
401            name: Option<String>,
402            id: KeyIdentity,
403            claimed: bool,
404        }
405        let mut ex_kbs: Vec<Existing> = self
406            .keybindings
407            .iter()
408            .map(|ex| Existing {
409                name: name_of(ex),
410                id: KeyIdentity::of(ex),
411                claimed: false,
412            })
413            .collect();
414
415        for (kb, name, id) in identities {
416            // The same binding (name and key): replace, claimed or not, so a
417            // re-sourced config stays idempotent and event updates land.
418            if let Some(i) = ex_kbs.iter().position(|ex| ex.name == name && ex.id == id) {
419                ex_kbs[i].claimed = true;
420                self.keybindings[i] = kb;
421                continue;
422            }
423
424            // A named binding with a new key re-keys the unclaimed entry of
425            // that name in place, keeping its position in the list.
426            if name.is_some()
427                && let Some(i) = ex_kbs.iter().position(|ex| ex.name == name && !ex.claimed)
428            {
429                ex_kbs[i].id = id;
430                ex_kbs[i].claimed = true;
431                self.keybindings[i] = kb;
432                continue;
433            }
434
435            // A new binding. If its name is already taken (necessarily by a
436            // claimed entry), both stay active; say so once.
437            if let Some(name) = &name
438                && ex_kbs.iter().any(|ex| ex.name.as_ref() == Some(name))
439            {
440                shared_names.insert(name.clone());
441            }
442            ex_kbs.push(Existing {
443                name,
444                id,
445                claimed: true,
446            });
447            self.keybindings.push(kb);
448        }
449        if !shared_names.is_empty() {
450            errors.warn(ConfigWarning::SharedKeybindingName {
451                names: shared_names.into_iter().collect::<Vec<_>>().join(", "),
452                span,
453            });
454        }
455    }
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461
462    /// A record-valued config field is a full-record replace on assignment (e.g.
463    /// `$env.config.keybindings = [...]`), but `update_from_value` must still merge
464    /// named defaults into place rather than silently dropping ones the caller
465    /// didn't mention.
466    #[test]
467    fn reassigning_a_named_list_field_keeps_unmentioned_defaults() {
468        let old = Config::default();
469        let mut new = old.clone();
470
471        let mut extra_menu = old.menus[0].clone();
472        extra_menu.name = Value::test_string("added_menu");
473        let mut extra_keybinding = old.keybindings[0].clone();
474        extra_keybinding.name = Some(Value::test_string("added_binding"));
475
476        let value = Value::test_record(record! {
477            "menus" => Value::test_list(vec![extra_menu.into_value(Span::test_data())]),
478            "keybindings" => Value::test_list(vec![extra_keybinding.into_value(Span::test_data())]),
479        });
480        new.update_from_value(&old, &value)
481            .expect("update should succeed");
482
483        for default_menu in &old.menus {
484            let name = default_menu.name.to_expanded_string("", &old);
485            assert!(
486                new.menus
487                    .iter()
488                    .any(|m| m.name.to_expanded_string("", &new) == name),
489                "default menu {name:?} was lost after reassigning `menus`"
490            );
491        }
492        for default_keybinding in &old.keybindings {
493            let Some(name) = default_keybinding
494                .name
495                .as_ref()
496                .map(|n| n.to_expanded_string("", &old))
497            else {
498                continue;
499            };
500            assert!(
501                new.keybindings.iter().any(|k| k
502                    .name
503                    .as_ref()
504                    .is_some_and(|n| n.to_expanded_string("", &new) == name)),
505                "default keybinding {name:?} was lost after reassigning `keybindings`"
506            );
507        }
508    }
509
510    /// Guards the unnamed case: with no `name` to merge on, every reassignment
511    /// used to append another copy.
512    #[test]
513    fn reassigning_an_unnamed_keybinding_does_not_duplicate_it() {
514        let old = Config::default();
515        let mut new = old.clone();
516
517        let mut unnamed = old.keybindings[0].clone();
518        unnamed.name = None;
519        unnamed.modifier = Value::test_string("alt");
520        unnamed.keycode = Value::test_string("char_j");
521        new.keybindings.push(unnamed);
522
523        let expected = new.keybindings.len();
524
525        // Feed the list back through `update_from_value` the way re-sourcing a
526        // config (or any `$env.config.keybindings = ...`) does.
527        for _ in 0..2 {
528            let value = Value::test_record(record! {
529                "keybindings" => Value::test_list(
530                    new.keybindings
531                        .iter()
532                        .map(|keybinding| keybinding.clone().into_value(Span::test_data()))
533                        .collect(),
534                ),
535            });
536            new.update_from_value(&old, &value)
537                .expect("update should succeed");
538        }
539
540        assert_eq!(
541            new.keybindings.len(),
542            expected,
543            "reassigning `keybindings` duplicated the unnamed binding"
544        );
545    }
546
547    /// `tui.border_type` is checked where it is set: a name that is not a tui
548    /// border is an error and the previous border stays.
549    #[test]
550    fn tui_border_type_rejects_non_borders_and_keeps_the_old_one() {
551        let old = Config::default();
552        let tui = |border_type: Value| {
553            Value::test_record(record! {
554                "tui" => Value::test_record(record! { "border_type" => border_type }),
555            })
556        };
557
558        let mut new = old.clone();
559        new.update_from_value(&old, &tui(Value::test_string("Double")))
560            .expect("a table theme name is a border");
561        assert_eq!(
562            new.tui.get("border_type"),
563            Some(&Value::test_string("Double"))
564        );
565
566        for bad in [
567            Value::test_string("none"),
568            Value::test_string("default"),
569            Value::test_string("wavy"),
570            Value::test_int(1),
571        ] {
572            let mut new = old.clone();
573            let err = new
574                .update_from_value(&old, &tui(bad.clone()))
575                .expect_err("not a tui border");
576            assert!(
577                format!("{err:?}").contains("$env.config.tui.border_type"),
578                "{bad:?}: {err:?}"
579            );
580            assert_eq!(new.tui.get("border_type"), old.tui.get("border_type"));
581        }
582    }
583
584    /// `TableMode::NAMES` feeds `table --theme` and `tui --border`
585    /// completions, so it must hold every name `from_str` accepts: the ones
586    /// its error lists, plus `default`.
587    #[test]
588    fn table_mode_names_match_from_str() {
589        for name in TableMode::NAMES {
590            assert!(name.parse::<TableMode>().is_ok(), "{name}");
591        }
592        let listed = "wavy".parse::<TableMode>().expect_err("not a table mode");
593        let listed: Vec<&str> = listed.split('\'').skip(1).step_by(2).collect();
594        for name in &listed {
595            assert!(TableMode::NAMES.contains(name), "{name} is not in NAMES");
596        }
597        assert_eq!(listed.len() + 1, TableMode::NAMES.len());
598    }
599
600    // --- merge semantics: replace on same name+key, append+warn on shared name ---
601
602    fn keybinding(
603        name: Option<&str>,
604        modifier: &str,
605        keycode: &str,
606        mode: Value,
607    ) -> ParsedKeybinding {
608        ParsedKeybinding {
609            name: name.map(Value::test_string),
610            modifier: Value::test_string(modifier),
611            keycode: Value::test_string(keycode),
612            event: Value::test_nothing(),
613            mode,
614        }
615    }
616
617    /// Run one `$env.config.keybindings = [...]` assignment; returns the warning.
618    fn assign(
619        config: &mut Config,
620        old: &Config,
621        keybindings: Vec<ParsedKeybinding>,
622    ) -> Option<ShellWarning> {
623        let value = Value::test_record(record! {
624            "keybindings" => Value::test_list(
625                keybindings
626                    .into_iter()
627                    .map(|kb| kb.into_value(Span::test_data()))
628                    .collect(),
629            ),
630        });
631        config
632            .update_from_value(old, &value)
633            .expect("update should succeed")
634    }
635
636    fn count_named(config: &Config, name: &str) -> usize {
637        config
638            .keybindings
639            .iter()
640            .filter(|kb| {
641                kb.name
642                    .as_ref()
643                    .is_some_and(|n| n.to_expanded_string("", config) == name)
644            })
645            .count()
646    }
647
648    /// The atuin regression (nushell/nushell#18848): two bindings sharing a name
649    /// on different keys must both survive, with one warning.
650    #[test]
651    fn a_shared_name_on_different_keys_keeps_both_bindings_and_warns() {
652        let old = Config::default();
653        let mut new = old.clone();
654
655        let warning = assign(
656            &mut new,
657            &old,
658            vec![
659                keybinding(
660                    Some("atuin"),
661                    "control",
662                    "char_r",
663                    Value::test_string("emacs"),
664                ),
665                keybinding(Some("atuin"), "none", "up", Value::test_string("emacs")),
666            ],
667        );
668
669        assert_eq!(
670            count_named(&new, "atuin"),
671            2,
672            "one of the bindings was dropped"
673        );
674        assert!(warning.is_some(), "sharing a name should warn");
675    }
676
677    /// Re-sourcing the exact same binding is idempotent and silent.
678    #[test]
679    fn reassigning_the_same_binding_replaces_it_without_warning() {
680        let old = Config::default();
681        let mut new = old.clone();
682
683        let atuin = || {
684            keybinding(
685                Some("atuin"),
686                "control",
687                "char_r",
688                Value::test_string("emacs"),
689            )
690        };
691        assign(&mut new, &old, vec![atuin()]);
692        let len = new.keybindings.len();
693
694        let warning = assign(&mut new, &old, vec![atuin()]);
695        assert_eq!(
696            new.keybindings.len(),
697            len,
698            "re-sourcing duplicated the binding"
699        );
700        assert!(
701            warning.is_none(),
702            "an identical re-assignment must not warn"
703        );
704    }
705
706    /// Same name and key with a new event is the update case: replaced in place.
707    #[test]
708    fn a_new_event_on_the_same_key_replaces_the_binding() {
709        let old = Config::default();
710        let mut new = old.clone();
711
712        assign(
713            &mut new,
714            &old,
715            vec![keybinding(
716                Some("atuin"),
717                "control",
718                "char_r",
719                Value::test_string("emacs"),
720            )],
721        );
722        let len = new.keybindings.len();
723
724        let mut updated = keybinding(
725            Some("atuin"),
726            "control",
727            "char_r",
728            Value::test_string("emacs"),
729        );
730        updated.event = Value::test_string("marker");
731        assign(&mut new, &old, vec![updated]);
732
733        assert_eq!(new.keybindings.len(), len);
734        let event = new
735            .keybindings
736            .iter()
737            .rev()
738            .find(|kb| {
739                kb.name
740                    .as_ref()
741                    .is_some_and(|n| n.to_expanded_string("", &new) == "atuin")
742            })
743            .map(|kb| kb.event.clone());
744        assert_eq!(
745            event,
746            Some(Value::test_string("marker")),
747            "event was not updated"
748        );
749    }
750
751    /// `emacs` and `[emacs]` spell the same key, so the second assignment replaces.
752    #[test]
753    fn a_bare_mode_and_its_singleton_list_merge_into_one_binding() {
754        let old = Config::default();
755        let mut new = old.clone();
756
757        assign(
758            &mut new,
759            &old,
760            vec![keybinding(
761                Some("atuin"),
762                "control",
763                "char_r",
764                Value::test_string("emacs"),
765            )],
766        );
767        let warning = assign(
768            &mut new,
769            &old,
770            vec![keybinding(
771                Some("atuin"),
772                "control",
773                "char_r",
774                Value::test_list(vec![Value::test_string("emacs")]),
775            )],
776        );
777
778        assert_eq!(
779            count_named(&new, "atuin"),
780            1,
781            "the mode spellings did not merge"
782        );
783        assert!(warning.is_none());
784    }
785
786    /// The same new binding twice in one assignment collapses to one entry
787    /// (guards the identity snapshot staying in sync with the list).
788    #[test]
789    fn the_same_binding_twice_in_one_assignment_is_stored_once() {
790        let old = Config::default();
791        let mut new = old.clone();
792
793        let atuin = || {
794            keybinding(
795                Some("atuin"),
796                "control",
797                "char_r",
798                Value::test_string("emacs"),
799            )
800        };
801        assign(&mut new, &old, vec![atuin(), atuin()]);
802
803        assert_eq!(count_named(&new, "atuin"), 1, "the duplicate was appended");
804    }
805
806    /// Re-keying by name: assigning a named binding with a new key replaces the
807    /// existing binding of that name in place, keeping its list position
808    /// (`$env.config.keybindings.0.keycode = ...` depends on this).
809    #[test]
810    fn a_named_binding_with_a_new_key_replaces_in_place() {
811        let old = Config::default();
812        let mut new = old.clone();
813
814        assign(
815            &mut new,
816            &old,
817            vec![keybinding(
818                Some("atuin"),
819                "control",
820                "char_r",
821                Value::test_string("emacs"),
822            )],
823        );
824        let len = new.keybindings.len();
825        let index = new
826            .keybindings
827            .iter()
828            .position(|kb| {
829                kb.name
830                    .as_ref()
831                    .is_some_and(|n| n.to_expanded_string("", &new) == "atuin")
832            })
833            .expect("binding was added");
834
835        let warning = assign(
836            &mut new,
837            &old,
838            vec![keybinding(
839                Some("atuin"),
840                "none",
841                "up",
842                Value::test_string("emacs"),
843            )],
844        );
845
846        assert_eq!(new.keybindings.len(), len, "re-keying must not append");
847        assert_eq!(
848            new.keybindings[index].keycode,
849            Value::test_string("up"),
850            "the binding was not re-keyed in place"
851        );
852        assert!(warning.is_none(), "re-keying a lone name must not warn");
853    }
854
855    /// A changed mode set is a re-key too, not a sibling binding.
856    #[test]
857    fn a_named_binding_with_a_changed_mode_replaces_instead_of_appending() {
858        let old = Config::default();
859        let mut new = old.clone();
860
861        assign(
862            &mut new,
863            &old,
864            vec![keybinding(
865                Some("atuin"),
866                "control",
867                "char_r",
868                Value::test_string("emacs"),
869            )],
870        );
871        let warning = assign(
872            &mut new,
873            &old,
874            vec![keybinding(
875                Some("atuin"),
876                "control",
877                "char_r",
878                Value::test_list(vec![
879                    Value::test_string("vi_normal"),
880                    Value::test_string("vi_insert"),
881                ]),
882            )],
883        );
884
885        assert_eq!(count_named(&new, "atuin"), 1, "the mode change appended");
886        assert!(warning.is_none());
887    }
888
889    /// Assigning an empty list is the reset escape hatch.
890    #[test]
891    fn assigning_an_empty_list_clears_the_keybindings() {
892        let old = Config::default();
893        let mut new = old.clone();
894
895        assign(&mut new, &old, vec![]);
896        assert!(new.keybindings.is_empty(), "`= []` should reset the list");
897    }
898}