mindfork 0.10.2

A terminal AI chat written in Rust: local models via llama.cpp or OpenAI, Anthropic, Gemini and Grok in the cloud, with persistent memory, notes, RAG and tools.
//! Parses the compaction slash-command in the input box (`/compact`). Pure,
//! testable logic modeled on [`super::reindex_command`]: the chat screen calls
//! it on send; a recognized command turns into an intent, while an unrecognized
//! string goes out as a regular message. Error messages are localized in the
//! interface language (axis B, docs/history/i18n-ui.md) — the caller passes its
//! `Locale`.
//!
//! Compaction is a chat-scoped operation, but the command is top-level rather
//! than a subcommand: it folds the earlier part of *this* conversation into a
//! rolling summary and there is nothing else to name. See
//! docs/research/history-compression.md §6.5.

use crate::shared::i18n::Locale;

/// Tries to parse an input string as the `/compact` command.
///
/// - `None` — the string isn't `/compact`: it should be sent as a regular
///   message.
/// - `Some(Ok(()))` — the command is correct (it takes no arguments).
/// - `Some(Err(msg))` — this is `/compact`, but with a syntax error (a localized
///   hint in `msg`), so a typo doesn't silently go out to the model as a chat
///   message.
pub fn parse(input: &str, loc: &Locale) -> Option<Result<(), String>> {
    let mut tokens = input.split_whitespace();
    let first = tokens.next()?;
    if !first.eq_ignore_ascii_case("/compact") {
        return None;
    }
    // The command takes no arguments. Trailing tokens are reported rather than
    // ignored: as with `/reindex`, there's no subcommand to disambiguate a typo
    // from, so silence would hide the mistake.
    match tokens.next() {
        None => Some(Ok(())),
        Some(arg) => Some(Err(loc.tf("ui.compact.bad_arg", &[("arg", arg)]))),
    }
}

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

    /// A reference locale (ru) for parse tests — the exact wording isn't asserted
    /// here (see `errors_are_localized_for_all_langs` below), only the Ok/Err shape.
    fn ru() -> &'static Locale {
        crate::shared::i18n::locale(crate::shared::i18n::Lang::Ru)
    }

    #[test]
    fn parses_bare_command() {
        assert_eq!(parse("/compact", ru()), Some(Ok(())));
    }

    #[test]
    fn surrounding_whitespace_is_tolerated() {
        assert_eq!(parse("  /compact  ", ru()), Some(Ok(())));
        assert_eq!(parse("\t/compact\n", ru()), Some(Ok(())));
    }

    #[test]
    fn command_is_case_insensitive() {
        assert_eq!(parse("/COMPACT", ru()), Some(Ok(())));
        assert_eq!(parse("  /COMPACT  ", ru()), Some(Ok(())));
        assert_eq!(parse("/Compact", ru()), Some(Ok(())));
    }

    #[test]
    fn trailing_arguments_are_rejected() {
        assert!(matches!(parse("/compact now", ru()), Some(Err(_))));
        assert!(matches!(parse("/compact --all", ru()), Some(Err(_))));
        assert!(matches!(parse("  /Compact  all  ", ru()), Some(Err(_))));
    }

    /// The offending argument is named back, not just "wrong syntax" — a typo is
    /// easier to see when the message quotes it.
    #[test]
    fn the_error_names_the_offending_argument() {
        let Some(Err(msg)) = parse("/compact nooow", ru()) else {
            panic!("expected a syntax error");
        };
        assert!(msg.contains("nooow"), "{msg}");
    }

    #[test]
    fn other_input_is_none() {
        // Neighbouring commands must keep their own meaning.
        assert_eq!(parse("/reindex", ru()), None);
        assert_eq!(parse("/rag rebuild", ru()), None);
        assert_eq!(parse("/tts stop", ru()), None);
        // A longer word that merely starts with the command name isn't it.
        assert_eq!(parse("/compactify", ru()), None);
        assert_eq!(parse("/compact-now", ru()), None);
        // Plain text and an empty string go out as regular messages.
        assert_eq!(parse("compact the history please", ru()), None);
        assert_eq!(parse("", ru()), None);
        assert_eq!(parse("   ", ru()), None);
    }

    /// Per-locale coverage (i18n gate discipline, docs/history/i18n-ui.md §3.5):
    /// the error path renders under EVERY built-in language with no unsubstituted
    /// `{…}` and, for `en`, with no Cyrillic leaking through from the ru default.
    #[test]
    fn errors_are_localized_for_all_langs() {
        for &lang in crate::shared::i18n::Lang::ALL {
            let loc = crate::shared::i18n::locale(lang);
            let Some(Err(msg)) = parse("/compact now", loc) else {
                panic!("expected a syntax error in {lang:?}");
            };
            assert!(
                !msg.contains('{') && !msg.contains('}'),
                "unsubstituted placeholder in {lang:?}: {msg}"
            );
            assert!(
                msg.contains("/compact"),
                "the message must name the command in {lang:?}: {msg}"
            );
            if lang == crate::shared::i18n::Lang::En {
                assert!(
                    !msg.chars().any(|c| ('\u{0400}'..='\u{04FF}').contains(&c)),
                    "Cyrillic leaked into the en message: {msg}"
                );
            }
        }
    }
}