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