agent-first-mail 0.2.1

Let your AI agent work your inbox — email pulled into plain files it reads, sorts, and drafts on your machine, with nothing sent until you confirm.
Documentation
pub fn error_hint(argv: &[String]) -> String {
    let tokens = command_tokens(argv);
    if tokens.is_empty() {
        return "try: afmail --help".to_string();
    }
    match tokens[0].as_str() {
        "case" => nested_hint("afmail case", tokens.get(1), CASE_ACTIONS),
        "message" => {
            if tokens.get(1).is_some_and(|token| token == "attachment") {
                nested_hint(
                    "afmail message attachment",
                    tokens.get(2),
                    MESSAGE_ATTACHMENT_ACTIONS,
                )
            } else {
                nested_hint("afmail message", tokens.get(1), MESSAGE_ACTIONS)
            }
        }
        "archive" => match tokens.get(1).map(String::as_str) {
            Some("message") => nested_hint(
                "afmail archive message",
                tokens.get(2),
                ARCHIVE_MESSAGE_ACTIONS,
            ),
            Some("case") => nested_hint("afmail archive case", tokens.get(2), ARCHIVE_CASE_ACTIONS),
            Some("list") => nested_hint("afmail archive list", tokens.get(2), ARCHIVE_LIST_ACTIONS),
            Some(_) => "try: afmail archive --help".to_string(),
            None => "try: afmail archive --help".to_string(),
        },
        "config" => nested_hint("afmail config", tokens.get(1), CONFIG_ACTIONS),
        "remote" => nested_hint("afmail remote", tokens.get(1), REMOTE_ACTIONS),
        "push" => nested_hint("afmail push", tokens.get(1), PUSH_ACTIONS),
        "doctor" => nested_hint("afmail doctor", tokens.get(1), DOCTOR_ACTIONS),
        "skill" => nested_hint("afmail skill", tokens.get(1), SKILL_ACTIONS),
        "triage" => nested_hint("afmail triage", tokens.get(1), TRIAGE_ACTIONS),
        "render" => nested_hint("afmail render", tokens.get(1), RENDER_ACTIONS),
        "log" => nested_hint("afmail log", tokens.get(1), LOG_ACTIONS),
        #[cfg(feature = "ui")]
        "ui" => nested_hint("afmail ui", tokens.get(1), UI_ACTIONS),
        command if ROOT_COMMANDS.contains(&command) => format!("try: afmail {command} --help"),
        _ => "try: afmail --help".to_string(),
    }
}

fn nested_hint(prefix: &str, action: Option<&String>, known_actions: &[&str]) -> String {
    if let Some(action) = action {
        if known_actions.contains(&action.as_str()) {
            return format!("try: {prefix} {action} --help");
        }
    }
    format!("try: {prefix} --help")
}

fn command_tokens(argv: &[String]) -> Vec<String> {
    let mut out = Vec::new();
    let mut iter = argv.iter().skip(1);
    while let Some(arg) = iter.next() {
        if arg == "--" {
            break;
        }
        if arg == "--output" || arg == "--log" {
            let _ = iter.next();
            continue;
        }
        if arg == "--verbose" || arg.starts_with("--output=") || arg.starts_with("--log=") {
            continue;
        }
        if arg.starts_with('-') {
            continue;
        }
        out.push(arg.clone());
    }
    out
}

#[cfg(feature = "ui")]
const ROOT_COMMANDS: &[&str] = &[
    "init", "pull", "config", "remote", "push", "status", "doctor", "purge", "skill", "triage",
    "message", "case", "archive", "render", "log", "ui",
];
#[cfg(not(feature = "ui"))]
const ROOT_COMMANDS: &[&str] = &[
    "init", "pull", "config", "remote", "push", "status", "doctor", "purge", "skill", "triage",
    "message", "case", "archive", "render", "log",
];
const CASE_ACTIONS: &[&str] = &[
    "create", "list", "show", "add", "move", "rename", "notes", "archive", "reopen", "tag",
    "untag", "draft", "merge",
];
const MESSAGE_ACTIONS: &[&str] = &["show", "spam", "trash", "restore", "attachment"];
const MESSAGE_ATTACHMENT_ACTIONS: &[&str] = &["fetch"];
const ARCHIVE_MESSAGE_ACTIONS: &[&str] = &[
    "add",
    "create",
    "show",
    "restore",
    "move",
    "rename",
    "set-summary",
    "notes",
];
const ARCHIVE_CASE_ACTIONS: &[&str] = &["show", "restore", "rename", "notes"];
const ARCHIVE_LIST_ACTIONS: &[&str] = &["cases", "messages"];
const CONFIG_ACTIONS: &[&str] = &["show", "get", "set"];
const REMOTE_ACTIONS: &[&str] = &["test", "folders"];
const PUSH_ACTIONS: &[&str] = &["list"];
const DOCTOR_ACTIONS: &[&str] = &["repair"];
const SKILL_ACTIONS: &[&str] = &["status", "install", "uninstall"];
const TRIAGE_ACTIONS: &[&str] = &["list"];
const RENDER_ACTIONS: &[&str] = &["refresh", "templates"];
const LOG_ACTIONS: &[&str] = &["list", "tail", "message", "case", "archive"];
#[cfg(feature = "ui")]
const UI_ACTIONS: &[&str] = &["snapshot"];

#[cfg(test)]
mod tests {
    use super::*;

    fn argv(args: &[&str]) -> Vec<String> {
        std::iter::once("afmail".to_string())
            .chain(args.iter().map(|arg| arg.to_string()))
            .collect()
    }

    #[test]
    fn hints_nested_command_help() {
        assert_eq!(
            error_hint(&argv(&["case", "draft", "--bad"])),
            "try: afmail case draft --help"
        );
        assert_eq!(
            error_hint(&argv(&["message", "attachment", "fetch", "--bad"])),
            "try: afmail message attachment fetch --help"
        );
    }

    #[test]
    fn skips_global_options_when_finding_command() {
        assert_eq!(
            error_hint(&argv(&[
                "--output",
                "yaml",
                "--log=startup",
                "archive",
                "message",
                "rename",
            ])),
            "try: afmail archive message rename --help"
        );
    }
}