Skip to main content

dynamic_cli/config/
directive.rs

1//! REPL directives
2//!
3//! A REPL line starting with `:` is a framework directive; any other line is
4//! an application command. The set of directives is closed for a given
5//! version of the crate and described by [`ReplDirective`]. An application
6//! may rename a directive, change its aliases or its description through the
7//! optional `directives:` section of its configuration, but it can neither
8//! add nor disable one.
9//!
10//! # Default directives
11//!
12//! | `implementation` | Name   | Aliases  | Arguments   |
13//! |------------------|--------|----------|-------------|
14//! | `repl_help`      | `help` | `h`, `?` | `[command]` |
15//! | `repl_load`      | `load` |          | `<path>`    |
16//! | `repl_quit`      | `quit` | `q`      |             |
17//! | `repl_exit`      | `exit` |          |             |
18//!
19//! Names and aliases are written without the leading `:`; the REPL adds it.
20//!
21//! # Overriding a directive
22//!
23//! ```yaml
24//! directives:
25//!   - implementation: repl_help
26//!     name: aide
27//!     aliases: [a]
28//!     description: "Afficher l'aide"
29//! ```
30//!
31//! An entry replaces the name, the aliases and the description of the
32//! directive selected by `implementation`; `aliases` defaults to an empty
33//! list. Directives without an entry keep their defaults.
34//! [`effective_directives`] computes the resulting table.
35//!
36//! # Validation
37//!
38//! The configuration validator rejects:
39//!
40//! - an unknown `implementation` (already at load time);
41//! - two entries for the same `implementation`;
42//! - an empty name or alias, one containing whitespace, or one starting
43//!   with `:`;
44//! - a name or alias used twice in the effective table, defaults included:
45//!   an override cannot take `q` while `:quit` keeps it as its alias.
46//!
47//! Directive names are never compared with command names, since a
48//! directive is always typed with its `:`. Command names and aliases, for
49//! their part, may not start with `:`.
50
51// Design: DA-030 (#92). The directive set is an enum rather than a trait:
52// commands are an open set supplied by the application, directives a closed
53// set that needs REPL internals (editor, history, loop control). A new
54// directive is a new variant, and the exhaustive matches below make the
55// compiler point at every place that must handle it.
56
57use serde::{Deserialize, Serialize};
58
59/// Prefix that marks a REPL line as a directive
60pub(crate) const DIRECTIVE_PREFIX: char = ':';
61
62/// Framework directive available in the REPL
63///
64/// Deserialized from the `implementation` field of a `directives:` entry
65/// (`repl_help`, `repl_load`, `repl_quit`, `repl_exit`). An unknown value
66/// fails at load time, and the error lists the accepted values.
67///
68/// The enum is `#[non_exhaustive]`: later versions may add directives.
69///
70/// # Example
71///
72/// ```
73/// use dynamic_cli::config::ReplDirective;
74///
75/// assert_eq!(ReplDirective::Help.default_name(), "help");
76/// assert_eq!(ReplDirective::Help.default_aliases(), &["h", "?"]);
77/// assert_eq!(ReplDirective::Help.usage(), "[command]");
78/// assert_eq!(ReplDirective::Help.as_str(), "repl_help");
79/// ```
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
81#[non_exhaustive]
82pub enum ReplDirective {
83    /// Show the application help, or the help of one command
84    #[serde(rename = "repl_help")]
85    Help,
86
87    /// Run every line of a script file in the current session
88    #[serde(rename = "repl_load")]
89    Load,
90
91    /// Save the session history, then leave the REPL
92    #[serde(rename = "repl_quit")]
93    Quit,
94
95    /// Leave the REPL without saving the session history
96    #[serde(rename = "repl_exit")]
97    Exit,
98}
99
100impl ReplDirective {
101    /// Every directive, in the order used by [`effective_directives`] and
102    /// by help listings
103    pub const ALL: &'static [ReplDirective] = &[
104        ReplDirective::Help,
105        ReplDirective::Load,
106        ReplDirective::Quit,
107        ReplDirective::Exit,
108    ];
109
110    /// Identifier used in the `implementation` field of the configuration
111    pub fn as_str(self) -> &'static str {
112        match self {
113            ReplDirective::Help => "repl_help",
114            ReplDirective::Load => "repl_load",
115            ReplDirective::Quit => "repl_quit",
116            ReplDirective::Exit => "repl_exit",
117        }
118    }
119
120    /// Name used when the configuration does not override the directive
121    pub fn default_name(self) -> &'static str {
122        match self {
123            ReplDirective::Help => "help",
124            ReplDirective::Load => "load",
125            ReplDirective::Quit => "quit",
126            ReplDirective::Exit => "exit",
127        }
128    }
129
130    /// Aliases used when the configuration does not override the directive
131    pub fn default_aliases(self) -> &'static [&'static str] {
132        match self {
133            ReplDirective::Help => &["h", "?"],
134            ReplDirective::Load => &[],
135            ReplDirective::Quit => &["q"],
136            ReplDirective::Exit => &[],
137        }
138    }
139
140    /// Description used when the configuration does not override the
141    /// directive
142    pub fn default_description(self) -> &'static str {
143        match self {
144            ReplDirective::Help => "Show the application help, or the help of a command",
145            ReplDirective::Load => "Run every line of a script file",
146            ReplDirective::Quit => "Save the session history and leave",
147            ReplDirective::Exit => "Leave without saving the session history",
148        }
149    }
150
151    /// Arguments accepted after the directive name, as shown in help
152    ///
153    /// Returns only the argument part, since the name itself may be
154    /// overridden; the result is empty for a directive without arguments.
155    /// Arguments are fixed by the action and cannot be configured.
156    pub fn usage(self) -> &'static str {
157        match self {
158            ReplDirective::Help => "[command]",
159            ReplDirective::Load => "<path>",
160            ReplDirective::Quit => "",
161            ReplDirective::Exit => "",
162        }
163    }
164}
165
166/// Name, aliases and description of a REPL directive
167///
168/// Used both for the entries of the `directives:` configuration section and
169/// for the complete table returned by [`effective_directives`].
170///
171/// # Example YAML
172///
173/// ```yaml
174/// implementation: repl_quit
175/// name: quitter
176/// aliases: [q]
177/// description: "Enregistrer l'historique et quitter"
178/// ```
179#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
180pub struct DirectiveDefinition {
181    /// Directive being defined or overridden
182    pub implementation: ReplDirective,
183
184    /// Name typed after `:` to invoke the directive
185    pub name: String,
186
187    /// Alternative names, also typed after `:`
188    #[serde(default)]
189    pub aliases: Vec<String>,
190
191    /// Human-readable description for help text
192    pub description: String,
193}
194
195impl DirectiveDefinition {
196    /// Definition of `directive` with its default name, aliases and
197    /// description
198    ///
199    /// # Example
200    ///
201    /// ```
202    /// use dynamic_cli::config::{DirectiveDefinition, ReplDirective};
203    ///
204    /// let quit = DirectiveDefinition::default_for(ReplDirective::Quit);
205    /// assert_eq!(quit.name, "quit");
206    /// assert_eq!(quit.aliases, vec!["q".to_string()]);
207    /// ```
208    pub fn default_for(directive: ReplDirective) -> Self {
209        Self {
210            implementation: directive,
211            name: directive.default_name().to_string(),
212            aliases: directive
213                .default_aliases()
214                .iter()
215                .map(|alias| alias.to_string())
216                .collect(),
217            description: directive.default_description().to_string(),
218        }
219    }
220
221    /// Usage line under the effective name, e.g. `:help [command]` or
222    /// `:quit`
223    ///
224    /// Shared by the REPL error hints and the help formatters, so both show
225    /// the same line.
226    pub(crate) fn usage_line(&self) -> String {
227        match self.implementation.usage() {
228            "" => format!("{}{}", DIRECTIVE_PREFIX, self.name),
229            args => format!("{}{} {}", DIRECTIVE_PREFIX, self.name, args),
230        }
231    }
232}
233
234/// Complete directive table after applying the configuration overrides
235///
236/// Returns one definition per directive, in [`ReplDirective::ALL`] order: the
237/// override whose `implementation` matches when there is one, the default
238/// definition otherwise.
239///
240/// The configuration validator rejects two overrides of the same directive.
241/// On an unvalidated list, the last override of a directive wins.
242///
243/// # Example
244///
245/// ```
246/// use dynamic_cli::config::{effective_directives, DirectiveDefinition, ReplDirective};
247///
248/// let overrides = vec![DirectiveDefinition {
249///     implementation: ReplDirective::Help,
250///     name: "aide".to_string(),
251///     aliases: vec![],
252///     description: "Afficher l'aide".to_string(),
253/// }];
254///
255/// let table = effective_directives(&overrides);
256/// assert_eq!(table.len(), ReplDirective::ALL.len());
257/// assert_eq!(table[0].name, "aide");
258/// assert_eq!(table[2].name, "quit");
259/// ```
260pub fn effective_directives(overrides: &[DirectiveDefinition]) -> Vec<DirectiveDefinition> {
261    ReplDirective::ALL
262        .iter()
263        .map(|&directive| {
264            overrides
265                .iter()
266                .rev()
267                .find(|entry| entry.implementation == directive)
268                .cloned()
269                .unwrap_or_else(|| DirectiveDefinition::default_for(directive))
270        })
271        .collect()
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use std::collections::HashSet;
278
279    fn entry(directive: ReplDirective, name: &str, aliases: &[&str]) -> DirectiveDefinition {
280        DirectiveDefinition {
281            implementation: directive,
282            name: name.to_string(),
283            aliases: aliases.iter().map(|a| a.to_string()).collect(),
284            description: format!("custom {}", name),
285        }
286    }
287
288    // ------------------------------------------------------------------
289    // Defaults
290    // ------------------------------------------------------------------
291
292    #[test]
293    fn test_all_lists_each_variant_once() {
294        let unique: HashSet<_> = ReplDirective::ALL.iter().collect();
295        assert_eq!(unique.len(), ReplDirective::ALL.len());
296        assert_eq!(ReplDirective::ALL.len(), 4);
297    }
298
299    #[test]
300    fn test_every_directive_has_default_name_and_description() {
301        for &directive in ReplDirective::ALL {
302            assert!(!directive.default_name().is_empty(), "{:?}", directive);
303            assert!(
304                !directive.default_description().is_empty(),
305                "{:?}",
306                directive
307            );
308            assert!(directive.as_str().starts_with("repl_"), "{:?}", directive);
309        }
310    }
311
312    #[test]
313    fn test_default_values_per_directive() {
314        assert_eq!(ReplDirective::Help.default_name(), "help");
315        assert_eq!(ReplDirective::Help.default_aliases(), &["h", "?"]);
316        assert_eq!(ReplDirective::Help.usage(), "[command]");
317
318        assert_eq!(ReplDirective::Load.default_name(), "load");
319        assert!(ReplDirective::Load.default_aliases().is_empty());
320        assert_eq!(ReplDirective::Load.usage(), "<path>");
321
322        assert_eq!(ReplDirective::Quit.default_name(), "quit");
323        assert_eq!(ReplDirective::Quit.default_aliases(), &["q"]);
324        assert_eq!(ReplDirective::Quit.usage(), "");
325
326        assert_eq!(ReplDirective::Exit.default_name(), "exit");
327        assert!(ReplDirective::Exit.default_aliases().is_empty());
328        assert_eq!(ReplDirective::Exit.usage(), "");
329    }
330
331    #[test]
332    fn test_default_names_and_aliases_are_disjoint_and_valid() {
333        let mut seen = HashSet::new();
334        for &directive in ReplDirective::ALL {
335            let tokens = std::iter::once(directive.default_name())
336                .chain(directive.default_aliases().iter().copied());
337            for token in tokens {
338                assert!(!token.is_empty());
339                assert!(!token.starts_with(':'));
340                assert!(!token.chars().any(char::is_whitespace));
341                assert!(seen.insert(token), "'{}' used twice", token);
342            }
343        }
344    }
345
346    #[test]
347    fn test_usage_has_no_surrounding_whitespace() {
348        for &directive in ReplDirective::ALL {
349            assert_eq!(directive.usage(), directive.usage().trim());
350        }
351    }
352
353    #[test]
354    fn test_default_for_matches_accessors() {
355        for &directive in ReplDirective::ALL {
356            let def = DirectiveDefinition::default_for(directive);
357            assert_eq!(def.implementation, directive);
358            assert_eq!(def.name, directive.default_name());
359            assert_eq!(def.aliases, directive.default_aliases());
360            assert_eq!(def.description, directive.default_description());
361        }
362    }
363
364    #[test]
365    fn test_usage_line_defaults() {
366        let lines: Vec<_> = ReplDirective::ALL
367            .iter()
368            .map(|&d| DirectiveDefinition::default_for(d).usage_line())
369            .collect();
370        assert_eq!(
371            lines,
372            vec![":help [command]", ":load <path>", ":quit", ":exit"]
373        );
374    }
375
376    #[test]
377    fn test_usage_line_follows_overridden_name() {
378        assert_eq!(
379            entry(ReplDirective::Help, "aide", &[]).usage_line(),
380            ":aide [command]"
381        );
382        assert_eq!(
383            entry(ReplDirective::Exit, "sortir", &[]).usage_line(),
384            ":sortir"
385        );
386    }
387
388    // ------------------------------------------------------------------
389    // Serde
390    // ------------------------------------------------------------------
391
392    #[test]
393    fn test_serde_identifiers_match_as_str() {
394        for &directive in ReplDirective::ALL {
395            let yaml = serde_yaml::to_string(&directive).unwrap();
396            assert_eq!(yaml.trim(), directive.as_str());
397            let back: ReplDirective = serde_yaml::from_str(directive.as_str()).unwrap();
398            assert_eq!(back, directive);
399        }
400    }
401
402    #[test]
403    fn test_deserialize_definition_aliases_default_empty() {
404        let yaml = r#"
405implementation: repl_exit
406name: sortir
407description: "Sortir sans enregistrer"
408"#;
409        let def: DirectiveDefinition = serde_yaml::from_str(yaml).unwrap();
410        assert_eq!(def.implementation, ReplDirective::Exit);
411        assert_eq!(def.name, "sortir");
412        assert!(def.aliases.is_empty());
413    }
414
415    #[test]
416    fn test_deserialize_definition_requires_name_and_description() {
417        let no_name = "implementation: repl_help\ndescription: x\n";
418        assert!(serde_yaml::from_str::<DirectiveDefinition>(no_name).is_err());
419        let no_description = "implementation: repl_help\nname: aide\n";
420        assert!(serde_yaml::from_str::<DirectiveDefinition>(no_description).is_err());
421    }
422
423    #[test]
424    fn test_deserialize_unknown_implementation_lists_valid_values() {
425        let yaml = "implementation: repl_history\nname: hist\ndescription: x\n";
426        let err = serde_yaml::from_str::<DirectiveDefinition>(yaml)
427            .unwrap_err()
428            .to_string();
429        assert!(err.contains("repl_history"), "{}", err);
430        for &directive in ReplDirective::ALL {
431            assert!(err.contains(directive.as_str()), "{}", err);
432        }
433    }
434
435    // ------------------------------------------------------------------
436    // Merge
437    // ------------------------------------------------------------------
438
439    #[test]
440    fn test_effective_directives_without_override() {
441        let table = effective_directives(&[]);
442        let expected: Vec<_> = ReplDirective::ALL
443            .iter()
444            .map(|&d| DirectiveDefinition::default_for(d))
445            .collect();
446        assert_eq!(table, expected);
447    }
448
449    #[test]
450    fn test_effective_directives_partial_override() {
451        let help = entry(ReplDirective::Help, "aide", &[]);
452        let table = effective_directives(std::slice::from_ref(&help));
453
454        assert_eq!(table.len(), ReplDirective::ALL.len());
455        assert_eq!(table[0], help);
456        // Overriding replaces the aliases: `h` and `?` are gone.
457        assert!(table[0].aliases.is_empty());
458        for (def, &directive) in table.iter().zip(ReplDirective::ALL).skip(1) {
459            assert_eq!(*def, DirectiveDefinition::default_for(directive));
460        }
461    }
462
463    #[test]
464    fn test_effective_directives_full_override_keeps_all_order() {
465        // Overrides given in reverse order; the table follows `ALL`.
466        let overrides = vec![
467            entry(ReplDirective::Exit, "sortir", &[]),
468            entry(ReplDirective::Quit, "quitter", &["q"]),
469            entry(ReplDirective::Load, "charger", &["c"]),
470            entry(ReplDirective::Help, "aide", &["a", "?"]),
471        ];
472        let table = effective_directives(&overrides);
473
474        let names: Vec<_> = table.iter().map(|d| d.name.as_str()).collect();
475        assert_eq!(names, vec!["aide", "charger", "quitter", "sortir"]);
476        let order: Vec<_> = table.iter().map(|d| d.implementation).collect();
477        assert_eq!(order, ReplDirective::ALL);
478    }
479
480    #[test]
481    fn test_effective_directives_last_duplicate_wins() {
482        let overrides = vec![
483            entry(ReplDirective::Quit, "first", &[]),
484            entry(ReplDirective::Quit, "second", &[]),
485        ];
486        let table = effective_directives(&overrides);
487        assert_eq!(table[2].name, "second");
488    }
489}