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

joaoh82 / rust_sqlite / 25278185540

03 May 2026 11:41AM UTC coverage: 56.503% (-0.4%) from 56.877%
25278185540

Pull #76

github

web-flow
Merge d243178fb into 91ca6317d
Pull Request #76: cleanup(engine): make process_command stdout-clean (drop REPL-only prints)

20 of 30 new or added lines in 4 files covered. (66.67%)

38 existing lines in 3 files now uncovered.

5465 of 9672 relevant lines covered (56.5%)

1.14 hits per line

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

80.39
/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;
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
    /// Parsed line that didn't match any known meta-command.
30
    Unknown,
31
}
32

33
/// Trait responsible for translating type into a formated text.
34
impl fmt::Display for MetaCommand {
35
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
1✔
36
        match self {
1✔
37
            MetaCommand::Exit => f.write_str(".exit"),
×
38
            MetaCommand::Help => f.write_str(".help"),
×
39
            MetaCommand::Open(_) => f.write_str(".open"),
×
40
            MetaCommand::Save(_) => f.write_str(".save"),
×
41
            MetaCommand::Tables => f.write_str(".tables"),
×
42
            MetaCommand::Ask(_) => f.write_str(".ask"),
1✔
43
            MetaCommand::Unknown => f.write_str("Unknown command"),
×
44
        }
45
    }
46
}
47

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

74
        let args: Vec<&str> = trimmed.split_whitespace().collect();
2✔
75
        let Some(cmd) = args.first() else {
2✔
76
            return MetaCommand::Unknown;
×
77
        };
78
        match *cmd {
×
79
            ".exit" => MetaCommand::Exit,
2✔
80
            ".help" => MetaCommand::Help,
3✔
81
            ".open" => match args.get(1) {
4✔
82
                Some(path) => MetaCommand::Open(PathBuf::from(path)),
2✔
83
                None => MetaCommand::Unknown,
1✔
84
            },
85
            ".save" => match args.get(1) {
4✔
86
                Some(path) => MetaCommand::Save(PathBuf::from(path)),
2✔
87
                None => MetaCommand::Unknown,
1✔
88
            },
89
            ".tables" => MetaCommand::Tables,
×
90
            _ => MetaCommand::Unknown,
×
91
        }
92
    }
93
}
94

95
/// Executes a parsed meta-command. May mutate `db` — `.open` replaces it
96
/// with the loaded file's database; `.save` just reads it.
97
pub fn handle_meta_command(
1✔
98
    command: MetaCommand,
99
    repl: &mut Editor<REPLHelper, DefaultHistory>,
100
    db: &mut Database,
101
) -> Result<String> {
102
    match command {
1✔
103
        MetaCommand::Exit => {
104
            repl.append_history("history").unwrap();
×
105
            std::process::exit(0)
106
        }
107
        MetaCommand::Help => Ok(format!(
1✔
108
            "{}{}{}{}{}{}{}{}",
109
            "Special commands:\n",
110
            ".help            - Display this message\n",
111
            ".open <FILENAME> - Open a SQLRite database file (creates it if missing)\n",
112
            ".save <FILENAME> - Write the current in-memory database to FILENAME\n",
113
            ".tables          - List tables in the current database\n",
114
            ".ask <QUESTION>  - Generate SQL from a natural-language question (LLM)\n",
115
            ".exit            - Quit this application\n",
116
            "\nOther meta commands (.read, .ast) are not implemented yet.\n\
117
             For .ask, set SQLRITE_LLM_API_KEY in your environment first."
118
        )),
119
        MetaCommand::Open(path) => handle_open(&path, db),
1✔
120
        MetaCommand::Save(path) => handle_save(&path, db),
1✔
121
        MetaCommand::Tables => handle_tables(db),
1✔
122
        MetaCommand::Ask(question) => handle_ask(&question, repl, db),
×
123
        MetaCommand::Unknown => Err(SQLRiteError::UnknownCommand(
×
124
            "Unknown command or invalid arguments. Enter '.help'".to_string(),
×
125
        )),
126
    }
127
}
128

129
fn handle_open(path: &Path, db: &mut Database) -> Result<String> {
1✔
130
    let db_name = path
2✔
131
        .file_stem()
132
        .and_then(|s| s.to_str())
3✔
133
        .unwrap_or("db")
134
        .to_string();
135
    if path.exists() {
3✔
136
        let loaded = open_database(path, db_name)?;
2✔
137
        let table_count = loaded.tables.len();
2✔
138
        *db = loaded;
1✔
139
        Ok(format!(
1✔
140
            "Opened '{}' ({table_count} table{s} loaded). Auto-save enabled.",
141
            path.display(),
1✔
142
            s = if table_count == 1 { "" } else { "s" }
1✔
143
        ))
144
    } else {
145
        // Same behavior as SQLite: `.open` on a missing file creates a fresh
146
        // DB that will be materialized on the next committing statement.
147
        let mut fresh = Database::new(db_name);
2✔
148
        fresh.source_path = Some(path.to_path_buf());
2✔
149
        // Touch the file with a valid empty DB so the path now exists and a
150
        // subsequent `.open` finds it. This also catches permission errors early
151
        // and attaches the long-lived pager to the fresh database.
152
        save_database(&mut fresh, path)?;
1✔
153
        *db = fresh;
1✔
154
        Ok(format!(
1✔
155
            "Opened '{}' (new database). Auto-save enabled.",
156
            path.display()
1✔
157
        ))
158
    }
159
}
160

161
fn handle_save(path: &Path, db: &mut Database) -> Result<String> {
1✔
162
    save_database(db, path)?;
1✔
163
    if db.source_path.as_deref() == Some(path) {
2✔
164
        Ok(format!(
×
165
            "Flushed database to '{}' (auto-save is already on).",
166
            path.display()
×
167
        ))
168
    } else {
169
        Ok(format!("Saved database to '{}'.", path.display()))
1✔
170
    }
171
}
172

173
fn handle_tables(db: &Database) -> Result<String> {
1✔
174
    if db.tables.is_empty() {
1✔
175
        return Ok("(no tables)".to_string());
1✔
176
    }
177
    // Sort for deterministic output — HashMap iteration order is arbitrary.
178
    let mut names: Vec<&String> = db.tables.keys().collect();
1✔
179
    names.sort();
2✔
180
    Ok(names
3✔
181
        .into_iter()
1✔
182
        .map(|s| s.as_str())
3✔
183
        .collect::<Vec<&str>>()
1✔
184
        .join("\n"))
1✔
185
}
186

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

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

219
    if resp.sql.trim().is_empty() {
×
220
        // Model decided the schema can't answer this question — surface
221
        // its explanation rather than silently producing nothing.
222
        return Ok(format!(
×
223
            "The model declined to generate SQL for that question.\n\
224
             Reason: {}",
225
            if resp.explanation.is_empty() {
×
226
                "(no explanation provided)"
×
227
            } else {
228
                resp.explanation.as_str()
×
229
            }
230
        ));
231
    }
232

233
    println!("Generated SQL:");
×
234
    println!("  {}", resp.sql);
×
235
    if !resp.explanation.is_empty() {
×
236
        println!("Rationale: {}", resp.explanation);
×
237
    }
238

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

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

266
#[cfg(test)]
267
mod tests {
268
    use super::*;
269
    use crate::repl::{REPLHelper, get_config};
270
    use sqlrite::process_command;
271

272
    fn new_editor() -> Editor<REPLHelper, DefaultHistory> {
1✔
273
        let config = get_config();
1✔
274
        let helper = REPLHelper::default();
1✔
275
        let mut repl: Editor<REPLHelper, DefaultHistory> =
2✔
276
            Editor::with_config(config).expect("failed to build rustyline editor");
277
        repl.set_helper(Some(helper));
1✔
278
        repl
1✔
279
    }
280

281
    fn tmp_path(name: &str) -> PathBuf {
1✔
282
        let mut p = std::env::temp_dir();
1✔
283
        let pid = std::process::id();
2✔
284
        let nanos = std::time::SystemTime::now()
2✔
285
            .duration_since(std::time::UNIX_EPOCH)
1✔
286
            .map(|d| d.as_nanos())
3✔
287
            .unwrap_or(0);
288
        p.push(format!("sqlrite-meta-{pid}-{nanos}-{name}.sqlrite"));
1✔
289
        p
1✔
290
    }
291

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

301
    #[test]
302
    fn help_works() {
3✔
303
        let mut repl = new_editor();
1✔
304
        let mut db = Database::new("x".to_string());
2✔
305
        let result = handle_meta_command(MetaCommand::Help, &mut repl, &mut db);
1✔
306
        assert!(result.is_ok());
2✔
307
    }
308

309
    #[test]
310
    fn parse_open_requires_argument() {
3✔
311
        assert_eq!(MetaCommand::new(".open".to_string()), MetaCommand::Unknown);
1✔
312
        assert_eq!(
1✔
313
            MetaCommand::new(".open my.sqlrite".to_string()),
1✔
314
            MetaCommand::Open(PathBuf::from("my.sqlrite"))
2✔
315
        );
316
    }
317

318
    #[test]
319
    fn parse_save_requires_argument() {
3✔
320
        assert_eq!(MetaCommand::new(".save".to_string()), MetaCommand::Unknown);
1✔
321
        assert_eq!(
1✔
322
            MetaCommand::new(".save my.sqlrite".to_string()),
1✔
323
            MetaCommand::Save(PathBuf::from("my.sqlrite"))
2✔
324
        );
325
    }
326

327
    #[test]
328
    fn parse_ask_captures_question_verbatim() {
3✔
329
        // Bare `.ask` is invalid — must have a question.
330
        assert_eq!(MetaCommand::new(".ask".to_string()), MetaCommand::Unknown);
1✔
331
        // `.ask` with empty trailing whitespace is also invalid.
332
        assert_eq!(
1✔
333
            MetaCommand::new(".ask   ".to_string()),
1✔
334
            MetaCommand::Unknown
335
        );
336
        // Valid question — captured verbatim, including punctuation.
337
        assert_eq!(
1✔
338
            MetaCommand::new(".ask How many users are over 30?".to_string()),
1✔
339
            MetaCommand::Ask("How many users are over 30?".to_string())
2✔
340
        );
341
        // Multiple internal spaces are preserved (after the leading
342
        // ".ask " strip + trim).
343
        assert_eq!(
1✔
344
            MetaCommand::new(".ask  show me   users".to_string()),
1✔
345
            MetaCommand::Ask("show me   users".to_string())
2✔
346
        );
347
        // Tab separator works.
348
        assert_eq!(
1✔
349
            MetaCommand::new(".ask\tcount rows".to_string()),
1✔
350
            MetaCommand::Ask("count rows".to_string())
2✔
351
        );
352
    }
353

354
    #[test]
355
    fn parse_ask_rejects_no_separator() {
3✔
356
        // `.askfoo` should NOT match `.ask` — it's a typo, not a
357
        // question. Without this guard, every `.askXXX` line would be
358
        // treated as the question "XXX" with no separator.
359
        assert_eq!(
1✔
360
            MetaCommand::new(".askfoo".to_string()),
1✔
361
            MetaCommand::Unknown
362
        );
363
        assert_eq!(
1✔
364
            MetaCommand::new(".asking".to_string()),
1✔
365
            MetaCommand::Unknown
366
        );
367
    }
368

369
    #[test]
370
    fn ask_meta_command_displays_as_dotask() {
3✔
371
        let cmd = MetaCommand::Ask("anything".to_string());
1✔
372
        assert_eq!(format!("{cmd}"), ".ask");
2✔
373
    }
374

375
    #[test]
376
    fn tables_meta_command() {
3✔
377
        let mut repl = new_editor();
1✔
378
        let mut db = Database::new("x".to_string());
2✔
379
        // Empty case.
380
        let msg = handle_meta_command(MetaCommand::Tables, &mut repl, &mut db).unwrap();
2✔
381
        assert_eq!(msg, "(no tables)");
2✔
382

383
        // Populated case — two tables, output should be sorted.
384
        process_command("CREATE TABLE zebras (id INTEGER PRIMARY KEY);", &mut db).unwrap();
1✔
385
        process_command("CREATE TABLE apples (id INTEGER PRIMARY KEY);", &mut db).unwrap();
1✔
386
        let msg = handle_meta_command(MetaCommand::Tables, &mut repl, &mut db).unwrap();
1✔
387
        assert_eq!(msg, "apples\nzebras");
2✔
388
    }
389

390
    #[test]
391
    fn save_then_open_round_trips_through_meta_commands() {
3✔
392
        use sqlrite::sql::db::table::Value;
393

394
        let path = tmp_path("meta_roundtrip");
1✔
395
        let mut repl = new_editor();
1✔
396
        let mut db = Database::new("x".to_string());
2✔
397

398
        process_command(
399
            "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL);",
400
            &mut db,
401
        )
402
        .unwrap();
403
        process_command("INSERT INTO users (name) VALUES ('alice');", &mut db).unwrap();
1✔
404

405
        handle_meta_command(MetaCommand::Save(path.clone()), &mut repl, &mut db).expect("save");
1✔
406

407
        // Replace db with a fresh one, then .open the file.
408
        db = Database::new("fresh".to_string());
1✔
409
        let msg =
1✔
410
            handle_meta_command(MetaCommand::Open(path.clone()), &mut repl, &mut db).expect("open");
411
        assert!(msg.contains("1 table loaded"));
2✔
412

413
        let users = db.get_table("users".to_string()).unwrap();
1✔
414
        let rowids = users.rowids();
1✔
415
        assert_eq!(rowids.len(), 1);
2✔
416
        assert_eq!(
1✔
417
            users.get_value("name", rowids[0]),
1✔
418
            Some(Value::Text("alice".to_string()))
2✔
419
        );
420

421
        cleanup(&path);
1✔
422
    }
423

424
    #[test]
425
    fn open_missing_file_creates_fresh_db_and_materializes_file() {
3✔
426
        let path = tmp_path("missing");
1✔
427
        let mut repl = new_editor();
1✔
428
        let mut db = Database::new("x".to_string());
2✔
429

430
        let msg =
2✔
431
            handle_meta_command(MetaCommand::Open(path.clone()), &mut repl, &mut db).expect("open");
432
        assert!(msg.contains("new database"));
2✔
433
        assert_eq!(db.tables.len(), 0);
1✔
434
        // Auto-save expects a file to exist to auto-flush into, so open-of-missing
435
        // touches the file with a valid empty DB.
436
        assert!(path.exists());
1✔
437
        assert_eq!(db.source_path.as_deref(), Some(path.as_path()));
1✔
438

439
        cleanup(&path);
1✔
440
    }
441

442
    #[test]
443
    fn auto_save_persists_writes_without_explicit_save() {
3✔
444
        use sqlrite::sql::db::table::Value;
445

446
        let path = tmp_path("autosave");
1✔
447
        let mut repl = new_editor();
1✔
448
        let mut db = Database::new("x".to_string());
2✔
449

450
        handle_meta_command(MetaCommand::Open(path.clone()), &mut repl, &mut db).expect("open");
2✔
451

452
        // The first write should auto-flush to disk.
453
        process_command(
454
            "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);",
455
            &mut db,
456
        )
457
        .unwrap();
458
        process_command("INSERT INTO users (name) VALUES ('alice');", &mut db).unwrap();
1✔
459

460
        // Drop the first Database so its exclusive lock releases before we
461
        // reopen the same file for verification.
462
        drop(db);
1✔
463

464
        // Reopen the file from scratch in a fresh Database — no manual .save was called.
465
        let fresh = sqlrite::sql::pager::open_database(&path, "x".to_string())
1✔
466
            .expect("open after auto-save");
467
        let users = fresh.get_table("users".to_string()).expect("users table");
2✔
468
        let rowids = users.rowids();
1✔
469
        assert_eq!(rowids.len(), 1);
2✔
470
        assert_eq!(
1✔
471
            users.get_value("name", rowids[0]),
1✔
472
            Some(Value::Text("alice".to_string()))
2✔
473
        );
474

475
        cleanup(&path);
1✔
476
    }
477
}
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