sqlrite-engine 0.10.0

Light version of SQLite developed with Rust. Published as `sqlrite-engine` on crates.io; import as `use sqlrite::…`.
Documentation
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
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
use std::fmt;
use std::path::{Path, PathBuf};

use rustyline::Editor;
use rustyline::history::DefaultHistory;

use crate::repl::{REPLHelper, ReplState};
use sqlrite::error::{Result, SQLRiteError};
use sqlrite::sql::db::database::Database;
use sqlrite::sql::pager::{open_database, save_database};
use sqlrite::{ask::ask_with_database, process_command_with_render};
use sqlrite_ask::AskConfig;

#[derive(Debug, PartialEq)]
pub enum MetaCommand {
    Exit,
    Help,
    /// `.open FILENAME` — create or load a persistent database.
    Open(PathBuf),
    /// `.save FILENAME` — write the current database to disk.
    Save(PathBuf),
    /// `.tables` — list the tables in the current database.
    Tables,
    /// `.ask <question>` — natural-language → SQL via the
    /// configured LLM. The rest of the line after `.ask ` becomes
    /// the question text (verbatim — including punctuation, quotes,
    /// etc.). See [`handle_ask`] for the confirm-and-run UX.
    Ask(String),
    /// `.spawn` — Phase 11.11a — mint a sibling [`Connection`]
    /// sharing the same `Arc<Mutex<Database>>` and switch to it.
    /// Enables interactive `BEGIN CONCURRENT` demos in the REPL.
    Spawn,
    /// `.use NAME` — Phase 11.11a — switch the active handle to
    /// the one whose display name matches `NAME` (case-insensitive).
    Use(String),
    /// `.conns` — Phase 11.11a — list every active handle, with a
    /// marker showing the current one and a `(tx)` flag per handle
    /// in an open `BEGIN CONCURRENT`.
    Conns,
    /// Parsed line that didn't match any known meta-command.
    Unknown,
}

/// Trait responsible for translating type into a formated text.
impl fmt::Display for MetaCommand {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            MetaCommand::Exit => f.write_str(".exit"),
            MetaCommand::Help => f.write_str(".help"),
            MetaCommand::Open(_) => f.write_str(".open"),
            MetaCommand::Save(_) => f.write_str(".save"),
            MetaCommand::Tables => f.write_str(".tables"),
            MetaCommand::Ask(_) => f.write_str(".ask"),
            MetaCommand::Spawn => f.write_str(".spawn"),
            MetaCommand::Use(_) => f.write_str(".use"),
            MetaCommand::Conns => f.write_str(".conns"),
            MetaCommand::Unknown => f.write_str("Unknown command"),
        }
    }
}

impl MetaCommand {
    pub fn new(command: String) -> MetaCommand {
        let trimmed = command.trim_end();
        // `.ask` is parsed by stripping the prefix and keeping the
        // rest of the line verbatim — every other meta-command splits
        // on whitespace, but a natural-language question can contain
        // arbitrary punctuation, multiple spaces, quoted phrases,
        // etc., and we don't want to molest any of it.
        if let Some(rest) = trimmed.strip_prefix(".ask") {
            // Require at least one whitespace between `.ask` and the
            // question — `.askfoo` is Unknown, `.ask foo` is the
            // question "foo".
            return match rest.chars().next() {
                Some(c) if c.is_whitespace() => {
                    let q = rest.trim().to_string();
                    if q.is_empty() {
                        MetaCommand::Unknown
                    } else {
                        MetaCommand::Ask(q)
                    }
                }
                None => MetaCommand::Unknown, // bare ".ask" with no question
                Some(_) => MetaCommand::Unknown, // ".askfoo"
            };
        }

        let args: Vec<&str> = trimmed.split_whitespace().collect();
        let Some(cmd) = args.first() else {
            return MetaCommand::Unknown;
        };
        match *cmd {
            ".exit" => MetaCommand::Exit,
            ".help" => MetaCommand::Help,
            ".open" => match args.get(1) {
                Some(path) => MetaCommand::Open(PathBuf::from(path)),
                None => MetaCommand::Unknown,
            },
            ".save" => match args.get(1) {
                Some(path) => MetaCommand::Save(PathBuf::from(path)),
                None => MetaCommand::Unknown,
            },
            ".tables" => MetaCommand::Tables,
            ".spawn" => MetaCommand::Spawn,
            ".conns" => MetaCommand::Conns,
            ".use" => match args.get(1) {
                Some(name) => MetaCommand::Use(name.to_string()),
                None => MetaCommand::Unknown,
            },
            _ => MetaCommand::Unknown,
        }
    }
}

/// Executes a parsed meta-command. May mutate the active handle's
/// underlying `Database` (`.open` swaps it; `.save` reads it) or the
/// REPL state itself (`.spawn`, `.use`, `.conns`).
pub fn handle_meta_command(
    command: MetaCommand,
    repl: &mut Editor<REPLHelper, DefaultHistory>,
    state: &mut ReplState,
) -> Result<String> {
    match command {
        MetaCommand::Exit => {
            repl.append_history("history").unwrap();
            std::process::exit(0)
        }
        MetaCommand::Help => Ok(format!(
            "{}{}{}{}{}{}{}{}{}{}{}{}",
            "Special commands:\n",
            ".help            - Display this message\n",
            ".open <FILENAME> - Open a SQLRite database file (creates it if missing)\n",
            ".save <FILENAME> - Write the current in-memory database to FILENAME\n",
            ".tables          - List tables in the current database\n",
            ".ask <QUESTION>  - Generate SQL from a natural-language question (LLM)\n",
            ".spawn           - Mint a sibling connection sharing this database\n",
            ".use <NAME>      - Switch the active handle (A, B, …) — see .conns\n",
            ".conns           - List every handle, marking the active one\n",
            ".exit            - Quit this application\n",
            "\nMulti-handle (.spawn / .use / .conns) is Phase 11.11a — drives\n\
             interactive BEGIN CONCURRENT demos. Every sibling handle shares\n\
             the same backing Database via Arc<Mutex<_>>.\n",
            "\nOther meta commands (.read, .ast) are not implemented yet.\n\
             For .ask, set SQLRITE_LLM_API_KEY in your environment first."
        )),
        MetaCommand::Open(path) => {
            // `.open` replaces the underlying Database, which strands
            // any sibling pointing at the old one. Collapse to a
            // single handle so the new file has a clean owner.
            state.collapse_to_active();
            let mut db = state.lock_active();
            handle_open(&path, &mut db)
        }
        MetaCommand::Save(path) => {
            let mut db = state.lock_active();
            handle_save(&path, &mut db)
        }
        MetaCommand::Tables => {
            let db = state.lock_active();
            handle_tables(&db)
        }
        MetaCommand::Ask(question) => {
            let mut db = state.lock_active();
            handle_ask(&question, repl, &mut db)
        }
        MetaCommand::Spawn => handle_spawn(state),
        MetaCommand::Use(name) => handle_use(&name, state),
        MetaCommand::Conns => Ok(handle_conns(state)),
        MetaCommand::Unknown => Err(SQLRiteError::UnknownCommand(
            "Unknown command or invalid arguments. Enter '.help'".to_string(),
        )),
    }
}

fn handle_spawn(state: &mut ReplState) -> Result<String> {
    let name = state.spawn_sibling();
    Ok(format!(
        "Spawned sibling handle '{name}' and switched to it. \
         {n} handles open. Use '.use NAME' to switch back.",
        n = state.handle_count()
    ))
}

fn handle_use(target: &str, state: &mut ReplState) -> Result<String> {
    match state.use_handle(target) {
        Ok(name) => Ok(format!("Active handle: '{name}'.")),
        Err(msg) => Err(SQLRiteError::General(msg)),
    }
}

fn handle_conns(state: &ReplState) -> String {
    let active = state.active_name().to_string();
    let mut lines = Vec::with_capacity(state.handles_summary().len() + 1);
    lines.push(format!("{} handle(s):", state.handles_summary().len()));
    for (name, in_tx) in state.handles_summary() {
        let marker = if name == active { "*" } else { " " };
        let tx_note = if in_tx { " (BEGIN CONCURRENT)" } else { "" };
        lines.push(format!("  {marker} {name}{tx_note}"));
    }
    lines.join("\n")
}

fn handle_open(path: &Path, db: &mut Database) -> Result<String> {
    let db_name = path
        .file_stem()
        .and_then(|s| s.to_str())
        .unwrap_or("db")
        .to_string();
    if path.exists() {
        let loaded = open_database(path, db_name)?;
        let table_count = loaded.tables.len();
        *db = loaded;
        Ok(format!(
            "Opened '{}' ({table_count} table{s} loaded). Auto-save enabled.",
            path.display(),
            s = if table_count == 1 { "" } else { "s" }
        ))
    } else {
        // Same behavior as SQLite: `.open` on a missing file creates a fresh
        // DB that will be materialized on the next committing statement.
        let mut fresh = Database::new(db_name);
        fresh.source_path = Some(path.to_path_buf());
        // Touch the file with a valid empty DB so the path now exists and a
        // subsequent `.open` finds it. This also catches permission errors early
        // and attaches the long-lived pager to the fresh database.
        save_database(&mut fresh, path)?;
        *db = fresh;
        Ok(format!(
            "Opened '{}' (new database). Auto-save enabled.",
            path.display()
        ))
    }
}

fn handle_save(path: &Path, db: &mut Database) -> Result<String> {
    save_database(db, path)?;
    if db.source_path.as_deref() == Some(path) {
        Ok(format!(
            "Flushed database to '{}' (auto-save is already on).",
            path.display()
        ))
    } else {
        Ok(format!("Saved database to '{}'.", path.display()))
    }
}

fn handle_tables(db: &Database) -> Result<String> {
    if db.tables.is_empty() {
        return Ok("(no tables)".to_string());
    }
    // Sort for deterministic output — HashMap iteration order is arbitrary.
    let mut names: Vec<&String> = db.tables.keys().collect();
    names.sort();
    Ok(names
        .into_iter()
        .map(|s| s.as_str())
        .collect::<Vec<&str>>()
        .join("\n"))
}

/// Handle `.ask <question>` — confirm-and-run UX:
///
/// 1. Build an `AskConfig` from the environment (`SQLRITE_LLM_*` vars).
/// 2. Call into [`sqlrite::ask::ask_with_database`] — generates SQL.
/// 3. Print the generated SQL + the model's one-sentence rationale.
/// 4. Prompt `Run? [Y/n] ` via rustyline. Empty / `y` / `yes` → run,
///    `n` / `no` → skip. Anything else also skips (paranoid default).
/// 5. If confirmed, run the SQL through `process_command` (the same
///    pipeline as a typed-out `SELECT` / `INSERT` / etc.) and return
///    its result string. If skipped, return a short "skipped" note.
///
/// Returns the rendered output string for the outer dispatch loop to
/// print. The rendered output already includes the SQL preview, the
/// rationale, and either the query result table or the skip message.
fn handle_ask(
    question: &str,
    repl: &mut Editor<REPLHelper, DefaultHistory>,
    db: &mut Database,
) -> Result<String> {
    // Read env-var config. Surfaces a friendly error if e.g.
    // SQLRITE_LLM_CACHE_TTL holds an unrecognized value. A missing
    // SQLRITE_LLM_API_KEY is *not* surfaced here — `from_env` returns
    // Ok(_) with `api_key: None`, and `ask_with_database` then fails
    // with `AskError::MissingApiKey` so the user gets a clear
    // "missing API key (set SQLRITE_LLM_API_KEY)" message instead
    // of "config error".
    let cfg: AskConfig =
        AskConfig::from_env().map_err(|e| SQLRiteError::General(format!("ask: {e}")))?;

    let resp = ask_with_database(db, question, &cfg)
        .map_err(|e| SQLRiteError::General(format!("ask: {e}")))?;

    if resp.sql.trim().is_empty() {
        // Model decided the schema can't answer this question — surface
        // its explanation rather than silently producing nothing.
        return Ok(format!(
            "The model declined to generate SQL for that question.\n\
             Reason: {}",
            if resp.explanation.is_empty() {
                "(no explanation provided)"
            } else {
                resp.explanation.as_str()
            }
        ));
    }

    println!("Generated SQL:");
    println!("  {}", resp.sql);
    if !resp.explanation.is_empty() {
        println!("Rationale: {}", resp.explanation);
    }

    // Confirm-and-run prompt. We use the same rustyline editor so
    // history works across the prompt; `readline` blocks until the
    // user submits a line. Ctrl-C / EOF map to the same "skip" path
    // as a `n` answer — refusing on interrupt is the safer default
    // when running LLM-generated SQL.
    let answer = match repl.readline("Run? [Y/n] ") {
        Ok(s) => s.trim().to_lowercase(),
        Err(_) => return Ok("Skipped (input interrupted).".to_string()),
    };
    let confirmed = matches!(answer.as_str(), "" | "y" | "yes");
    if !confirmed {
        return Ok("Skipped.".to_string());
    }

    // Run the generated SQL through the same pipeline as a typed
    // statement. We use the `_with_render` variant so SELECTs come back
    // with their rendered prettytable; concatenate it above the status
    // line so the REPL's outer dispatch (which just prints whatever
    // string we return) shows both. DDL/DML statements return only a
    // status — `rendered` is `None` and we skip the prepend.
    let output = process_command_with_render(&resp.sql, db)?;
    Ok(match output.rendered {
        Some(rendered) => format!("{rendered}{}", output.status),
        None => output.status,
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::repl::{REPLHelper, get_config};
    use sqlrite::Connection;
    use sqlrite::process_command;

    fn new_editor() -> Editor<REPLHelper, DefaultHistory> {
        let config = get_config();
        let helper = REPLHelper::default();
        let mut repl: Editor<REPLHelper, DefaultHistory> =
            Editor::with_config(config).expect("failed to build rustyline editor");
        repl.set_helper(Some(helper));
        repl
    }

    fn new_state() -> ReplState {
        ReplState::new(Connection::open_in_memory().expect("in-memory open"))
    }

    fn tmp_path(name: &str) -> PathBuf {
        let mut p = std::env::temp_dir();
        let pid = std::process::id();
        let nanos = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_nanos())
            .unwrap_or(0);
        p.push(format!("sqlrite-meta-{pid}-{nanos}-{name}.sqlrite"));
        p
    }

    /// Phase 4c: every .sqlrite has a `-wal` sidecar now. Delete both so
    /// `/tmp` doesn't accumulate orphan WALs across test runs.
    fn cleanup(path: &std::path::Path) {
        let _ = std::fs::remove_file(path);
        let mut wal = path.as_os_str().to_owned();
        wal.push("-wal");
        let _ = std::fs::remove_file(PathBuf::from(wal));
    }

    #[test]
    fn help_works() {
        let mut repl = new_editor();
        let mut state = new_state();
        let result = handle_meta_command(MetaCommand::Help, &mut repl, &mut state);
        assert!(result.is_ok());
    }

    #[test]
    fn parse_open_requires_argument() {
        assert_eq!(MetaCommand::new(".open".to_string()), MetaCommand::Unknown);
        assert_eq!(
            MetaCommand::new(".open my.sqlrite".to_string()),
            MetaCommand::Open(PathBuf::from("my.sqlrite"))
        );
    }

    #[test]
    fn parse_save_requires_argument() {
        assert_eq!(MetaCommand::new(".save".to_string()), MetaCommand::Unknown);
        assert_eq!(
            MetaCommand::new(".save my.sqlrite".to_string()),
            MetaCommand::Save(PathBuf::from("my.sqlrite"))
        );
    }

    #[test]
    fn parse_ask_captures_question_verbatim() {
        // Bare `.ask` is invalid — must have a question.
        assert_eq!(MetaCommand::new(".ask".to_string()), MetaCommand::Unknown);
        // `.ask` with empty trailing whitespace is also invalid.
        assert_eq!(
            MetaCommand::new(".ask   ".to_string()),
            MetaCommand::Unknown
        );
        // Valid question — captured verbatim, including punctuation.
        assert_eq!(
            MetaCommand::new(".ask How many users are over 30?".to_string()),
            MetaCommand::Ask("How many users are over 30?".to_string())
        );
        // Multiple internal spaces are preserved (after the leading
        // ".ask " strip + trim).
        assert_eq!(
            MetaCommand::new(".ask  show me   users".to_string()),
            MetaCommand::Ask("show me   users".to_string())
        );
        // Tab separator works.
        assert_eq!(
            MetaCommand::new(".ask\tcount rows".to_string()),
            MetaCommand::Ask("count rows".to_string())
        );
    }

    #[test]
    fn parse_ask_rejects_no_separator() {
        // `.askfoo` should NOT match `.ask` — it's a typo, not a
        // question. Without this guard, every `.askXXX` line would be
        // treated as the question "XXX" with no separator.
        assert_eq!(
            MetaCommand::new(".askfoo".to_string()),
            MetaCommand::Unknown
        );
        assert_eq!(
            MetaCommand::new(".asking".to_string()),
            MetaCommand::Unknown
        );
    }

    #[test]
    fn ask_meta_command_displays_as_dotask() {
        let cmd = MetaCommand::Ask("anything".to_string());
        assert_eq!(format!("{cmd}"), ".ask");
    }

    #[test]
    fn tables_meta_command() {
        let mut repl = new_editor();
        let mut state = new_state();
        // Empty case.
        let msg = handle_meta_command(MetaCommand::Tables, &mut repl, &mut state).unwrap();
        assert_eq!(msg, "(no tables)");

        // Populated case — two tables, output should be sorted.
        {
            let mut db = state.lock_active();
            process_command("CREATE TABLE zebras (id INTEGER PRIMARY KEY);", &mut db).unwrap();
            process_command("CREATE TABLE apples (id INTEGER PRIMARY KEY);", &mut db).unwrap();
        }
        let msg = handle_meta_command(MetaCommand::Tables, &mut repl, &mut state).unwrap();
        assert_eq!(msg, "apples\nzebras");
    }

    #[test]
    fn save_then_open_round_trips_through_meta_commands() {
        use sqlrite::sql::db::table::Value;

        let path = tmp_path("meta_roundtrip");
        let mut repl = new_editor();
        let mut state = new_state();

        {
            let mut db = state.lock_active();
            process_command(
                "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL);",
                &mut db,
            )
            .unwrap();
            process_command("INSERT INTO users (name) VALUES ('alice');", &mut db).unwrap();
        }

        handle_meta_command(MetaCommand::Save(path.clone()), &mut repl, &mut state).expect("save");

        // Replace state with a fresh one, then .open the file.
        state = new_state();
        let msg = handle_meta_command(MetaCommand::Open(path.clone()), &mut repl, &mut state)
            .expect("open");
        assert!(msg.contains("1 table loaded"));

        let db = state.lock_active();
        let users = db.get_table("users".to_string()).unwrap();
        let rowids = users.rowids();
        assert_eq!(rowids.len(), 1);
        assert_eq!(
            users.get_value("name", rowids[0]),
            Some(Value::Text("alice".to_string()))
        );
        drop(db);

        cleanup(&path);
    }

    #[test]
    fn open_missing_file_creates_fresh_db_and_materializes_file() {
        let path = tmp_path("missing");
        let mut repl = new_editor();
        let mut state = new_state();

        let msg = handle_meta_command(MetaCommand::Open(path.clone()), &mut repl, &mut state)
            .expect("open");
        assert!(msg.contains("new database"));
        let db = state.lock_active();
        assert_eq!(db.tables.len(), 0);
        // Auto-save expects a file to exist to auto-flush into, so open-of-missing
        // touches the file with a valid empty DB.
        assert!(path.exists());
        assert_eq!(db.source_path.as_deref(), Some(path.as_path()));
        drop(db);

        cleanup(&path);
    }

    #[test]
    fn auto_save_persists_writes_without_explicit_save() {
        use sqlrite::sql::db::table::Value;

        let path = tmp_path("autosave");
        let mut repl = new_editor();
        let mut state = new_state();

        handle_meta_command(MetaCommand::Open(path.clone()), &mut repl, &mut state).expect("open");

        // The first write should auto-flush to disk.
        {
            let mut db = state.lock_active();
            process_command(
                "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);",
                &mut db,
            )
            .unwrap();
            process_command("INSERT INTO users (name) VALUES ('alice');", &mut db).unwrap();
        }

        // Drop the state (and thus the connection holding the
        // pager's exclusive lock) before we reopen the same file
        // for verification.
        drop(state);

        let fresh = sqlrite::sql::pager::open_database(&path, "x".to_string())
            .expect("open after auto-save");
        let users = fresh.get_table("users".to_string()).expect("users table");
        let rowids = users.rowids();
        assert_eq!(rowids.len(), 1);
        assert_eq!(
            users.get_value("name", rowids[0]),
            Some(Value::Text("alice".to_string()))
        );

        cleanup(&path);
    }

    // ----- Phase 11.11a multi-handle tests -----------------------

    #[test]
    fn parse_spawn_no_arg() {
        assert_eq!(MetaCommand::new(".spawn".to_string()), MetaCommand::Spawn);
        // Trailing whitespace is fine.
        assert_eq!(
            MetaCommand::new(".spawn   ".to_string()),
            MetaCommand::Spawn
        );
    }

    #[test]
    fn parse_use_requires_argument() {
        assert_eq!(MetaCommand::new(".use".to_string()), MetaCommand::Unknown);
        assert_eq!(
            MetaCommand::new(".use B".to_string()),
            MetaCommand::Use("B".to_string())
        );
        assert_eq!(
            MetaCommand::new(".use b".to_string()),
            MetaCommand::Use("b".to_string())
        );
    }

    #[test]
    fn parse_conns_no_arg() {
        assert_eq!(MetaCommand::new(".conns".to_string()), MetaCommand::Conns);
    }

    #[test]
    fn spawn_creates_sibling_and_switches() {
        let mut repl = new_editor();
        let mut state = new_state();
        assert_eq!(state.active_name(), "A");

        let msg = handle_meta_command(MetaCommand::Spawn, &mut repl, &mut state).expect("spawn ok");
        assert!(msg.contains("'B'"));
        assert!(msg.contains("2 handles"));
        assert_eq!(state.active_name(), "B");

        // .use A switches back.
        let msg = handle_meta_command(MetaCommand::Use("A".to_string()), &mut repl, &mut state)
            .expect("use ok");
        assert!(msg.contains("'A'"));
        assert_eq!(state.active_name(), "A");
    }

    #[test]
    fn use_is_case_insensitive() {
        let mut repl = new_editor();
        let mut state = new_state();
        handle_meta_command(MetaCommand::Spawn, &mut repl, &mut state).unwrap();
        // Now active is "B"; switch back via lowercase.
        let msg = handle_meta_command(MetaCommand::Use("a".to_string()), &mut repl, &mut state)
            .expect("lowercase use should match A");
        assert!(msg.contains("'A'"));
    }

    #[test]
    fn use_unknown_handle_errors_with_valid_list() {
        let mut repl = new_editor();
        let mut state = new_state();
        let err = handle_meta_command(MetaCommand::Use("Z".to_string()), &mut repl, &mut state)
            .unwrap_err();
        let s = format!("{err}");
        assert!(s.contains("no handle named 'Z'"));
        assert!(s.contains("current handles: A"));
    }

    #[test]
    fn conns_reports_active_and_count() {
        let mut repl = new_editor();
        let mut state = new_state();
        handle_meta_command(MetaCommand::Spawn, &mut repl, &mut state).unwrap();
        let msg = handle_meta_command(MetaCommand::Conns, &mut repl, &mut state).expect("conns ok");
        assert!(msg.contains("2 handle(s):"));
        // Active is B (spawn switched to it); marked with `*`.
        assert!(msg.lines().any(|l| l.contains("* B")));
        assert!(msg.lines().any(|l| l.starts_with("    A")));
    }

    #[test]
    fn siblings_share_underlying_database() {
        let mut repl = new_editor();
        let mut state = new_state();
        // Create a table on handle A.
        {
            let mut db = state.lock_active();
            process_command(
                "CREATE TABLE t (id INTEGER PRIMARY KEY, v INTEGER);",
                &mut db,
            )
            .unwrap();
            process_command("INSERT INTO t (id, v) VALUES (1, 100);", &mut db).unwrap();
        }
        handle_meta_command(MetaCommand::Spawn, &mut repl, &mut state).unwrap();
        // From handle B, the same row is visible.
        let db = state.lock_active();
        let t = db.get_table("t".to_string()).expect("t visible on B");
        assert_eq!(t.rowids().len(), 1);
    }

    #[test]
    fn open_collapses_to_single_handle() {
        let path = tmp_path("open_collapses");
        let mut repl = new_editor();
        let mut state = new_state();
        handle_meta_command(MetaCommand::Spawn, &mut repl, &mut state).unwrap();
        handle_meta_command(MetaCommand::Spawn, &mut repl, &mut state).unwrap();
        // 3 handles, active is "C".
        assert_eq!(state.handle_count(), 3);
        assert_eq!(state.active_name(), "C");

        // .open should collapse to 1 handle, renamed to A.
        handle_meta_command(MetaCommand::Open(path.clone()), &mut repl, &mut state).expect("open");
        assert_eq!(state.handle_count(), 1);
        assert_eq!(state.active_name(), "A");

        drop(state);
        cleanup(&path);
    }

    #[test]
    fn handle_name_sequence_past_z_wraps_to_aa() {
        // The Phase 11.11a roadmap caps interactive demos at a few
        // siblings, but the naming scheme should at least not panic
        // past 26. Test 27 -> AA.
        let mut state = new_state();
        // Spawn 26 siblings -> Z is the 26th. The 27th becomes AA.
        for _ in 0..26 {
            state.spawn_sibling();
        }
        // 27 total handles now (A + 26 siblings).
        assert_eq!(state.handle_count(), 27);
        assert_eq!(state.active_name(), "AA");
    }
}