refactor: split composer config into subparts compose, reply and forward

* feat: Add configs for `reply-with` and `forward-with` commands

Config extended from:

(Old)
```toml
[message.composer.mml]
command = "mml compose"
```

, where `compose-with`, `reply-with`, and `forward-with`
all share the same composer command, to:

(New)
```toml
[message.composer.mml]
default = true
compose-command = "mml compose"
reply-command = "mml reply"
forward-command = "mml forward"
```

* docs: ComposerConfig

* refactor(account): simplify composer resolution with `get_composer`

- Implement `Account::get_composer`, and `Account::get_reader`
  to fetch config by name or default.
- Remove the redundant `resolve_composer`, `default_composer`
  `resolve_reader`, and `default_reader` helpers.
- Simplify the configuration retrieval architecture.

* refactor: update composer and reader config to use std::process::Command

- Remove `_command` suffix from `compose`, `reply`,
  and `forward` configuration fields.
- Replace composer and reader config command type
  from `String` to `std::process::Command`.
- Remove `Clone` derives from `Config`, `Account`, and
  related structs due to `Command` type limitations.

Refs: #687
This commit is contained in:
Bowen Ho
2026-05-23 23:42:03 +08:00
committed by GitHub
parent 72533ff4a3
commit c490bd5a27
8 changed files with 126 additions and 113 deletions
+10 -8
View File
@@ -18,6 +18,7 @@
use anyhow::{Result, bail};
use clap::Parser;
use pimalaya_cli::printer::Printer;
use pimalaya_config::command::shell;
use crate::shared::{
client::EmailClient,
@@ -57,16 +58,17 @@ pub struct MessageComposeWithCommand {
impl MessageComposeWithCommand {
pub fn execute(self, printer: &mut impl Printer, mut client: EmailClient) -> Result<()> {
let command = match self.command.as_deref() {
Some(cmd) => cmd.to_owned(),
None => {
runner::resolve_composer(&client.account.composer, self.name.as_deref())?.to_owned()
}
};
let mut command = self.command.map(|cmd| shell(&cmd));
let command = command.as_mut().unwrap_or(
&mut client
.account
.get_composer_mut(self.name.as_deref())?
.compose,
);
let raw = runner::run(&command, &[])?;
let raw = runner::run(command, &[])?;
if raw.is_empty() {
bail!("composer `{command}` produced no output");
bail!("composer `{command:?}` produced no output");
}
output::route(printer, &mut client, raw, self.save.as_deref(), self.send)
+10 -8
View File
@@ -18,6 +18,7 @@
use anyhow::{Result, bail};
use clap::Parser;
use pimalaya_cli::printer::Printer;
use pimalaya_config::command::shell;
use crate::shared::{
client::EmailClient,
@@ -59,16 +60,17 @@ impl MessageForwardWithCommand {
pub fn execute(self, printer: &mut impl Printer, mut client: EmailClient) -> Result<()> {
let source = client.get_message(&self.mailbox, &self.id)?;
let command = match self.command.as_deref() {
Some(cmd) => cmd.to_owned(),
None => {
runner::resolve_composer(&client.account.composer, self.name.as_deref())?.to_owned()
}
};
let mut command = self.command.map(|cmd| shell(&cmd));
let command = command.as_mut().unwrap_or(
&mut client
.account
.get_composer_mut(self.name.as_deref())?
.forward,
);
let raw = runner::run(&command, &source)?;
let raw = runner::run(command, &source)?;
if raw.is_empty() {
bail!("composer `{command}` produced no output");
bail!("composer `{command:?}` produced no output");
}
output::route(printer, &mut client, raw, self.save.as_deref(), self.send)
+10 -8
View File
@@ -19,6 +19,7 @@ use anyhow::{Result, anyhow, bail};
use clap::Parser;
use percent_encoding::percent_decode_str;
use pimalaya_cli::printer::Printer;
use pimalaya_config::command::shell;
use url::Url;
use crate::shared::{
@@ -88,16 +89,17 @@ impl MessageMailtoCommand {
None,
)?;
let command = match self.command.as_deref() {
Some(cmd) => cmd.to_owned(),
None => {
runner::resolve_composer(&client.account.composer, self.name.as_deref())?.to_owned()
}
};
let mut command = self.command.map(|cmd| shell(&cmd));
let command = command.as_mut().unwrap_or(
&mut client
.account
.get_composer_mut(self.name.as_deref())?
.compose,
);
let raw = runner::run(&command, &draft)?;
let raw = runner::run(command, &draft)?;
if raw.is_empty() {
bail!("composer `{command}` produced no output");
bail!("composer `{command:?}` produced no output");
}
output::route(printer, &mut client, raw, self.save.as_deref(), self.send)
+6 -7
View File
@@ -20,6 +20,7 @@ use std::io::{Write, stdout};
use anyhow::Result;
use clap::Parser;
use pimalaya_cli::printer::Printer;
use pimalaya_config::command::shell;
use crate::shared::{client::EmailClient, messages::runner};
@@ -58,14 +59,12 @@ impl MessageReadWithCommand {
pub fn execute(self, _printer: &mut impl Printer, mut client: EmailClient) -> Result<()> {
let source = client.get_message(&self.mailbox, &self.id)?;
let command = match self.command.as_deref() {
Some(cmd) => cmd.to_owned(),
None => {
runner::resolve_reader(&client.account.reader, self.name.as_deref())?.to_owned()
}
};
let mut command = self.command.map(|cmd| shell(&cmd));
let command = command
.as_mut()
.unwrap_or(&mut client.account.get_reader_mut(self.name.as_deref())?.command);
let bytes = runner::run(&command, &source)?;
let bytes = runner::run(command, &source)?;
if !bytes.is_empty() {
let mut out = stdout().lock();
+7 -8
View File
@@ -18,6 +18,7 @@
use anyhow::{Result, bail};
use clap::Parser;
use pimalaya_cli::printer::Printer;
use pimalaya_config::command::shell;
use crate::shared::{
client::EmailClient,
@@ -64,16 +65,14 @@ impl MessageReplyWithCommand {
pub fn execute(self, printer: &mut impl Printer, mut client: EmailClient) -> Result<()> {
let source = client.get_message(&self.mailbox, &self.id)?;
let command = match self.command.as_deref() {
Some(cmd) => cmd.to_owned(),
None => {
runner::resolve_composer(&client.account.composer, self.name.as_deref())?.to_owned()
}
};
let mut command = self.command.map(|cmd| shell(&cmd));
let command = command
.as_mut()
.unwrap_or(&mut client.account.get_composer_mut(self.name.as_deref())?.reply);
let raw = runner::run(&command, &source)?;
let raw = runner::run(command, &source)?;
if raw.is_empty() {
bail!("composer `{command}` produced no output");
bail!("composer `{command:?}` produced no output");
}
output::route(printer, &mut client, raw, self.save.as_deref(), self.send)
+9 -62
View File
@@ -26,90 +26,37 @@
//! `/dev/tty` once they've consumed stdin — standard Unix practice.
use std::{
collections::HashMap,
io::Write,
process::{Command, Stdio},
};
use anyhow::{Result, anyhow, bail};
use crate::config::{ComposerConfig, ReaderConfig};
/// Resolves a composer entry to its shell command line. When `name`
/// is given, looks up the corresponding entry and bails if missing.
/// When `name` is `None`, returns the entry with `default = true`,
/// or bails with a hint if no default is set.
pub fn resolve_composer<'a>(
composers: &'a HashMap<String, ComposerConfig>,
name: Option<&str>,
) -> Result<&'a str> {
match name {
Some(name) => match composers.get(name) {
Some(entry) => Ok(entry.command.as_str()),
None => bail!("no composer named `{name}` in [message.composer]"),
},
None => default_composer(composers).map(|entry| entry.command.as_str()),
}
}
/// Same as [`resolve_composer`] but for readers.
pub fn resolve_reader<'a>(
readers: &'a HashMap<String, ReaderConfig>,
name: Option<&str>,
) -> Result<&'a str> {
match name {
Some(name) => match readers.get(name) {
Some(entry) => Ok(entry.command.as_str()),
None => bail!("no reader named `{name}` in [message.reader]"),
},
None => default_reader(readers).map(|entry| entry.command.as_str()),
}
}
fn default_composer(composers: &HashMap<String, ComposerConfig>) -> Result<&ComposerConfig> {
composers.values().find(|c| c.default).ok_or_else(|| {
anyhow!(
"no composer specified and no default in [message.composer.*]; \
pass a <name> or set `default = true` on one entry"
)
})
}
fn default_reader(readers: &HashMap<String, ReaderConfig>) -> Result<&ReaderConfig> {
readers.values().find(|c| c.default).ok_or_else(|| {
anyhow!(
"no reader specified and no default in [message.reader.*]; \
pass a <name> or set `default = true` on one entry"
)
})
}
/// Spawns `command` through `sh -c`, writes `stdin_bytes` to its
/// stdin, and returns the captured stdout bytes. Stderr is inherited.
/// Spawns `command`, writes `stdin_bytes` to its
/// stdin, and returns the captured stdout bytes.
/// Stderr is inherited.
/// Bails on a non-zero exit status.
pub fn run(command: &str, stdin_bytes: &[u8]) -> Result<Vec<u8>> {
let mut child = Command::new("sh")
.arg("-c")
.arg(command)
pub fn run(command: &mut Command, stdin_bytes: &[u8]) -> Result<Vec<u8>> {
let mut child = command
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
.map_err(|err| anyhow!("spawn `{command}`: {err}"))?;
.map_err(|err| anyhow!("spawn `{command:?}`: {err}"))?;
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(stdin_bytes)
.map_err(|err| anyhow!("write stdin to `{command}`: {err}"))?;
.map_err(|err| anyhow!("write stdin to `{command:?}`: {err}"))?;
}
let output = child
.wait_with_output()
.map_err(|err| anyhow!("wait `{command}`: {err}"))?;
.map_err(|err| anyhow!("wait `{command:?}`: {err}"))?;
if !output.status.success() {
bail!(
"`{command}` exited with status {}",
"`{command:?}` exited with status {}",
output
.status
.code()