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

joaoh82 / rust_sqlite / 25190382873

30 Apr 2026 09:32PM UTC coverage: 58.9% (-8.9%) from 67.839%
25190382873

push

github

web-flow
Phase 7g.2: REPL .ask + dep-direction flip (#60)

Wires the REPL's `.ask <question>` meta-command — the smallest
per-product wrapper sub-phase, and the natural validation that
sqlrite-ask 0.1.18's foundational shape fits the consumer side.
Required a structural refactor 7g.1 didn't anticipate; this commit
lands both pieces together because they're inseparable.

## What ships

REPL UX (sqlrite> prompt):

    sqlrite> .ask How many users are over 30?
    Generated SQL:
      SELECT COUNT(*) FROM users WHERE age > 30
    Rationale: Counts users older than thirty.
    Run? [Y/n] y
    +-------+
    | count |
    +-------+
    | 47    |
    +-------+
    SELECT Statement executed. 1 row returned.

`.ask` parses verbatim (rest of line after `.ask `, preserving
quotes/punctuation/multiple spaces — natural-language questions
need that). Confirm-and-run via rustyline; empty/y/yes runs through
the same `process_command` pipeline as a typed statement. Ctrl-C /
EOF / `n` map to skip — paranoid default for LLM-generated SQL.
Empty SQL response (model declined) surfaces the model's
explanation rather than silently doing nothing.

`.help` updated. New tests in `src/meta_command/`: `parse_ask_*`
(verbatim capture, separator handling), `ask_meta_command_displays_*`.

## Why the dep-direction flip

The REPL binary needed to import `sqlrite-ask` to call ask().
But `sqlrite-ask` 0.1.18 imported `sqlrite-engine` (for
Connection/Database integration and the ConnectionAskExt trait).
That's a cargo cycle:

    sqlrite-engine[bin] → sqlrite-ask → sqlrite-engine[lib]

Cargo's static cycle detection counts every potential edge in
the dep graph regardless of features — `optional = true` on
either side doesn't escape it. Verified empirically:
`cargo check -p sqlrite-engine --no-default-features` errors
with `cyclic package dependency` even when the optional dep
isn't enabled.

The fix flipped the dep direction structurally:

  - `sqlrite-ask` 0.1.19 dropped `sqlrite-engine` entir... (continued)

43 of 80 new or added lines in 5 files covered. (53.75%)

1458 existing lines in 5 files now uncovered.

5496 of 9331 relevant lines covered (58.9%)

1.21 hits per line

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

82.09
/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};
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✔
UNCOV
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✔
NEW
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✔
NEW
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.
201
fn handle_ask(
1✔
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".
NEW
213
    let cfg: AskConfig =
×
214
        AskConfig::from_env().map_err(|e| SQLRiteError::General(format!("ask: {e}")))?;
215

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

NEW
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.
NEW
222
        return Ok(format!(
×
223
            "The model declined to generate SQL for that question.\n\
224
             Reason: {}",
NEW
225
            if resp.explanation.is_empty() {
×
NEW
226
                "(no explanation provided)"
×
227
            } else {
NEW
228
                resp.explanation.as_str()
×
229
            }
230
        ));
231
    }
232

NEW
233
    println!("Generated SQL:");
×
NEW
234
    println!("  {}", resp.sql);
×
NEW
235
    if !resp.explanation.is_empty() {
×
NEW
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.
NEW
244
    let answer = match repl.readline("Run? [Y/n] ") {
×
NEW
245
        Ok(s) => s.trim().to_lowercase(),
×
NEW
246
        Err(_) => return Ok("Skipped (input interrupted).".to_string()),
×
247
    };
NEW
248
    let confirmed = matches!(answer.as_str(), "" | "y" | "yes");
×
NEW
249
    if !confirmed {
×
NEW
250
        return Ok("Skipped.".to_string());
×
251
    }
252

253
    // Run the generated SQL through the same pipeline as a typed
254
    // statement. process_command handles both DDL/DML (returns a
255
    // status string like "INSERT Statement executed.") and SELECT
256
    // (returns a rendered table). Either is fine to return up to the
257
    // outer dispatch loop, which just prints what we hand back.
NEW
258
    process_command(&resp.sql, db)
×
259
}
260

261
#[cfg(test)]
262
mod tests {
263
    use super::*;
264
    use crate::repl::{REPLHelper, get_config};
265
    use sqlrite::process_command;
266

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

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

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

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

304
    #[test]
305
    fn parse_open_requires_argument() {
3✔
306
        assert_eq!(MetaCommand::new(".open".to_string()), MetaCommand::Unknown);
1✔
307
        assert_eq!(
1✔
308
            MetaCommand::new(".open my.sqlrite".to_string()),
1✔
309
            MetaCommand::Open(PathBuf::from("my.sqlrite"))
2✔
310
        );
311
    }
312

313
    #[test]
314
    fn parse_save_requires_argument() {
3✔
315
        assert_eq!(MetaCommand::new(".save".to_string()), MetaCommand::Unknown);
1✔
316
        assert_eq!(
1✔
317
            MetaCommand::new(".save my.sqlrite".to_string()),
1✔
318
            MetaCommand::Save(PathBuf::from("my.sqlrite"))
2✔
319
        );
320
    }
321

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

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

364
    #[test]
365
    fn ask_meta_command_displays_as_dotask() {
3✔
366
        let cmd = MetaCommand::Ask("anything".to_string());
1✔
367
        assert_eq!(format!("{cmd}"), ".ask");
2✔
368
    }
369

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

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

385
    #[test]
386
    fn save_then_open_round_trips_through_meta_commands() {
3✔
387
        use sqlrite::sql::db::table::Value;
388

389
        let path = tmp_path("meta_roundtrip");
1✔
390
        let mut repl = new_editor();
1✔
391
        let mut db = Database::new("x".to_string());
2✔
392

393
        process_command(
394
            "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL);",
395
            &mut db,
396
        )
397
        .unwrap();
398
        process_command("INSERT INTO users (name) VALUES ('alice');", &mut db).unwrap();
1✔
399

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

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

408
        let users = db.get_table("users".to_string()).unwrap();
1✔
409
        let rowids = users.rowids();
1✔
410
        assert_eq!(rowids.len(), 1);
2✔
411
        assert_eq!(
1✔
412
            users.get_value("name", rowids[0]),
1✔
413
            Some(Value::Text("alice".to_string()))
2✔
414
        );
415

416
        cleanup(&path);
1✔
417
    }
418

419
    #[test]
420
    fn open_missing_file_creates_fresh_db_and_materializes_file() {
3✔
421
        let path = tmp_path("missing");
1✔
422
        let mut repl = new_editor();
1✔
423
        let mut db = Database::new("x".to_string());
2✔
424

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

434
        cleanup(&path);
1✔
435
    }
436

437
    #[test]
438
    fn auto_save_persists_writes_without_explicit_save() {
3✔
439
        use sqlrite::sql::db::table::Value;
440

441
        let path = tmp_path("autosave");
1✔
442
        let mut repl = new_editor();
1✔
443
        let mut db = Database::new("x".to_string());
2✔
444

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

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

455
        // Drop the first Database so its exclusive lock releases before we
456
        // reopen the same file for verification.
457
        drop(db);
1✔
458

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

470
        cleanup(&path);
1✔
471
    }
472
}
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