1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
//! 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}"
);
}
}
}
}