Skip to main content

runsible_console/
lib.rs

1//! runsible-console — interactive REPL frontend for the runsible engine.
2//!
3//! M0 surface: a `rustyline`-backed loop that parses `<module> [k=v ...]`
4//! lines and runs each as a one-task synthetic playbook against a single
5//! startup-supplied target.
6
7pub mod errors;
8pub mod parse;
9pub mod repl;
10
11pub use errors::{ConsoleError, Result};
12pub use parse::{parse_line, ReplCommand};
13pub use repl::run_repl;
14
15// ---------------------------------------------------------------------------
16// TRIPLE SIMS gate
17// ---------------------------------------------------------------------------
18
19/// Smoke gate: parse every grammar form (Quit, Empty, Comment, Invoke with
20/// args, case-insensitive Quit) through `parse_line`, verifying each variant
21/// matches the expected `ReplCommand` and the args round-trip correctly.
22/// Returns 0 on success.
23pub fn f30() -> i32 {
24    // ── Stage 1: "quit" → Quit ──────────────────────────────────────────────
25    if !matches!(parse_line("quit"), ReplCommand::Quit) {
26        return 1;
27    }
28
29    // ── Stage 2: "" → Empty ─────────────────────────────────────────────────
30    if !matches!(parse_line(""), ReplCommand::Empty) {
31        return 2;
32    }
33
34    // ── Stage 3: "# comment" → Comment ──────────────────────────────────────
35    if !matches!(parse_line("# comment"), ReplCommand::Comment) {
36        return 3;
37    }
38
39    // ── Stage 4: "debug msg=hello" → Invoke { module: "debug", args.msg=="hello" }
40    match parse_line("debug msg=hello") {
41        ReplCommand::Invoke { module, args } => {
42            if module != "debug" {
43                return 4;
44            }
45            match args.get("msg").and_then(|v| v.as_str()) {
46                Some("hello") => {}
47                _ => return 5,
48            }
49        }
50        _ => return 6,
51    }
52
53    // ── Stage 5: "EXIT" (case-insensitive) → Quit ──────────────────────────
54    if !matches!(parse_line("EXIT"), ReplCommand::Quit) {
55        return 7;
56    }
57
58    // ── Stage 6: ConsoleCompleter pulls module names from the catalog and
59    // completes a partial prefix. ──────────────────────────────────────────
60    let completer = repl::ConsoleCompleter::from_builtins();
61    let (start, candidates) = completer.complete_word("deb", 3);
62    if start != 0 {
63        return 8;
64    }
65    if !candidates.iter().any(|c| c == "debug") {
66        return 9;
67    }
68
69    // Empty prefix returns at least the catalog's known short alias for ping.
70    let (_, all) = completer.complete_word("", 0);
71    if !all.iter().any(|c| c == "ping" || c == "runsible_builtin.ping") {
72        return 10;
73    }
74
75    // Unknown prefix returns no candidates.
76    let (_, none) = completer.complete_word("xyz_no_such_prefix_zzz", 22);
77    if !none.is_empty() {
78        return 11;
79    }
80
81    0
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn parse_quit() {
90        assert!(matches!(parse_line("quit"), ReplCommand::Quit));
91        assert!(matches!(parse_line("exit"), ReplCommand::Quit));
92        assert!(matches!(parse_line("EXIT"), ReplCommand::Quit));
93        assert!(matches!(parse_line(" quit "), ReplCommand::Quit));
94        assert!(matches!(parse_line("Quit"), ReplCommand::Quit));
95    }
96
97    #[test]
98    fn parse_empty() {
99        assert!(matches!(parse_line(""), ReplCommand::Empty));
100        assert!(matches!(parse_line("   "), ReplCommand::Empty));
101        assert!(matches!(parse_line("\t\t"), ReplCommand::Empty));
102    }
103
104    #[test]
105    fn parse_comment() {
106        assert!(matches!(parse_line("# hello"), ReplCommand::Comment));
107        assert!(matches!(parse_line("#no space"), ReplCommand::Comment));
108        assert!(matches!(parse_line("   # leading whitespace"), ReplCommand::Comment));
109    }
110
111    #[test]
112    fn parse_invoke_no_args() {
113        match parse_line("runsible_builtin.ping") {
114            ReplCommand::Invoke { module, args } => {
115                assert_eq!(module, "runsible_builtin.ping");
116                let table = args.as_table().expect("args must be a table");
117                assert!(table.is_empty(), "expected empty args, got {table:?}");
118            }
119            other => panic!("expected Invoke, got {other:?}"),
120        }
121    }
122
123    #[test]
124    fn parse_invoke_kv_args() {
125        match parse_line("runsible_builtin.debug msg=hello") {
126            ReplCommand::Invoke { module, args } => {
127                assert_eq!(module, "runsible_builtin.debug");
128                assert_eq!(
129                    args.get("msg").and_then(|v| v.as_str()),
130                    Some("hello")
131                );
132            }
133            other => panic!("expected Invoke, got {other:?}"),
134        }
135    }
136
137    #[test]
138    fn parse_invoke_multi_kv() {
139        match parse_line("debug msg=hi var=x") {
140            ReplCommand::Invoke { module, args } => {
141                assert_eq!(module, "debug");
142                assert_eq!(args.get("msg").and_then(|v| v.as_str()), Some("hi"));
143                assert_eq!(args.get("var").and_then(|v| v.as_str()), Some("x"));
144            }
145            other => panic!("expected Invoke, got {other:?}"),
146        }
147    }
148
149    #[test]
150    fn parse_unknown() {
151        // "Unknown" inputs that aren't quit/exit/empty/comment fall through
152        // as Invoke calls with the first token as the module name; positional
153        // tokens without '=' are silently dropped.
154        match parse_line("garbage with spaces") {
155            ReplCommand::Invoke { module, args } => {
156                assert_eq!(module, "garbage");
157                let table = args.as_table().expect("args must be a table");
158                assert!(table.is_empty(), "non-kv tokens should be skipped");
159            }
160            other => panic!("expected Invoke for non-quit input, got {other:?}"),
161        }
162    }
163
164    /// `:quit` is NOT recognized — colon-prefixed REPL commands are a future
165    /// feature and currently fall through to `Invoke { module: ":quit" }`.
166    /// Lock that behavior in so the day we add real REPL meta-commands we
167    /// notice this test breaking.
168    #[test]
169    fn parse_colon_quit_is_unrecognized_invoke() {
170        match parse_line(":quit") {
171            ReplCommand::Invoke { module, args } => {
172                assert_eq!(module, ":quit");
173                let table = args.as_table().expect("args must be a table");
174                assert!(table.is_empty());
175            }
176            other => panic!("expected Invoke (current M0 behavior), got {other:?}"),
177        }
178    }
179
180    /// Bare module name with no args produces an Invoke whose args table is
181    /// empty.
182    #[test]
183    fn parse_alias_only_invoke_has_empty_args() {
184        match parse_line("debug") {
185            ReplCommand::Invoke { module, args } => {
186                assert_eq!(module, "debug");
187                let table = args.as_table().expect("args must be a table");
188                assert!(table.is_empty(), "expected no args; got {table:?}");
189            }
190            other => panic!("expected Invoke, got {other:?}"),
191        }
192    }
193
194    /// Quoted values containing whitespace are NOT supported by the M0
195    /// tokenizer (it splits on whitespace before key=value parsing). Lock
196    /// that limitation in so callers know what to expect.
197    #[test]
198    fn arg_with_spaces_not_supported_yet() {
199        match parse_line("debug msg=\"hello world\"") {
200            ReplCommand::Invoke { module, args } => {
201                assert_eq!(module, "debug");
202                // Whitespace splits the input — `msg` gets `"hello` as its
203                // value (with a stray opening quote) and the trailing
204                // `world"` token is dropped because it has no `=`.
205                let v = args.get("msg").and_then(|v| v.as_str()).unwrap_or("");
206                assert!(
207                    v.contains("hello") && v.contains('"'),
208                    "expected the partial-quote value to land in `msg`; got: {v:?}"
209                );
210                assert!(
211                    !v.contains("world"),
212                    "M0 tokenizer must NOT join whitespace-separated quoted args; got: {v:?}"
213                );
214            }
215            other => panic!("expected Invoke, got {other:?}"),
216        }
217    }
218
219    /// Repeated keys: last value wins (the toml::Map insert overwrites).
220    #[test]
221    fn parse_duplicate_keys_last_wins() {
222        match parse_line("debug msg=hello msg=overwritten") {
223            ReplCommand::Invoke { module, args } => {
224                assert_eq!(module, "debug");
225                assert_eq!(
226                    args.get("msg").and_then(|v| v.as_str()),
227                    Some("overwritten"),
228                    "duplicate keys: last value must win"
229                );
230            }
231            other => panic!("expected Invoke, got {other:?}"),
232        }
233    }
234
235    /// Malformed `=value` token (empty key) — the parser inserts an empty
236    /// key into the args table. Lock that behavior in.
237    #[test]
238    fn parse_malformed_token_empty_key_inserted() {
239        match parse_line("debug =bare") {
240            ReplCommand::Invoke { module, args } => {
241                assert_eq!(module, "debug");
242                let table = args.as_table().expect("args must be a table");
243                // M0 split_once('=') on "=bare" returns ("", "bare"), so we
244                // get an empty-string key. This is a known quirk; lock it in.
245                assert_eq!(
246                    table.get("").and_then(|v| v.as_str()),
247                    Some("bare"),
248                    "M0 inserts empty-key tokens; got: {table:?}"
249                );
250            }
251            other => panic!("expected Invoke, got {other:?}"),
252        }
253    }
254
255    /// Comment lines are detected even when they mention reserved words.
256    #[test]
257    fn parse_comment_with_reserved_word_in_body() {
258        assert!(matches!(
259            parse_line("# this is a comment with debug in it"),
260            ReplCommand::Comment
261        ));
262        // Trailing whitespace before `#` is also a comment.
263        assert!(matches!(
264            parse_line("   # padded"),
265            ReplCommand::Comment
266        ));
267    }
268
269    /// Mixed tabs + spaces: still Empty.
270    #[test]
271    fn parse_tabs_and_spaces_only_is_empty() {
272        assert!(matches!(parse_line("\t  "), ReplCommand::Empty));
273        assert!(matches!(parse_line(" \t \t "), ReplCommand::Empty));
274    }
275
276    /// Uppercase EXIT must be recognized — quit/exit are case-insensitive.
277    #[test]
278    fn parse_uppercase_exit_is_quit() {
279        assert!(matches!(parse_line("EXIT"), ReplCommand::Quit));
280        assert!(matches!(parse_line("ExIt"), ReplCommand::Quit));
281        assert!(matches!(parse_line("QUIT"), ReplCommand::Quit));
282    }
283}