himalaya Gmail 命令模块布局重构:每子命令一文件、领域命名与共享 MessageArg 的落地实践
himalaya Gmail 命令模块布局重构:每子命令一文件、领域命名与共享 MessageArg 的落地实践
本文基于 himalaya 仓库中 gmail-command-module-layout 变更记录(含 任务清单、需求增量 与 落地日志),剖析这次已落地(status: landed,2026-08-14)的源码组织重构:gmail 后端如何从"每资源一个大文件"收敛到与 imap、jmap、msgraph 一致的"每子命令一文件"布局,同时解决 FormatArg 复制粘贴、裸动词命令类型命名冲突、原始消息输入各行其是三个隐患。读完本文,你将掌握 himalaya 协议命令模块的规范布局、<Domain><Target><Verb>Command 命名规则,以及共享 MessageArg 三态输入(文件路径 / -- 内联 / stdin 管道)的完整行为。
变更背景:gmail 子树为何成为特例
在本次变更之前,himalaya 的 gmail 子树把所有同资源子命令堆在一个文件里:src/gmail/messages.rs 一个文件就承载了 11 个命令、一张表与两种输出类型,共 488 行。与此同时,msgraph、imap、jmap 三个后端早已迁移到"每子命令一个文件"的形态,gmail 成了明显的异类,而且随着 gmail-structured-json-output 持续新增输出类型,文件还在不断膨胀。
更关键的是,这个特例并非孤立的组织问题,它还随身携带了三处小而深的实现分歧:
FormatArg与read_message被复制粘贴:messages、drafts、threads 三个模块各自维护了一份拷贝,改一处要同步三处;- settings 子树存在 12 个裸动词命令类型:
Get、List、Create、Delete在 delegates、filters、forwarding-addresses 等资源里各声明了三次,违反命名规则 naming-006,之所以能编译仅仅是因为模块把它们隔开了; - 原始消息输入解析各行其是:gmail 通过本地 helper 解析 raw RFC 5322 消息,而其余所有后端(shared、IMAP、JMAP、Maildir、SMTP、Graph)统一使用共享的
MessageArg。
重构目标与三大分歧
这次变更的 What 部分给出了明确的重构蓝图(见 proposal.md):
- 每个资源升级为"兄弟模块文件 + 子命令文件夹"的双层结构;
- 类型跟随拥有它们的代码迁移;
- 共享化
FormatArg与MessageArg; - 按命名规则重命名裸动词命令类型;
- 按 inline-003 清除 struct 字段与 enum 变体之间的空行。
对应地,delta.md 沉淀出三条新的 SHALL 级需求,并最终并入 cairn/spec/commands.md 成为全局规范:One module per subcommand、Command types carry a domain and a target、Raw message input is shared。
目录结构:兄弟模块 + 子命令文件夹
落地后的 gmail 子树的组织方式在 src/gmail/mod.rs 中可见全貌:attachments、backend、cli、client、drafts、format、history、labels、messages、profile、search、settings、threads 各自由一个顶层模块承载。其中每个协议命令资源遵循统一的双层布局:
- 兄弟模块文件(如 src/gmail/labels.rs):承载
pub mod子模块声明与Commandenum(如GmailLabelsCommand),以及 enum 上的execute分发逻辑; - 同名文件夹(如 src/gmail/messages/):一个子命令一个文件。以 messages 为例,落地后拆出
batch_delete.rs、batch_modify.rs、delete.rs、get.rs、import.rs、insert.rs、list.rs、modify.rs、send.rs、trash.rs、untrash.rs共 11 个文件。
settings 的九个资源(autoforwarding、delegates、filters、forwarding-addresses、imap、language、pop、sendas、vacation)同样按此拆分,见 src/gmail/settings/:每个资源既有 xxx.rs 兄弟模块文件,又有对应的 xxx/ 子命令目录。落地日志记录:整个子树从 16 个文件变为 78 个文件。
以 src/gmail/settings/sendas.rs 为例,可以看到这个布局的典型形态——模块文件声明六个子命令,enum 通过 #[command(rename_all = "kebab-case")] 保证 CLI 变体名不变,execute 把每个变体分发给对应子命令文件里的命令类型:
pub enum GmailSettingsSendAsCommand {
List(GmailSettingsSendAsListCommand),
Get(GmailSettingsSendAsGetCommand),
Create(GmailSettingsSendAsCreateCommand),
Update(GmailSettingsSendAsUpdateCommand),
#[command(visible_aliases = ["del", "remove", "rm"])]
Delete(GmailSettingsSendAsDeleteCommand),
Verify(GmailSettingsSendAsVerifyCommand),
}
类型随代码而动:输出类型归位
"Types live with the subcommand that owns them" 是本次沉淀的第一条规范的核心。此前输出类型与命令混居一个大文件,重构后按归属迁移:
MessageIdsTable移入 src/gmail/messages/list.rs(list 命令用它渲染消息 ID 表格);- 消息输出类型移入 src/gmail/messages/get.rs;
HistoryTypeArg移入 src/gmail/history/list.rs。
对于横跨多个子命令的类型,规范给出两条规则:服务于多个子命令的类型,放进"拥有它"的那个子命令文件;不属于任何单一子命令的,则拥有自己的模块。FormatArg 和过滤器摘要正是后者的例子。
FormatArg 归一:三份拷贝合一
messages get、drafts get、threads get 三个命令都向 Gmail REST API 传递同一个 format 查询参数(messages.get、drafts.get、threads.get 共用),因此此前每个模块各自复制了一份 FormatArg。重构后三份拷贝归一为 src/gmail/format.rs,由三个 get 命令共享。
该文件清晰地展示了参数到 Gmail 语义的映射:FormatArg 是 clap::ValueEnum,四个变体经 From 实现 转换为 io-gmail 的 GmailMessageFormat:
- Minimal:仅标识符与标签,不含头部与正文;
- Full:完整解析的 payload(头部、MIME 结构与正文);
- Raw:整封消息的原始 RFC 5322 字节;
- Metadata(默认):仅头部,可配合
--header进一步收窄——也是命令打印所需的最小信息集。
注意 #<a href="https://link.gitcode.com/i/9e2a559139c487cdcdd969a68a3e61ed" target="_blank">default] 标注在 Metadata 变体上([src/gmail/format.rs#L26),且 #[clap(rename_all = "kebab-case")] 保证 CLI 上以 --format metadata 这类 kebab-case 形式呈现,不改变用户可见的命令行接口。
同样被"提出重复调用点"的还有 LabelsTable::new(此前 labels 的 list 与 get 命令各自构建一次)和过滤器标准/动作摘要(移入 src/gmail/settings/filters/summary.rs)。
命名规范:<Domain><Target><Verb>Command
裸动词命令类型是本次重构最容易被低估的一处。在 settings 子树中,Get、List、Create、Delete 这些类型名在 delegates、filters、forwarding-addresses 三个资源里各声明了三次——它们只在各自模块内可见,所以能编译,但一旦有代码需要同时引用两个资源同名命令类型,命名就不可用了。
重构后这 12 个类型统一命名为 GmailSettings<Target><Verb>Command,例如 GmailSettingsDelegatesListCommand、GmailSettingsFiltersCreateCommand、GmailSettingsForwardingAddressesGetCommand。send-as 命令也在这次重构中补回了 Settings 这个 target 段(它们自己的父 enum 早已携带该语义)。
这条规则后来被 delta.md 沉淀为全局需求:协议命令类型 SHALL 命名为 <Domain><Target><Verb>Command,裸动词永远不能作为类型名。在 src/gmail/settings/sendas.rs 的 import 中可以看到完整命名样例:
use crate::gmail::settings::sendas::{
create::GmailSettingsSendAsCreateCommand, delete::GmailSettingsSendAsDeleteCommand,
get::GmailSettingsSendAsGetCommand, list::GmailSettingsSendAsListCommand,
update::GmailSettingsSendAsUpdateCommand, verify::GmailSettingsSendAsVerifyCommand,
};
共享 MessageArg:raw 消息输入的统一
第三个分歧是行为层面的:gmail 此前用本地 read_message helper 解析原始消息,而其他后端早已统一走共享 MessageArg。本次变更删除了 src/gmail/input.rs,messages 的 send/import/insert 与 drafts 的 create/update 全部改用共享 MessageArg。
src/shared/message/arg.rs 是 MessageArg 的实现(该模块说明文字写明"从 mml 移植,让 shared 命令与协议专属的 save/send 命令接受同一种三种输入形态")。其核心机制如下:
- 字段声明(src/shared/message/arg.rs#L27-L32):一个
raw = true的尾部位置参数message-raw,这让内联消息必须放在--分隔符之后; - 解析优先级(resolve 实现):位置参数优先——先尝试按文件路径读取(
path_parser判定,且"真实文件读取失败是硬错误,绝不被静默当作消息正文"),否则把参数拼接为内联消息(并把\r/\n转义解码为真实 CRLF);若位置参数为空且 stdin 非终端(即被管道输入),则从 stdin 逐行读取; - CRLF 归一化:无论来源是文件还是内联,都经过
crlf::normalize——因为 IMAPAPPEND会拒绝裸换行,统一 CRLF 是后端契约; - 空消息前置拒绝(parse 实现):解析结果若 trim 后为空则直接
bail!("Message is empty"),避免空消息进入后端后在远端以难以解读的错误失败。
src/gmail/messages/send.rs 展示了落地后的调用形态:GmailMessageSendCommand 以 #[command(flatten)] 内嵌 MessageArg,execute 中 self.message.parse()?.into_bytes() 得到字节,再经 encode_raw 交给 client.message_send。drafts 的 create/update 同样通过该参数获得输入。
表与参数不强制前缀:cli-001 的边界
值得注意的细节是:Gmail 前缀刻意没有加到表和值参数类型上。LabelsTable、MessageIdsTable、FormatArg、DispositionArg 等保持裸名。依据是 cli-001 规则——域前缀只在裸名会发生冲突时才保留,而这些类型名在整个 CLI 覆盖的多个后端之间都不冲突;恰恰相反,裸动词类型名是确定冲突的,所以它们必须重命名。这正是该规则的分界:保留前缀与否,取决于是否存在真实的命名碰撞,而不是一律加上领域前缀以示区分。
格式化清理与验证
重构还顺带执行了 inline-003 规则:清除了整个 gmail 子树中 struct 字段之间与 enum 变体之间的空行。落地日志特别注明:这只是对 gmail 子树的清扫,msgraph、imap、jmap 与 shared 等其他子树并未在同一变更中处理。
验证环节(见 落地日志)覆盖了三个层面:CLI 表面逐命令核对(clap 变体名、命令路径、别名、帮助文本全部保持原样);schema registry 仍然生成与重构前相同的 23 个 Gmail JSON Schema;构建、fmt、clippy 干净,88 个测试通过。
对用户的可见影响:-- 分隔符与三态输入
本次重构的 Scope / non-goals 明确:除 raw 消息输入外 CLI 表面不变。而 MessageArg 带来的唯一用户可见变化是——它设置 raw = true,因此 gmail 命令的内联消息现在必须放在 -- 分隔符之后,这与 shared、IMAP、JMAP、Maildir、SMTP、Graph 命令早已确立的行为一致。例如:
# 文件路径输入(Gmail 新增能力)
himalaya gmail messages send /path/to/message.eml
# 内联消息需以 -- 分隔
himalaya gmail messages send -- "From: a@b.c
Subject: hello
body"
# 管道 stdin(脚本路径,不受影响)
cat message.eml | himalaya gmail messages send
与此同时,gmail 因此获得了文件路径输入、CRLF 归一化,以及"空消息前置拒绝"(此前空消息会发送到 Gmail 后得到一个难以解读的远端错误)。由于这是对既有契约的收敛而非新契约,落地日志注明无需新增 changelog 条目。
沉淀为规范
最终,这次变更的三个核心决策没有停留在"某一次重构的局部选择",而是被写入了 cairn/spec/commands.md 的全局需求:
- One module per subcommand:协议命令资源必须采用"兄弟模块文件携带
pub mod与Commandenum + 文件夹每子命令一文件"的布局;类型随拥有它的子命令走,跨子命令共享的类型归入拥有者文件或独立模块; - Command types carry a domain and a target:命令类型必须命名为
<Domain><Target><Verb>Command,裸动词永不作为类型名;域前缀只在裸名跨后端冲突时保留,cli 子树下的表与值 enum 默认不带前缀; - Raw message input is shared:接受 raw RFC 5322 消息的命令必须通过共享
MessageArg解析——文件路径、--后的内联值或管道 stdin 三选一,解析结果归一化为 CRLF 且空消息直接拒绝,保证任何后端都收不到零长度消息。
对照 任务清单 可以看到,全部十项任务均已完成勾选并落地:模块拆分、settings 九资源拆分、FormatArg 归一、命令类型重命名、MessageArg 替换与 input.rs 删除、LabelsTable::new 与过滤器摘要去重、空行清理、schema registry 路径更新与 CLI 表面核对、构建测试格式化、以及规范折叠与日志记录。对任何希望理解 himalaya 协议命令层组织方式或参与其后续重构的开发者来说,src/gmail/ 与 src/shared/message/arg.rs 就是最直接的样板。