1use crate::command::Registry;
12use crate::key::KeyParseError;
13use crate::keymap::Keymap;
14use crate::mode::Mode;
15
16#[derive(Debug, thiserror::Error, PartialEq, Eq)]
17pub enum ConfigError {
18 #[error("{file} is not valid TOML: {message}")]
19 Syntax { file: &'static str, message: String },
20 #[error("{file}: `{path}` should be {expected}")]
21 Type {
22 file: &'static str,
23 path: String,
24 expected: &'static str,
25 },
26 #[error("keys.toml: `{0}` is not a mode (expected normal, insert, visual, v-line or command)")]
27 UnknownMode(String),
28 #[error("keys.toml: `{chord}` is bound to `{command}`, which is not a command")]
29 UnknownCommand { chord: String, command: String },
30 #[error("keys.toml: `{chord}` is not a key sequence: {source}")]
31 BadChord {
32 chord: String,
33 #[source]
34 source: KeyParseError,
35 },
36 #[error("views.toml: view `{name}` has key `{key}`; keys must be a single digit 1-9")]
37 BadViewKey { name: String, key: String },
38 #[error("views.toml: two views claim key `{0}`")]
39 DuplicateViewKey(char),
40 #[error("config.toml: `{path}` is `{value}`, which is not a colour ({expected})")]
41 BadAccent {
42 path: String,
43 value: String,
44 expected: &'static str,
45 },
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct SavedView {
55 pub key: char,
58 pub name: String,
59 pub query: String,
60}
61
62pub fn parse_views(src: &str) -> Result<Vec<SavedView>, ConfigError> {
71 const FILE: &str = "views.toml";
72 let table: toml::Table = toml::from_str(src).map_err(|e| ConfigError::Syntax {
73 file: FILE,
74 message: e.message().to_string(),
75 })?;
76
77 let Some(raw) = table.get("view") else {
78 return Ok(Vec::new());
79 };
80 let entries = raw.as_array().ok_or(ConfigError::Type {
81 file: FILE,
82 path: "view".into(),
83 expected: "an array of [[view]] tables",
84 })?;
85
86 let mut views: Vec<SavedView> = Vec::new();
87 for (i, entry) in entries.iter().enumerate() {
88 let t = entry.as_table().ok_or_else(|| ConfigError::Type {
89 file: FILE,
90 path: format!("view[{i}]"),
91 expected: "a table",
92 })?;
93 let field = |name: &str| -> Result<String, ConfigError> {
94 t.get(name)
95 .and_then(|v| v.as_str())
96 .map(str::to_string)
97 .ok_or_else(|| ConfigError::Type {
98 file: FILE,
99 path: format!("view[{i}].{name}"),
100 expected: "a string",
101 })
102 };
103
104 let name = field("name")?;
105 let key = field("key")?;
106 let mut chars = key.chars();
107 let key = match (chars.next(), chars.next()) {
108 (Some(c @ '1'..='9'), None) => c,
109 _ => return Err(ConfigError::BadViewKey { name, key }),
110 };
111 if views.iter().any(|v| v.key == key) {
112 return Err(ConfigError::DuplicateViewKey(key));
113 }
114 views.push(SavedView {
115 key,
116 name,
117 query: field("query")?,
118 });
119 }
120
121 views.sort_by_key(|v| v.key);
122 Ok(views)
123}
124
125#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct CodecConfig {
128 pub endpoint: String,
130 pub auth: Option<String>,
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub enum Accent {
142 Red,
143 Green,
144 Yellow,
145 Blue,
146 Magenta,
147 Cyan,
148}
149
150impl Accent {
151 pub const NAMES: &'static str = "red, green, yellow, blue, magenta or cyan";
152
153 pub fn parse(s: &str) -> Option<Self> {
154 Some(match s {
155 "red" => Self::Red,
156 "green" => Self::Green,
157 "yellow" => Self::Yellow,
158 "blue" => Self::Blue,
159 "magenta" => Self::Magenta,
160 "cyan" => Self::Cyan,
161 _ => return None,
162 })
163 }
164}
165
166#[derive(Debug, Clone, Default, PartialEq, Eq)]
168pub struct ProfileConfig {
169 pub accent: Option<Accent>,
171 pub readonly: bool,
173 pub codec: Option<CodecConfig>,
175}
176
177#[derive(Debug, Clone, Default, PartialEq, Eq)]
179pub struct Resolved {
180 pub accent: Option<Accent>,
181 pub readonly: bool,
182 pub codec: Option<CodecConfig>,
183}
184
185#[derive(Debug, Clone, Default, PartialEq, Eq)]
187pub struct Config {
188 pub codec: Option<CodecConfig>,
190 pub profiles: Vec<(String, ProfileConfig)>,
191}
192
193impl Config {
194 pub fn resolve(&self, profile: &str) -> Resolved {
197 let found = self.profiles.iter().find(|(name, _)| name == profile);
198 match found {
199 None => Resolved {
200 accent: None,
201 readonly: false,
202 codec: self.codec.clone(),
203 },
204 Some((_, p)) => Resolved {
205 accent: p.accent,
206 readonly: p.readonly,
207 codec: p.codec.clone().or_else(|| self.codec.clone()),
211 },
212 }
213 }
214}
215
216pub fn parse_config(src: &str) -> Result<Config, ConfigError> {
231 const FILE: &str = "config.toml";
232 let table: toml::Table = toml::from_str(src).map_err(|e| ConfigError::Syntax {
233 file: FILE,
234 message: e.message().to_string(),
235 })?;
236
237 let codec = match table.get("codec") {
238 None => None,
239 Some(raw) => Some(parse_codec(raw, "codec")?),
240 };
241
242 let profiles = match table.get("profile") {
243 None => Vec::new(),
244 Some(raw) => {
245 let table = raw.as_table().ok_or(ConfigError::Type {
246 file: FILE,
247 path: "profile".into(),
248 expected: "a table",
249 })?;
250 let mut out = Vec::with_capacity(table.len());
251 for (name, raw) in table {
252 out.push((name.clone(), parse_profile(raw, name)?));
253 }
254 out
255 }
256 };
257
258 Ok(Config { codec, profiles })
259}
260
261fn parse_profile(raw: &toml::Value, name: &str) -> Result<ProfileConfig, ConfigError> {
262 const FILE: &str = "config.toml";
263 let table = raw.as_table().ok_or_else(|| ConfigError::Type {
264 file: FILE,
265 path: format!("profile.{name}"),
266 expected: "a table",
267 })?;
268
269 let accent = match table.get("accent") {
270 None => None,
271 Some(v) => {
272 let text = v.as_str().ok_or_else(|| ConfigError::Type {
273 file: FILE,
274 path: format!("profile.{name}.accent"),
275 expected: "a string",
276 })?;
277 Some(Accent::parse(text).ok_or_else(|| ConfigError::BadAccent {
278 path: format!("profile.{name}.accent"),
279 value: text.to_string(),
280 expected: Accent::NAMES,
281 })?)
282 }
283 };
284
285 let readonly = match table.get("readonly") {
286 None => false,
287 Some(v) => v.as_bool().ok_or_else(|| ConfigError::Type {
288 file: FILE,
289 path: format!("profile.{name}.readonly"),
290 expected: "true or false",
291 })?,
292 };
293
294 let codec = match table.get("codec") {
295 None => None,
296 Some(raw) => Some(parse_codec(raw, &format!("profile.{name}.codec"))?),
297 };
298
299 Ok(ProfileConfig {
300 accent,
301 readonly,
302 codec,
303 })
304}
305
306fn parse_codec(raw: &toml::Value, path: &str) -> Result<CodecConfig, ConfigError> {
307 const FILE: &str = "config.toml";
308 let codec = raw.as_table().ok_or_else(|| ConfigError::Type {
309 file: FILE,
310 path: path.to_string(),
311 expected: "a table",
312 })?;
313
314 let endpoint = codec
315 .get("endpoint")
316 .and_then(|v| v.as_str())
317 .ok_or_else(|| ConfigError::Type {
318 file: FILE,
319 path: format!("{path}.endpoint"),
320 expected: "a string",
321 })?
322 .trim_end_matches('/')
323 .to_string();
324 if endpoint.is_empty() {
325 return Err(ConfigError::Type {
326 file: FILE,
327 path: format!("{path}.endpoint"),
328 expected: "a non-empty URL",
329 });
330 }
331
332 let auth = match codec.get("auth") {
333 None => None,
334 Some(v) => Some(
335 v.as_str()
336 .ok_or_else(|| ConfigError::Type {
337 file: FILE,
338 path: format!("{path}.auth"),
339 expected: "a string",
340 })?
341 .to_string(),
342 ),
343 };
344
345 Ok(CodecConfig { endpoint, auth })
346}
347
348pub fn apply_keys(src: &str, registry: &Registry, keymap: &mut Keymap) -> Result<(), ConfigError> {
366 const FILE: &str = "keys.toml";
367 let table: toml::Table = toml::from_str(src).map_err(|e| ConfigError::Syntax {
368 file: FILE,
369 message: e.message().to_string(),
370 })?;
371
372 for (mode_name, bindings) in &table {
373 let mode = parse_mode(mode_name)?;
374 let bindings = bindings.as_table().ok_or_else(|| ConfigError::Type {
375 file: FILE,
376 path: mode_name.clone(),
377 expected: "a table of \"chord\" = \"command.id\"",
378 })?;
379
380 for (chord, command) in bindings {
381 let command = command.as_str().ok_or_else(|| ConfigError::Type {
382 file: FILE,
383 path: format!("{mode_name}.{chord}"),
384 expected: "a command id string",
385 })?;
386 let id = registry
389 .get(command)
390 .ok_or_else(|| ConfigError::UnknownCommand {
391 chord: chord.clone(),
392 command: command.to_string(),
393 })?
394 .id;
395 keymap
396 .bind(mode, chord, id)
397 .map_err(|source| ConfigError::BadChord {
398 chord: chord.clone(),
399 source,
400 })?;
401 }
402 }
403 Ok(())
404}
405
406pub fn bind_views(views: &[SavedView], keymap: &mut Keymap) -> Result<(), ConfigError> {
415 for v in views {
416 let seq = format!("<leader>{}", v.key);
417 let id: &'static str = Box::leak(format!("view.{}", v.key).into_boxed_str());
418 keymap
419 .bind(Mode::Normal, &seq, id)
420 .map_err(|source| ConfigError::BadChord { chord: seq, source })?;
421 }
422 Ok(())
423}
424
425fn parse_mode(name: &str) -> Result<Mode, ConfigError> {
426 Ok(match name.trim().to_ascii_lowercase().as_str() {
427 "normal" => Mode::Normal,
428 "insert" => Mode::Insert,
429 "visual" => Mode::Visual,
430 "v-line" | "visual-line" | "visualline" => Mode::VisualLine,
431 "command" => Mode::Command,
432 _ => return Err(ConfigError::UnknownMode(name.to_string())),
433 })
434}
435
436#[cfg(test)]
437mod tests {
438 use super::*;
439 use crate::key::Chord;
440 use crate::keymap::{Pending, Resolution, default_keymap};
441
442 #[test]
443 fn views_parse_in_key_order() {
444 let views = parse_views(
445 r#"
446 [[view]]
447 key = "3"
448 name = "Failed"
449 query = "ExecutionStatus = 'Failed'"
450
451 [[view]]
452 key = "1"
453 name = "Running"
454 query = "ExecutionStatus = 'Running'"
455 "#,
456 )
457 .unwrap();
458
459 assert_eq!(views.len(), 2);
460 assert_eq!(views[0].key, '1');
461 assert_eq!(views[0].name, "Running");
462 assert_eq!(views[1].key, '3');
463 assert_eq!(views[1].query, "ExecutionStatus = 'Failed'");
464 }
465
466 #[test]
467 fn an_absent_or_empty_views_file_is_not_an_error() {
468 assert_eq!(parse_views("").unwrap(), Vec::new());
469 assert_eq!(parse_views("# nothing here\n").unwrap(), Vec::new());
470 }
471
472 #[test]
473 fn a_view_key_must_be_a_single_digit() {
474 for key in ["0", "10", "a", ""] {
475 let src = format!("[[view]]\nkey = \"{key}\"\nname = \"N\"\nquery = \"\"\n");
476 assert!(
477 matches!(parse_views(&src), Err(ConfigError::BadViewKey { .. })),
478 "key {key:?} should be rejected"
479 );
480 }
481 }
482
483 #[test]
484 fn two_views_cannot_claim_the_same_key() {
485 let src = r#"
486 [[view]]
487 key = "1"
488 name = "A"
489 query = ""
490 [[view]]
491 key = "1"
492 name = "B"
493 query = ""
494 "#;
495 assert_eq!(parse_views(src), Err(ConfigError::DuplicateViewKey('1')));
496 }
497
498 #[test]
499 fn a_view_missing_a_field_says_which_one() {
500 let err = parse_views("[[view]]\nkey = \"1\"\n").unwrap_err();
501 assert!(
502 err.to_string().contains("view[0].name"),
503 "error should name the missing field, got: {err}"
504 );
505 }
506
507 #[test]
508 fn malformed_toml_is_reported_not_ignored() {
509 assert!(matches!(
510 parse_views("[[view]\nkey =").unwrap_err(),
511 ConfigError::Syntax { .. }
512 ));
513 }
514
515 #[test]
516 fn saved_views_bind_under_the_leader_not_the_bare_digit() {
517 let mut registry = Registry::builtin();
520 let views = vec![SavedView {
521 key: '1',
522 name: "Running".into(),
523 query: "ExecutionStatus = 'Running'".into(),
524 }];
525 registry.add_views(&views);
526 let mut keymap = default_keymap();
527 bind_views(&views, &mut keymap).unwrap();
528
529 let mut p = Pending::default();
530 assert_eq!(
531 keymap.resolve(Mode::Normal, &mut p, Chord::ch('1')),
532 Resolution::Count(1),
533 "a bare digit must still start a count"
534 );
535 p.clear();
536
537 assert!(matches!(
538 keymap.resolve(Mode::Normal, &mut p, Chord::ch(' ')),
539 Resolution::Pending { .. }
540 ));
541 assert_eq!(
542 keymap.resolve(Mode::Normal, &mut p, Chord::ch('1')),
543 Resolution::Run {
544 id: "view.1",
545 count: None
546 }
547 );
548 }
549
550 #[test]
551 fn an_unconfigured_view_slot_is_left_unbound() {
552 let mut keymap = default_keymap();
555 bind_views(&[], &mut keymap).unwrap();
556 let mut p = Pending::default();
557 keymap.resolve(Mode::Normal, &mut p, Chord::ch(' '));
558 match keymap.resolve(Mode::Normal, &mut p, Chord::ch('4')) {
559 Resolution::Unbound { .. } => {}
560 other => panic!("<leader>4 should be unbound, got {other:?}"),
561 }
562 }
563
564 #[test]
565 fn a_codec_endpoint_is_read_and_normalised() {
566 let c = parse_config(
567 r#"
568 [codec]
569 endpoint = "http://localhost:8081/"
570 auth = "Bearer abc"
571 "#,
572 )
573 .unwrap();
574 let codec = c.codec.unwrap();
575 assert_eq!(codec.endpoint, "http://localhost:8081");
578 assert_eq!(codec.auth.as_deref(), Some("Bearer abc"));
579 }
580
581 #[test]
582 fn auth_is_optional_and_never_invented() {
583 let c = parse_config("[codec]\nendpoint = \"http://x\"\n").unwrap();
584 assert_eq!(c.codec.unwrap().auth, None);
585 }
586
587 #[test]
588 fn no_codec_section_means_no_codec() {
589 assert_eq!(parse_config("").unwrap(), Config::default());
590 assert_eq!(parse_config("# nothing\n").unwrap().codec, None);
591 }
592
593 #[test]
594 fn a_codec_section_without_an_endpoint_is_an_error() {
595 let err = parse_config("[codec]\nauth = \"x\"\n").unwrap_err();
597 assert!(err.to_string().contains("codec.endpoint"), "got {err}");
598
599 let err = parse_config("[codec]\nendpoint = \"\"\n").unwrap_err();
600 assert!(err.to_string().contains("codec.endpoint"), "got {err}");
601 }
602
603 #[test]
604 fn keys_toml_overrides_a_default_binding() {
605 let registry = Registry::builtin();
606 let mut keymap = default_keymap();
607
608 apply_keys("[normal]\n\"j\" = \"motion.up\"\n", ®istry, &mut keymap).unwrap();
609
610 let mut p = Pending::default();
611 assert_eq!(
612 keymap.resolve(Mode::Normal, &mut p, Chord::ch('j')),
613 Resolution::Run {
614 id: "motion.up",
615 count: None
616 },
617 "a user binding must win over the built-in one"
618 );
619 }
620
621 #[test]
622 fn keys_toml_adds_a_new_sequence() {
623 let registry = Registry::builtin();
624 let mut keymap = default_keymap();
625 apply_keys("[normal]\n\"ZZ\" = \"app.quit\"\n", ®istry, &mut keymap).unwrap();
626
627 let mut p = Pending::default();
628 assert!(matches!(
629 keymap.resolve(Mode::Normal, &mut p, Chord::ch('Z')),
630 Resolution::Pending { .. }
631 ));
632 assert_eq!(
633 keymap.resolve(Mode::Normal, &mut p, Chord::ch('Z')),
634 Resolution::Run {
635 id: "app.quit",
636 count: None
637 }
638 );
639 }
640
641 #[test]
642 fn every_mode_name_is_accepted() {
643 let registry = Registry::builtin();
644 let mut keymap = default_keymap();
645 let src = r#"
646 [normal]
647 "<F5>" = "app.refresh"
648 [insert]
649 "<F5>" = "mode.normal"
650 [visual]
651 "<F5>" = "app.cancel"
652 [v-line]
653 "<F5>" = "app.cancel"
654 [command]
655 "<F5>" = "app.cancel"
656 "#;
657 assert_eq!(apply_keys(src, ®istry, &mut keymap), Ok(()));
658 }
659
660 #[test]
661 fn an_unknown_command_is_an_error_rather_than_a_dead_key() {
662 let registry = Registry::builtin();
665 let mut keymap = default_keymap();
666 let err = apply_keys(
667 "[normal]\n\"x\" = \"motion.sideways\"\n",
668 ®istry,
669 &mut keymap,
670 )
671 .unwrap_err();
672 assert_eq!(
673 err,
674 ConfigError::UnknownCommand {
675 chord: "x".into(),
676 command: "motion.sideways".into()
677 }
678 );
679 assert!(err.to_string().contains("motion.sideways"));
680 }
681
682 #[test]
683 fn an_unknown_mode_is_an_error() {
684 let registry = Registry::builtin();
685 let mut keymap = default_keymap();
686 assert_eq!(
687 apply_keys("[sideways]\n\"x\" = \"app.quit\"\n", ®istry, &mut keymap),
688 Err(ConfigError::UnknownMode("sideways".into()))
689 );
690 }
691
692 #[test]
693 fn an_unparseable_chord_names_itself() {
694 let registry = Registry::builtin();
695 let mut keymap = default_keymap();
696 let err = apply_keys(
697 "[normal]\n\"<Nope>\" = \"app.quit\"\n",
698 ®istry,
699 &mut keymap,
700 )
701 .unwrap_err();
702 assert!(
703 matches!(err, ConfigError::BadChord { ref chord, .. } if chord == "<Nope>"),
704 "got {err}"
705 );
706 }
707
708 #[test]
709 fn an_empty_keys_file_leaves_the_defaults_alone() {
710 let registry = Registry::builtin();
711 let mut keymap = default_keymap();
712 let before = keymap.bindings().len();
713 apply_keys("", ®istry, &mut keymap).unwrap();
714 assert_eq!(keymap.bindings().len(), before);
715 }
716
717 #[test]
718 fn a_config_with_no_profile_section_gives_every_profile_the_globals() {
719 let cfg = parse_config("[codec]\nendpoint = \"http://localhost:8081\"").unwrap();
721 let r = cfg.resolve("anything");
722 assert_eq!(r.codec.unwrap().endpoint, "http://localhost:8081");
723 assert!(!r.readonly);
724 assert_eq!(r.accent, None);
725 }
726
727 #[test]
728 fn a_profile_codec_overrides_the_global_one() {
729 let cfg = parse_config(
730 r#"
731[codec]
732endpoint = "http://localhost:8081"
733
734[profile.prod.codec]
735endpoint = "https://codec.internal"
736"#,
737 )
738 .unwrap();
739 assert_eq!(
740 cfg.resolve("prod").codec.unwrap().endpoint,
741 "https://codec.internal"
742 );
743 assert_eq!(
745 cfg.resolve("sit").codec.unwrap().endpoint,
746 "http://localhost:8081"
747 );
748 }
749
750 #[test]
751 fn a_profile_carries_its_accent_and_readonly_flag() {
752 let cfg = parse_config(
753 r#"
754[profile.prod]
755accent = "red"
756readonly = true
757
758[profile.sit]
759accent = "green"
760"#,
761 )
762 .unwrap();
763 let prod = cfg.resolve("prod");
764 assert_eq!(prod.accent, Some(Accent::Red));
765 assert!(prod.readonly);
766
767 let sit = cfg.resolve("sit");
768 assert_eq!(sit.accent, Some(Accent::Green));
769 assert!(!sit.readonly, "readonly must not leak between profiles");
770 }
771
772 #[test]
773 fn an_unknown_accent_is_reported_rather_than_ignored() {
774 let err = parse_config("[profile.prod]\naccent = \"crimson\"").unwrap_err();
777 assert!(matches!(err, ConfigError::BadAccent { .. }), "{err:?}");
778 assert!(err.to_string().contains("crimson"), "{err}");
779 }
780
781 #[test]
782 fn readonly_must_be_a_boolean() {
783 let err = parse_config("[profile.prod]\nreadonly = \"yes\"").unwrap_err();
784 assert!(matches!(err, ConfigError::Type { .. }), "{err:?}");
785 }
786}