Skip to main content

rmut_session/
commands.rs

1//! Config commands, and the hooks that run them.
2//!
3//! mutt's `set`, `unset`, `toggle` and `alias` change what the session
4//! is; `bind`, `macro`, `push` and `exec` change what keys do, and a
5//! session has no keys. So a command line runs here as far as it can
6//! and the rest goes back to the front end as a [`Request`], which is
7//! also what a hook line does: a `folder-hook` naming a `bind` is
8//! perfectly legal, and only the front end can honour it.
9
10use rmut_core::command;
11use rmut_core::config::Config;
12
13use crate::{ComposeBase, ComposeKind, Request, Session};
14
15/// What a command line did, for the front end to finish and report.
16#[derive(Default)]
17pub struct CommandRun {
18    /// What the commands had to say (`set beep?` and the like), in
19    /// order, for the front end to show as one line.
20    pub reports: Vec<String>,
21    /// Warnings from recompiling the session's derived state.
22    pub warnings: Vec<String>,
23}
24
25impl Session {
26    /// Run a config command line (mutt's enter-command, and every
27    /// hook's payload).
28    ///
29    /// Everything the session owns is applied here; whatever belongs
30    /// to the keys goes back as [`Request::Command`], and the front
31    /// end is told the config moved with [`Request::ConfigChanged`].
32    pub fn run_command_line(&mut self, line: &str) -> CommandRun {
33        let mut run = CommandRun::default();
34        let commands = match command::parse(line) {
35            Ok(commands) => commands,
36            Err(err) => {
37                self.error(err);
38                return run;
39            }
40        };
41        if commands.is_empty() {
42            return run;
43        }
44        // What decides the order messages sit in: $sort and $sort_aux,
45        // and the three that decide what a thread is.
46        let sort_before = (
47            self.config.index.sort.clone(),
48            self.config.index.sort_aux.clone(),
49            self.config.index.strict_threads,
50            self.config.index.sort_re,
51            self.config.mail.reply_regexp.clone(),
52        );
53        for cmd in commands {
54            let outcome = match &cmd {
55                command::Command::Bind { .. }
56                | command::Command::Macro { .. }
57                | command::Command::Push(_)
58                | command::Command::Exec(_) => {
59                    // The front end's: it has the key tables.
60                    self.requests.push(Request::Command(cmd));
61                    continue;
62                }
63                command::Command::Alias { nick, expansion } => self.alias_command(nick, expansion),
64                config_command => command::apply(&mut self.config, config_command),
65            };
66            match outcome {
67                Ok(Some(text)) => run.reports.push(text),
68                Ok(None) => {}
69                Err(err) => {
70                    self.error(err);
71                    return run;
72                }
73            }
74        }
75        // A `:set trash="=Trash"` names a mailbox too.
76        self.config.expand_folders();
77        run.warnings = self.recompile();
78        // A new $sort takes effect where it can be seen.
79        if sort_before
80            != (
81                self.config.index.sort.clone(),
82                self.config.index.sort_aux.clone(),
83                self.config.index.strict_threads,
84                self.config.index.sort_re,
85                self.config.mail.reply_regexp.clone(),
86            )
87        {
88            if let Some(spec) = self.config.index.sort.clone()
89                && let Some((sort, rev)) = crate::parse_sort(&spec)
90            {
91                self.sort = sort;
92                self.sort_rev = rev;
93            }
94            self.apply_sort();
95        }
96        self.requests.push(Request::ConfigChanged);
97        run
98    }
99
100    /// mutt's `alias` command: one line appended to the alias file.
101    fn alias_command(&mut self, nick: &str, expansion: &str) -> Result<Option<String>, String> {
102        if nick.contains(char::is_whitespace) {
103            return Err("the alias nick must be one word".into());
104        }
105        match rmut_core::alias::append_to(self.config.mail.alias_file.as_deref(), nick, expansion) {
106            Ok(_) => Ok(Some(format!("added: alias {nick} {expansion}"))),
107            Err(err) => Err(format!("cannot save the alias: {err:#}")),
108        }
109    }
110
111    /// Run one hook's command line, naming the hook when it fails so
112    /// it is clear where a bad line came from.
113    fn run_hook(&mut self, what: &str, line: &str) {
114        self.clear_notice();
115        let run = self.run_command_line(line);
116        if let Some(err) = self.notice().filter(|n| n.is_error()).map(|n| n.text()) {
117            self.error(format!("{what}: {err}"));
118        } else if !run.warnings.is_empty() {
119            self.error(format!("{what}: {}", run.warnings.join("; ")));
120        }
121    }
122
123    /// mutt's folder-hook: the lines matching the mailbox that is now
124    /// open.
125    pub fn run_folder_hooks(&mut self) {
126        if self.config.folder_hooks.is_empty() {
127            return;
128        }
129        let title = self.title.clone();
130        let lines: Vec<String> = self
131            .config
132            .folder_hooks
133            .iter()
134            .filter(|h| rmut_core::config::glob_match(&h.folder, &title))
135            .map(|h| h.command.clone())
136            .collect();
137        for line in lines {
138            self.run_hook("folder-hook", &line);
139        }
140    }
141
142    /// mutt's message-hook: the lines matching the selected message
143    /// are in force while it is selected, and the config goes back to
144    /// what it was as soon as the match set changes. Cheap when
145    /// nothing matches, so a draw loop can call it every frame.
146    pub fn sync_message_hooks(&mut self) {
147        if self.message_hooks.is_empty() && self.active_message_hooks.is_empty() {
148            return;
149        }
150        let matching = self.matching_message_hooks();
151        if matching == self.active_message_hooks {
152            return;
153        }
154        self.active_message_hooks = matching.clone();
155        // Back to the pre-hook config first: a hook that no longer
156        // matches must leave no trace.
157        self.restore_hook_base();
158        if matching.is_empty() {
159            return;
160        }
161        self.hook_base = Some(Box::new(self.config.clone()));
162        for i in matching {
163            let Some(line) = self.message_hooks.get(i).map(|h| h.value.clone()) else {
164                continue;
165            };
166            self.run_hook("message-hook", &line);
167        }
168    }
169
170    /// Leaving the message (or the mailbox): whatever the
171    /// message-hooks changed goes back.
172    pub fn clear_message_hooks(&mut self) {
173        self.active_message_hooks.clear();
174        self.restore_hook_base();
175    }
176
177    fn restore_hook_base(&mut self) {
178        let Some(base) = self.hook_base.take() else {
179            return;
180        };
181        self.config = *base;
182        let warnings = self.recompile();
183        if !warnings.is_empty() {
184            self.error(warnings.join("; "));
185        }
186        self.requests.push(Request::ConfigChanged);
187    }
188
189    /// mutt's reply-hook: the lines matching the message being replied
190    /// to, in force while this reply's draft is built, so `set from`,
191    /// edit_headers and my_hdr all see them. Hands back the config to
192    /// put back afterwards.
193    pub fn apply_reply_hooks(
194        &mut self,
195        base: Option<&ComposeBase>,
196        kind: ComposeKind,
197    ) -> Option<Box<Config>> {
198        if self.reply_hooks.is_empty()
199            || !matches!(
200                kind,
201                ComposeKind::Reply | ComposeKind::GroupReply | ComposeKind::ListReply
202            )
203        {
204            return None;
205        }
206        let lines = self.reply_hook_lines(&base?.path);
207        if lines.is_empty() {
208            return None;
209        }
210        let saved = Box::new(self.config.clone());
211        for line in lines {
212            self.run_hook("reply-hook", &line);
213        }
214        Some(saved)
215    }
216
217    /// Undo [`Session::apply_reply_hooks`].
218    pub fn restore_after_reply_hooks(&mut self, saved: Option<Box<Config>>) {
219        let Some(saved) = saved else { return };
220        self.config = *saved;
221        let warnings = self.recompile();
222        if !warnings.is_empty() {
223            self.error(warnings.join("; "));
224        }
225        self.requests.push(Request::ConfigChanged);
226    }
227}