• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

joaoh82 / rust_sqlite / 25661912235

11 May 2026 09:28AM UTC coverage: 69.041% (+0.2%) from 68.807%
25661912235

Pull #131

github

web-flow
Merge 7c3fbd645 into 0b969f656
Pull Request #131: feat(repl): Phase 11.11a REPL .spawn for interactive BEGIN CONCURRENT demos (SQLR-22)

187 of 232 new or added lines in 4 files covered. (80.6%)

2 existing lines in 1 file now uncovered.

11148 of 16147 relevant lines covered (69.04%)

1.24 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

86.5
/src/meta_command/mod.rs
1
use std::fmt;
2
use std::path::{Path, PathBuf};
3

4
use rustyline::Editor;
5
use rustyline::history::DefaultHistory;
6

7
use crate::repl::{REPLHelper, ReplState};
8
use sqlrite::error::{Result, SQLRiteError};
9
use sqlrite::sql::db::database::Database;
10
use sqlrite::sql::pager::{open_database, save_database};
11
use sqlrite::{ask::ask_with_database, process_command_with_render};
12
use sqlrite_ask::AskConfig;
13

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

44
/// Trait responsible for translating type into a formated text.
45
impl fmt::Display for MetaCommand {
46
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
1✔
47
        match self {
1✔
48
            MetaCommand::Exit => f.write_str(".exit"),
×
49
            MetaCommand::Help => f.write_str(".help"),
×
50
            MetaCommand::Open(_) => f.write_str(".open"),
×
51
            MetaCommand::Save(_) => f.write_str(".save"),
×
52
            MetaCommand::Tables => f.write_str(".tables"),
×
53
            MetaCommand::Ask(_) => f.write_str(".ask"),
1✔
NEW
54
            MetaCommand::Spawn => f.write_str(".spawn"),
×
NEW
55
            MetaCommand::Use(_) => f.write_str(".use"),
×
NEW
56
            MetaCommand::Conns => f.write_str(".conns"),
×
UNCOV
57
            MetaCommand::Unknown => f.write_str("Unknown command"),
×
58
        }
59
    }
60
}
61

62
impl MetaCommand {
63
    pub fn new(command: String) -> MetaCommand {
1✔
64
        let trimmed = command.trim_end();
2✔
65
        // `.ask` is parsed by stripping the prefix and keeping the
66
        // rest of the line verbatim — every other meta-command splits
67
        // on whitespace, but a natural-language question can contain
68
        // arbitrary punctuation, multiple spaces, quoted phrases,
69
        // etc., and we don't want to molest any of it.
70
        if let Some(rest) = trimmed.strip_prefix(".ask") {
1✔
71
            // Require at least one whitespace between `.ask` and the
72
            // question — `.askfoo` is Unknown, `.ask foo` is the
73
            // question "foo".
74
            return match rest.chars().next() {
2✔
75
                Some(c) if c.is_whitespace() => {
3✔
76
                    let q = rest.trim().to_string();
1✔
77
                    if q.is_empty() {
3✔
78
                        MetaCommand::Unknown
×
79
                    } else {
80
                        MetaCommand::Ask(q)
1✔
81
                    }
82
                }
83
                None => MetaCommand::Unknown, // bare ".ask" with no question
1✔
84
                Some(_) => MetaCommand::Unknown, // ".askfoo"
1✔
85
            };
86
        }
87

88
        let args: Vec<&str> = trimmed.split_whitespace().collect();
2✔
89
        let Some(cmd) = args.first() else {
2✔
90
            return MetaCommand::Unknown;
×
91
        };
92
        match *cmd {
×
93
            ".exit" => MetaCommand::Exit,
2✔
94
            ".help" => MetaCommand::Help,
3✔
95
            ".open" => match args.get(1) {
4✔
96
                Some(path) => MetaCommand::Open(PathBuf::from(path)),
2✔
97
                None => MetaCommand::Unknown,
1✔
98
            },
99
            ".save" => match args.get(1) {
4✔
100
                Some(path) => MetaCommand::Save(PathBuf::from(path)),
2✔
101
                None => MetaCommand::Unknown,
1✔
102
            },
103
            ".tables" => MetaCommand::Tables,
2✔
104
            ".spawn" => MetaCommand::Spawn,
3✔
105
            ".conns" => MetaCommand::Conns,
3✔
106
            ".use" => match args.get(1) {
4✔
107
                Some(name) => MetaCommand::Use(name.to_string()),
2✔
108
                None => MetaCommand::Unknown,
1✔
109
            },
UNCOV
110
            _ => MetaCommand::Unknown,
×
111
        }
112
    }
113
}
114

115
/// Executes a parsed meta-command. May mutate the active handle's
116
/// underlying `Database` (`.open` swaps it; `.save` reads it) or the
117
/// REPL state itself (`.spawn`, `.use`, `.conns`).
118
pub fn handle_meta_command(
1✔
119
    command: MetaCommand,
120
    repl: &mut Editor<REPLHelper, DefaultHistory>,
121
    state: &mut ReplState,
122
) -> Result<String> {
123
    match command {
1✔
124
        MetaCommand::Exit => {
125
            repl.append_history("history").unwrap();
×
126
            std::process::exit(0)
127
        }
128
        MetaCommand::Help => Ok(format!(
1✔
129
            "{}{}{}{}{}{}{}{}{}{}{}{}",
130
            "Special commands:\n",
131
            ".help            - Display this message\n",
132
            ".open <FILENAME> - Open a SQLRite database file (creates it if missing)\n",
133
            ".save <FILENAME> - Write the current in-memory database to FILENAME\n",
134
            ".tables          - List tables in the current database\n",
135
            ".ask <QUESTION>  - Generate SQL from a natural-language question (LLM)\n",
136
            ".spawn           - Mint a sibling connection sharing this database\n",
137
            ".use <NAME>      - Switch the active handle (A, B, …) — see .conns\n",
138
            ".conns           - List every handle, marking the active one\n",
139
            ".exit            - Quit this application\n",
140
            "\nMulti-handle (.spawn / .use / .conns) is Phase 11.11a — drives\n\
141
             interactive BEGIN CONCURRENT demos. Every sibling handle shares\n\
142
             the same backing Database via Arc<Mutex<_>>.\n",
143
            "\nOther meta commands (.read, .ast) are not implemented yet.\n\
144
             For .ask, set SQLRITE_LLM_API_KEY in your environment first."
145
        )),
146
        MetaCommand::Open(path) => {
1✔
147
            // `.open` replaces the underlying Database, which strands
148
            // any sibling pointing at the old one. Collapse to a
149
            // single handle so the new file has a clean owner.
150
            state.collapse_to_active();
1✔
151
            let mut db = state.lock_active();
1✔
152
            handle_open(&path, &mut db)
2✔
153
        }
154
        MetaCommand::Save(path) => {
1✔
155
            let mut db = state.lock_active();
2✔
156
            handle_save(&path, &mut db)
2✔
157
        }
158
        MetaCommand::Tables => {
159
            let db = state.lock_active();
1✔
160
            handle_tables(&db)
2✔
161
        }
NEW
162
        MetaCommand::Ask(question) => {
×
NEW
163
            let mut db = state.lock_active();
×
NEW
164
            handle_ask(&question, repl, &mut db)
×
165
        }
166
        MetaCommand::Spawn => handle_spawn(state),
1✔
167
        MetaCommand::Use(name) => handle_use(&name, state),
1✔
168
        MetaCommand::Conns => Ok(handle_conns(state)),
1✔
169
        MetaCommand::Unknown => Err(SQLRiteError::UnknownCommand(
×
170
            "Unknown command or invalid arguments. Enter '.help'".to_string(),
×
171
        )),
172
    }
173
}
174

175
fn handle_spawn(state: &mut ReplState) -> Result<String> {
1✔
176
    let name = state.spawn_sibling();
1✔
177
    Ok(format!(
1✔
178
        "Spawned sibling handle '{name}' and switched to it. \
179
         {n} handles open. Use '.use NAME' to switch back.",
180
        n = state.handle_count()
2✔
181
    ))
182
}
183

184
fn handle_use(target: &str, state: &mut ReplState) -> Result<String> {
1✔
185
    match state.use_handle(target) {
1✔
186
        Ok(name) => Ok(format!("Active handle: '{name}'.")),
1✔
187
        Err(msg) => Err(SQLRiteError::General(msg)),
1✔
188
    }
189
}
190

191
fn handle_conns(state: &ReplState) -> String {
1✔
192
    let active = state.active_name().to_string();
1✔
193
    let mut lines = Vec::with_capacity(state.handles_summary().len() + 1);
2✔
194
    lines.push(format!("{} handle(s):", state.handles_summary().len()));
1✔
195
    for (name, in_tx) in state.handles_summary() {
2✔
196
        let marker = if name == active { "*" } else { " " };
2✔
197
        let tx_note = if in_tx { " (BEGIN CONCURRENT)" } else { "" };
1✔
198
        lines.push(format!("  {marker} {name}{tx_note}"));
1✔
199
    }
200
    lines.join("\n")
1✔
201
}
202

203
fn handle_open(path: &Path, db: &mut Database) -> Result<String> {
1✔
204
    let db_name = path
2✔
205
        .file_stem()
206
        .and_then(|s| s.to_str())
3✔
207
        .unwrap_or("db")
208
        .to_string();
209
    if path.exists() {
3✔
210
        let loaded = open_database(path, db_name)?;
2✔
211
        let table_count = loaded.tables.len();
2✔
212
        *db = loaded;
1✔
213
        Ok(format!(
1✔
214
            "Opened '{}' ({table_count} table{s} loaded). Auto-save enabled.",
215
            path.display(),
1✔
216
            s = if table_count == 1 { "" } else { "s" }
1✔
217
        ))
218
    } else {
219
        // Same behavior as SQLite: `.open` on a missing file creates a fresh
220
        // DB that will be materialized on the next committing statement.
221
        let mut fresh = Database::new(db_name);
2✔
222
        fresh.source_path = Some(path.to_path_buf());
2✔
223
        // Touch the file with a valid empty DB so the path now exists and a
224
        // subsequent `.open` finds it. This also catches permission errors early
225
        // and attaches the long-lived pager to the fresh database.
226
        save_database(&mut fresh, path)?;
1✔
227
        *db = fresh;
1✔
228
        Ok(format!(
1✔
229
            "Opened '{}' (new database). Auto-save enabled.",
230
            path.display()
1✔
231
        ))
232
    }
233
}
234

235
fn handle_save(path: &Path, db: &mut Database) -> Result<String> {
1✔
236
    save_database(db, path)?;
1✔
237
    if db.source_path.as_deref() == Some(path) {
2✔
238
        Ok(format!(
×
239
            "Flushed database to '{}' (auto-save is already on).",
240
            path.display()
×
241
        ))
242
    } else {
243
        Ok(format!("Saved database to '{}'.", path.display()))
1✔
244
    }
245
}
246

247
fn handle_tables(db: &Database) -> Result<String> {
1✔
248
    if db.tables.is_empty() {
1✔
249
        return Ok("(no tables)".to_string());
1✔
250
    }
251
    // Sort for deterministic output — HashMap iteration order is arbitrary.
252
    let mut names: Vec<&String> = db.tables.keys().collect();
1✔
253
    names.sort();
2✔
254
    Ok(names
3✔
255
        .into_iter()
1✔
256
        .map(|s| s.as_str())
3✔
257
        .collect::<Vec<&str>>()
1✔
258
        .join("\n"))
1✔
259
}
260

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

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

293
    if resp.sql.trim().is_empty() {
×
294
        // Model decided the schema can't answer this question — surface
295
        // its explanation rather than silently producing nothing.
296
        return Ok(format!(
×
297
            "The model declined to generate SQL for that question.\n\
298
             Reason: {}",
299
            if resp.explanation.is_empty() {
×
300
                "(no explanation provided)"
×
301
            } else {
302
                resp.explanation.as_str()
×
303
            }
304
        ));
305
    }
306

307
    println!("Generated SQL:");
×
308
    println!("  {}", resp.sql);
×
309
    if !resp.explanation.is_empty() {
×
310
        println!("Rationale: {}", resp.explanation);
×
311
    }
312

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

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

340
#[cfg(test)]
341
mod tests {
342
    use super::*;
343
    use crate::repl::{REPLHelper, get_config};
344
    use sqlrite::Connection;
345
    use sqlrite::process_command;
346

347
    fn new_editor() -> Editor<REPLHelper, DefaultHistory> {
1✔
348
        let config = get_config();
1✔
349
        let helper = REPLHelper::default();
1✔
350
        let mut repl: Editor<REPLHelper, DefaultHistory> =
2✔
351
            Editor::with_config(config).expect("failed to build rustyline editor");
352
        repl.set_helper(Some(helper));
1✔
353
        repl
1✔
354
    }
355

356
    fn new_state() -> ReplState {
1✔
357
        ReplState::new(Connection::open_in_memory().expect("in-memory open"))
1✔
358
    }
359

360
    fn tmp_path(name: &str) -> PathBuf {
1✔
361
        let mut p = std::env::temp_dir();
1✔
362
        let pid = std::process::id();
2✔
363
        let nanos = std::time::SystemTime::now()
2✔
364
            .duration_since(std::time::UNIX_EPOCH)
1✔
365
            .map(|d| d.as_nanos())
3✔
366
            .unwrap_or(0);
367
        p.push(format!("sqlrite-meta-{pid}-{nanos}-{name}.sqlrite"));
1✔
368
        p
1✔
369
    }
370

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

380
    #[test]
381
    fn help_works() {
3✔
382
        let mut repl = new_editor();
1✔
383
        let mut state = new_state();
1✔
384
        let result = handle_meta_command(MetaCommand::Help, &mut repl, &mut state);
1✔
385
        assert!(result.is_ok());
2✔
386
    }
387

388
    #[test]
389
    fn parse_open_requires_argument() {
3✔
390
        assert_eq!(MetaCommand::new(".open".to_string()), MetaCommand::Unknown);
1✔
391
        assert_eq!(
1✔
392
            MetaCommand::new(".open my.sqlrite".to_string()),
1✔
393
            MetaCommand::Open(PathBuf::from("my.sqlrite"))
2✔
394
        );
395
    }
396

397
    #[test]
398
    fn parse_save_requires_argument() {
3✔
399
        assert_eq!(MetaCommand::new(".save".to_string()), MetaCommand::Unknown);
1✔
400
        assert_eq!(
1✔
401
            MetaCommand::new(".save my.sqlrite".to_string()),
1✔
402
            MetaCommand::Save(PathBuf::from("my.sqlrite"))
2✔
403
        );
404
    }
405

406
    #[test]
407
    fn parse_ask_captures_question_verbatim() {
3✔
408
        // Bare `.ask` is invalid — must have a question.
409
        assert_eq!(MetaCommand::new(".ask".to_string()), MetaCommand::Unknown);
1✔
410
        // `.ask` with empty trailing whitespace is also invalid.
411
        assert_eq!(
1✔
412
            MetaCommand::new(".ask   ".to_string()),
1✔
413
            MetaCommand::Unknown
414
        );
415
        // Valid question — captured verbatim, including punctuation.
416
        assert_eq!(
1✔
417
            MetaCommand::new(".ask How many users are over 30?".to_string()),
1✔
418
            MetaCommand::Ask("How many users are over 30?".to_string())
2✔
419
        );
420
        // Multiple internal spaces are preserved (after the leading
421
        // ".ask " strip + trim).
422
        assert_eq!(
1✔
423
            MetaCommand::new(".ask  show me   users".to_string()),
1✔
424
            MetaCommand::Ask("show me   users".to_string())
2✔
425
        );
426
        // Tab separator works.
427
        assert_eq!(
1✔
428
            MetaCommand::new(".ask\tcount rows".to_string()),
1✔
429
            MetaCommand::Ask("count rows".to_string())
2✔
430
        );
431
    }
432

433
    #[test]
434
    fn parse_ask_rejects_no_separator() {
3✔
435
        // `.askfoo` should NOT match `.ask` — it's a typo, not a
436
        // question. Without this guard, every `.askXXX` line would be
437
        // treated as the question "XXX" with no separator.
438
        assert_eq!(
1✔
439
            MetaCommand::new(".askfoo".to_string()),
1✔
440
            MetaCommand::Unknown
441
        );
442
        assert_eq!(
1✔
443
            MetaCommand::new(".asking".to_string()),
1✔
444
            MetaCommand::Unknown
445
        );
446
    }
447

448
    #[test]
449
    fn ask_meta_command_displays_as_dotask() {
3✔
450
        let cmd = MetaCommand::Ask("anything".to_string());
1✔
451
        assert_eq!(format!("{cmd}"), ".ask");
2✔
452
    }
453

454
    #[test]
455
    fn tables_meta_command() {
3✔
456
        let mut repl = new_editor();
1✔
457
        let mut state = new_state();
1✔
458
        // Empty case.
459
        let msg = handle_meta_command(MetaCommand::Tables, &mut repl, &mut state).unwrap();
2✔
460
        assert_eq!(msg, "(no tables)");
2✔
461

462
        // Populated case — two tables, output should be sorted.
463
        {
464
            let mut db = state.lock_active();
1✔
465
            process_command("CREATE TABLE zebras (id INTEGER PRIMARY KEY);", &mut db).unwrap();
2✔
466
            process_command("CREATE TABLE apples (id INTEGER PRIMARY KEY);", &mut db).unwrap();
1✔
467
        }
468
        let msg = handle_meta_command(MetaCommand::Tables, &mut repl, &mut state).unwrap();
1✔
469
        assert_eq!(msg, "apples\nzebras");
2✔
470
    }
471

472
    #[test]
473
    fn save_then_open_round_trips_through_meta_commands() {
3✔
474
        use sqlrite::sql::db::table::Value;
475

476
        let path = tmp_path("meta_roundtrip");
1✔
477
        let mut repl = new_editor();
1✔
478
        let mut state = new_state();
1✔
479

480
        {
481
            let mut db = state.lock_active();
2✔
482
            process_command(
483
                "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL);",
484
                &mut db,
1✔
485
            )
486
            .unwrap();
487
            process_command("INSERT INTO users (name) VALUES ('alice');", &mut db).unwrap();
1✔
488
        }
489

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

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

498
        let db = state.lock_active();
1✔
499
        let users = db.get_table("users".to_string()).unwrap();
2✔
500
        let rowids = users.rowids();
1✔
501
        assert_eq!(rowids.len(), 1);
2✔
502
        assert_eq!(
1✔
503
            users.get_value("name", rowids[0]),
1✔
504
            Some(Value::Text("alice".to_string()))
2✔
505
        );
506
        drop(db);
1✔
507

508
        cleanup(&path);
1✔
509
    }
510

511
    #[test]
512
    fn open_missing_file_creates_fresh_db_and_materializes_file() {
3✔
513
        let path = tmp_path("missing");
1✔
514
        let mut repl = new_editor();
1✔
515
        let mut state = new_state();
1✔
516

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

528
        cleanup(&path);
1✔
529
    }
530

531
    #[test]
532
    fn auto_save_persists_writes_without_explicit_save() {
3✔
533
        use sqlrite::sql::db::table::Value;
534

535
        let path = tmp_path("autosave");
1✔
536
        let mut repl = new_editor();
1✔
537
        let mut state = new_state();
1✔
538

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

541
        // The first write should auto-flush to disk.
542
        {
543
            let mut db = state.lock_active();
1✔
544
            process_command(
545
                "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);",
546
                &mut db,
1✔
547
            )
548
            .unwrap();
549
            process_command("INSERT INTO users (name) VALUES ('alice');", &mut db).unwrap();
1✔
550
        }
551

552
        // Drop the state (and thus the connection holding the
553
        // pager's exclusive lock) before we reopen the same file
554
        // for verification.
555
        drop(state);
1✔
556

557
        let fresh = sqlrite::sql::pager::open_database(&path, "x".to_string())
1✔
558
            .expect("open after auto-save");
559
        let users = fresh.get_table("users".to_string()).expect("users table");
2✔
560
        let rowids = users.rowids();
1✔
561
        assert_eq!(rowids.len(), 1);
2✔
562
        assert_eq!(
1✔
563
            users.get_value("name", rowids[0]),
1✔
564
            Some(Value::Text("alice".to_string()))
2✔
565
        );
566

567
        cleanup(&path);
1✔
568
    }
569

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

572
    #[test]
573
    fn parse_spawn_no_arg() {
3✔
574
        assert_eq!(MetaCommand::new(".spawn".to_string()), MetaCommand::Spawn);
1✔
575
        // Trailing whitespace is fine.
576
        assert_eq!(
1✔
577
            MetaCommand::new(".spawn   ".to_string()),
1✔
578
            MetaCommand::Spawn
579
        );
580
    }
581

582
    #[test]
583
    fn parse_use_requires_argument() {
3✔
584
        assert_eq!(MetaCommand::new(".use".to_string()), MetaCommand::Unknown);
1✔
585
        assert_eq!(
1✔
586
            MetaCommand::new(".use B".to_string()),
1✔
587
            MetaCommand::Use("B".to_string())
2✔
588
        );
589
        assert_eq!(
1✔
590
            MetaCommand::new(".use b".to_string()),
1✔
591
            MetaCommand::Use("b".to_string())
2✔
592
        );
593
    }
594

595
    #[test]
596
    fn parse_conns_no_arg() {
4✔
597
        assert_eq!(MetaCommand::new(".conns".to_string()), MetaCommand::Conns);
1✔
598
    }
599

600
    #[test]
601
    fn spawn_creates_sibling_and_switches() {
3✔
602
        let mut repl = new_editor();
1✔
603
        let mut state = new_state();
1✔
604
        assert_eq!(state.active_name(), "A");
2✔
605

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

611
        // .use A switches back.
612
        let msg = handle_meta_command(MetaCommand::Use("A".to_string()), &mut repl, &mut state)
1✔
613
            .expect("use ok");
614
        assert!(msg.contains("'A'"));
2✔
615
        assert_eq!(state.active_name(), "A");
1✔
616
    }
617

618
    #[test]
619
    fn use_is_case_insensitive() {
3✔
620
        let mut repl = new_editor();
1✔
621
        let mut state = new_state();
1✔
622
        handle_meta_command(MetaCommand::Spawn, &mut repl, &mut state).unwrap();
2✔
623
        // Now active is "B"; switch back via lowercase.
624
        let msg = handle_meta_command(MetaCommand::Use("a".to_string()), &mut repl, &mut state)
1✔
625
            .expect("lowercase use should match A");
626
        assert!(msg.contains("'A'"));
2✔
627
    }
628

629
    #[test]
630
    fn use_unknown_handle_errors_with_valid_list() {
3✔
631
        let mut repl = new_editor();
1✔
632
        let mut state = new_state();
1✔
633
        let err = handle_meta_command(MetaCommand::Use("Z".to_string()), &mut repl, &mut state)
2✔
634
            .unwrap_err();
635
        let s = format!("{err}");
2✔
636
        assert!(s.contains("no handle named 'Z'"));
2✔
637
        assert!(s.contains("current handles: A"));
1✔
638
    }
639

640
    #[test]
641
    fn conns_reports_active_and_count() {
3✔
642
        let mut repl = new_editor();
1✔
643
        let mut state = new_state();
1✔
644
        handle_meta_command(MetaCommand::Spawn, &mut repl, &mut state).unwrap();
2✔
645
        let msg = handle_meta_command(MetaCommand::Conns, &mut repl, &mut state).expect("conns ok");
1✔
646
        assert!(msg.contains("2 handle(s):"));
2✔
647
        // Active is B (spawn switched to it); marked with `*`.
648
        assert!(msg.lines().any(|l| l.contains("* B")));
3✔
649
        assert!(msg.lines().any(|l| l.starts_with("    A")));
3✔
650
    }
651

652
    #[test]
653
    fn siblings_share_underlying_database() {
3✔
654
        let mut repl = new_editor();
1✔
655
        let mut state = new_state();
1✔
656
        // Create a table on handle A.
657
        {
658
            let mut db = state.lock_active();
2✔
659
            process_command(
660
                "CREATE TABLE t (id INTEGER PRIMARY KEY, v INTEGER);",
661
                &mut db,
1✔
662
            )
663
            .unwrap();
664
            process_command("INSERT INTO t (id, v) VALUES (1, 100);", &mut db).unwrap();
1✔
665
        }
666
        handle_meta_command(MetaCommand::Spawn, &mut repl, &mut state).unwrap();
1✔
667
        // From handle B, the same row is visible.
668
        let db = state.lock_active();
1✔
669
        let t = db.get_table("t".to_string()).expect("t visible on B");
2✔
670
        assert_eq!(t.rowids().len(), 1);
1✔
671
    }
672

673
    #[test]
674
    fn open_collapses_to_single_handle() {
3✔
675
        let path = tmp_path("open_collapses");
1✔
676
        let mut repl = new_editor();
1✔
677
        let mut state = new_state();
1✔
678
        handle_meta_command(MetaCommand::Spawn, &mut repl, &mut state).unwrap();
2✔
679
        handle_meta_command(MetaCommand::Spawn, &mut repl, &mut state).unwrap();
1✔
680
        // 3 handles, active is "C".
681
        assert_eq!(state.handle_count(), 3);
1✔
682
        assert_eq!(state.active_name(), "C");
1✔
683

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

689
        drop(state);
1✔
690
        cleanup(&path);
1✔
691
    }
692

693
    #[test]
694
    fn handle_name_sequence_past_z_wraps_to_aa() {
3✔
695
        // The Phase 11.11a roadmap caps interactive demos at a few
696
        // siblings, but the naming scheme should at least not panic
697
        // past 26. Test 27 -> AA.
698
        let mut state = new_state();
1✔
699
        // Spawn 26 siblings -> Z is the 26th. The 27th becomes AA.
700
        for _ in 0..26 {
2✔
701
            state.spawn_sibling();
2✔
702
        }
703
        // 27 total handles now (A + 26 siblings).
704
        assert_eq!(state.handle_count(), 27);
1✔
705
        assert_eq!(state.active_name(), "AA");
1✔
706
    }
707
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc