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                command::Command::Unalias(nicks) => self.unalias_command(nicks),
65                command::Command::Unmailboxes(_) => {
66                    let outcome = command::apply(&mut self.config, &cmd);
67                    // Whatever shows the watched mailboxes redraws.
68                    self.requests.push(Request::MailboxesChanged);
69                    outcome
70                }
71                config_command => command::apply(&mut self.config, config_command),
72            };
73            match outcome {
74                Ok(Some(text)) => run.reports.push(text),
75                Ok(None) => {}
76                Err(err) => {
77                    self.error(err);
78                    return run;
79                }
80            }
81        }
82        // A `:set trash="=Trash"` names a mailbox too.
83        self.config.expand_folders();
84        run.warnings = self.recompile();
85        // A new $sort takes effect where it can be seen.
86        if sort_before
87            != (
88                self.config.index.sort.clone(),
89                self.config.index.sort_aux.clone(),
90                self.config.index.strict_threads,
91                self.config.index.sort_re,
92                self.config.mail.reply_regexp.clone(),
93            )
94        {
95            if let Some(spec) = self.config.index.sort.clone()
96                && let Some((sort, rev)) = crate::parse_sort(&spec)
97            {
98                self.sort = sort;
99                self.sort_rev = rev;
100            }
101            self.apply_sort();
102        }
103        self.requests.push(Request::ConfigChanged);
104        run
105    }
106
107    /// mutt's `alias` command: one line appended to the alias file.
108    fn alias_command(&mut self, nick: &str, expansion: &str) -> Result<Option<String>, String> {
109        if nick.contains(char::is_whitespace) {
110            return Err("the alias nick must be one word".into());
111        }
112        match rmut_core::alias::append_to(self.config.mail.alias_file.as_deref(), nick, expansion) {
113            Ok(_) => Ok(Some(format!("added: alias {nick} {expansion}"))),
114            Err(err) => Err(format!("cannot save the alias: {err:#}")),
115        }
116    }
117
118    /// mutt's unalias: out of the alias file, so it stays gone.
119    fn unalias_command(&mut self, nicks: &[String]) -> Result<Option<String>, String> {
120        match rmut_core::alias::remove_from(self.config.mail.alias_file.as_deref(), nicks) {
121            Ok(0) => Ok(Some("no such alias".into())),
122            Ok(n) => Ok(Some(format!("removed {n} alias(es)"))),
123            Err(err) => Err(format!("cannot rewrite the alias file: {err:#}")),
124        }
125    }
126
127    /// Run one hook's command line, naming the hook when it fails so
128    /// it is clear where a bad line came from.
129    fn run_hook(&mut self, what: &str, line: &str) {
130        self.clear_notice();
131        let run = self.run_command_line(line);
132        if let Some(err) = self.notice().filter(|n| n.is_error()).map(|n| n.text()) {
133            self.error(format!("{what}: {err}"));
134        } else if !run.warnings.is_empty() {
135            self.error(format!("{what}: {}", run.warnings.join("; ")));
136        }
137    }
138
139    /// mutt's folder-hook: the lines matching the mailbox that is now
140    /// open.
141    pub fn run_folder_hooks(&mut self) {
142        if self.config.folder_hooks.is_empty() {
143            return;
144        }
145        let title = self.title.clone();
146        let lines: Vec<String> = self
147            .config
148            .folder_hooks
149            .iter()
150            .filter(|h| rmut_core::config::glob_match(&h.folder, &title))
151            .map(|h| h.command.clone())
152            .collect();
153        for line in lines {
154            self.run_hook("folder-hook", &line);
155        }
156    }
157
158    /// mutt's message-hook: the lines matching the selected message
159    /// are in force while it is selected, and the config goes back to
160    /// what it was as soon as the match set changes. Cheap when
161    /// nothing matches, so a draw loop can call it every frame.
162    pub fn sync_message_hooks(&mut self) {
163        if self.message_hooks.is_empty() && self.active_message_hooks.is_empty() {
164            return;
165        }
166        let matching = self.matching_message_hooks();
167        if matching == self.active_message_hooks {
168            return;
169        }
170        self.active_message_hooks = matching.clone();
171        // Back to the pre-hook config first: a hook that no longer
172        // matches must leave no trace.
173        self.restore_hook_base();
174        if matching.is_empty() {
175            return;
176        }
177        self.hook_base = Some(Box::new(self.config.clone()));
178        for i in matching {
179            let Some(line) = self.message_hooks.get(i).map(|h| h.value.clone()) else {
180                continue;
181            };
182            self.run_hook("message-hook", &line);
183        }
184    }
185
186    /// Leaving the message (or the mailbox): whatever the
187    /// message-hooks changed goes back.
188    pub fn clear_message_hooks(&mut self) {
189        self.active_message_hooks.clear();
190        self.restore_hook_base();
191    }
192
193    fn restore_hook_base(&mut self) {
194        let Some(base) = self.hook_base.take() else {
195            return;
196        };
197        self.config = *base;
198        let warnings = self.recompile();
199        if !warnings.is_empty() {
200            self.error(warnings.join("; "));
201        }
202        self.requests.push(Request::ConfigChanged);
203    }
204
205    /// mutt's reply-hook: the lines matching the message being replied
206    /// to, in force while this reply's draft is built, so `set from`,
207    /// edit_headers and my_hdr all see them. Hands back the config to
208    /// put back afterwards.
209    pub fn apply_reply_hooks(
210        &mut self,
211        base: Option<&ComposeBase>,
212        kind: ComposeKind,
213    ) -> Option<Box<Config>> {
214        if self.reply_hooks.is_empty()
215            || !matches!(
216                kind,
217                ComposeKind::Reply | ComposeKind::GroupReply | ComposeKind::ListReply
218            )
219        {
220            return None;
221        }
222        let lines = self.reply_hook_lines(&base?.path);
223        if lines.is_empty() {
224            return None;
225        }
226        let saved = Box::new(self.config.clone());
227        for line in lines {
228            self.run_hook("reply-hook", &line);
229        }
230        Some(saved)
231    }
232
233    /// Undo [`Session::apply_reply_hooks`].
234    pub fn restore_after_reply_hooks(&mut self, saved: Option<Box<Config>>) {
235        let Some(saved) = saved else { return };
236        self.config = *saved;
237        let warnings = self.recompile();
238        if !warnings.is_empty() {
239            self.error(warnings.join("; "));
240        }
241        self.requests.push(Request::ConfigChanged);
242    }
243}