Iteration 4a: rebuild command with confirmation modal
Adds the explicit `rebuild` app-level command (ADR-0015 §7, §11)
and a modal UI infrastructure to host its confirmation dialog.
Typing `rebuild` emits Action::PrepareRebuild; the runtime reads
project.yaml + data/ to compute a summary ("3 tables and 47 rows
will be reconstructed; the existing playground.db will be
replaced") and posts AppEvent::RebuildPrepared, which opens the
modal. Y confirms, N/Esc cancels. While the modal is open,
normal input is gated.
The worker's do_rebuild_from_text now wipes existing user tables
and metadata before reloading from text, so it works on both
fresh and populated databases. Source text is plumbed through
rebuild_from_text so the explicit rebuild logs to history.log
while the silent on-load rebuild from Iteration 3 stays silent.
Modal infrastructure (App.modal field + key routing + centered
overlay rendering + word-wrap) is reused by Iteration 4b's save
/ save as / load / new flows.
Tests: 314 passing (268 lib + 9 + 5 + 6 new + 9 + 17),
0 failing, 0 skipped. Clippy clean.
This commit is contained in:
@@ -24,4 +24,16 @@ pub enum Action {
|
|||||||
command: Command,
|
command: Command,
|
||||||
source: String,
|
source: String,
|
||||||
},
|
},
|
||||||
|
/// User issued the `rebuild` app-level command (ADR-0015
|
||||||
|
/// §7, §11). Runtime computes a summary from
|
||||||
|
/// `project.yaml` + `data/` and posts back as
|
||||||
|
/// `AppEvent::RebuildPrepared`, which opens the
|
||||||
|
/// confirmation modal.
|
||||||
|
PrepareRebuild,
|
||||||
|
/// User confirmed the rebuild from inside the modal.
|
||||||
|
/// Runtime wipes the current schema/data and reconstructs
|
||||||
|
/// from text sources.
|
||||||
|
Rebuild {
|
||||||
|
source: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+80
-2
@@ -111,6 +111,32 @@ pub struct App {
|
|||||||
/// loop exits and prints it to stderr post-teardown so the
|
/// loop exits and prints it to stderr post-teardown so the
|
||||||
/// banner remains above the shell prompt.
|
/// banner remains above the shell prompt.
|
||||||
pub fatal_message: Option<String>,
|
pub fatal_message: Option<String>,
|
||||||
|
/// Active modal dialog (rebuild confirmation, save-as path
|
||||||
|
/// prompt, load picker, …). While `Some`, `update`
|
||||||
|
/// dispatches keys to the modal instead of the input
|
||||||
|
/// field.
|
||||||
|
pub modal: Option<Modal>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dialogs that take over keyboard input when active.
|
||||||
|
///
|
||||||
|
/// Track-2 lifecycle commands (`rebuild`, `save as`, `load`,
|
||||||
|
/// `new`) need confirmation prompts or path entry that the
|
||||||
|
/// single-line input field can't naturally express. Each
|
||||||
|
/// modal owns a small state machine; the renderer draws an
|
||||||
|
/// overlay and `App::update` routes keys through it.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Modal {
|
||||||
|
/// `rebuild` confirmation. Shows a summary of what would
|
||||||
|
/// be reconstructed; `Y` confirms, `N` / `Esc` dismisses.
|
||||||
|
RebuildConfirm(RebuildConfirmModal),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct RebuildConfirmModal {
|
||||||
|
/// One-line summary derived from `project.yaml` + `data/`
|
||||||
|
/// (e.g. `"3 tables, 47 rows will be reconstructed"`).
|
||||||
|
pub summary: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
const PAGE_SCROLL_LINES: usize = 5;
|
const PAGE_SCROLL_LINES: usize = 5;
|
||||||
@@ -142,6 +168,7 @@ impl App {
|
|||||||
last_output_total_wrapped: 0,
|
last_output_total_wrapped: 0,
|
||||||
project_name: None,
|
project_name: None,
|
||||||
fatal_message: None,
|
fatal_message: None,
|
||||||
|
modal: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,6 +255,20 @@ impl App {
|
|||||||
self.fatal_message = Some(banner);
|
self.fatal_message = Some(banner);
|
||||||
vec![Action::Quit]
|
vec![Action::Quit]
|
||||||
}
|
}
|
||||||
|
AppEvent::RebuildPrepared { summary } => {
|
||||||
|
self.modal = Some(Modal::RebuildConfirm(RebuildConfirmModal { summary }));
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
AppEvent::RebuildSucceeded { summary } => {
|
||||||
|
self.modal = None;
|
||||||
|
self.note_system(format!("[ok] rebuild — {summary}"));
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
AppEvent::RebuildFailed { error } => {
|
||||||
|
self.modal = None;
|
||||||
|
self.note_error(format!("rebuild failed: {error}"));
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,6 +280,13 @@ impl App {
|
|||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
trace!(?key, "handle_key");
|
trace!(?key, "handle_key");
|
||||||
|
|
||||||
|
// While a modal is open it owns the keyboard. Normal
|
||||||
|
// input editing, history navigation, and command
|
||||||
|
// submission are all gated behind closing the modal.
|
||||||
|
if self.modal.is_some() {
|
||||||
|
return self.handle_modal_key(key);
|
||||||
|
}
|
||||||
match (key.code, key.modifiers) {
|
match (key.code, key.modifiers) {
|
||||||
(KeyCode::Char('c'), KeyModifiers::CONTROL) => vec![Action::Quit],
|
(KeyCode::Char('c'), KeyModifiers::CONTROL) => vec![Action::Quit],
|
||||||
(KeyCode::Enter, _) => self.submit(),
|
(KeyCode::Enter, _) => self.submit(),
|
||||||
@@ -449,10 +497,12 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Canonical app-level commands recognised in both modes.
|
// Canonical app-level commands recognised in both modes.
|
||||||
// The current iteration implements `quit` and `mode`;
|
// Track-2's full lifecycle command set (`save`, `load`,
|
||||||
// the rest of the canonical list lands in later iterations.
|
// `new`, `export`, `import`) lands across Iterations 4
|
||||||
|
// and 5; this iteration adds `rebuild`.
|
||||||
match effective_input.as_str() {
|
match effective_input.as_str() {
|
||||||
"quit" | "q" => return vec![Action::Quit],
|
"quit" | "q" => return vec![Action::Quit],
|
||||||
|
"rebuild" => return vec![Action::PrepareRebuild],
|
||||||
other if other.starts_with("mode") => {
|
other if other.starts_with("mode") => {
|
||||||
self.handle_mode_command(other);
|
self.handle_mode_command(other);
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
@@ -610,6 +660,34 @@ impl App {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Route a keypress through whichever modal is active.
|
||||||
|
///
|
||||||
|
/// Each modal owns its own tiny state machine. On
|
||||||
|
/// confirmation, the modal yields one or more `Action`s
|
||||||
|
/// for the runtime to enact. On dismissal it simply
|
||||||
|
/// closes itself.
|
||||||
|
fn handle_modal_key(&mut self, key: KeyEvent) -> Vec<Action> {
|
||||||
|
let Some(modal) = self.modal.as_ref() else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
match modal {
|
||||||
|
Modal::RebuildConfirm(_) => match key.code {
|
||||||
|
KeyCode::Char('y') | KeyCode::Char('Y') => {
|
||||||
|
self.modal = None;
|
||||||
|
vec![Action::Rebuild {
|
||||||
|
source: "rebuild".to_string(),
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
|
||||||
|
self.modal = None;
|
||||||
|
self.note_system("rebuild cancelled");
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
_ => Vec::new(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_mode_command(&mut self, raw: &str) {
|
fn handle_mode_command(&mut self, raw: &str) {
|
||||||
let arg = raw.strip_prefix("mode").unwrap_or(raw).trim();
|
let arg = raw.strip_prefix("mode").unwrap_or(raw).trim();
|
||||||
match arg {
|
match arg {
|
||||||
|
|||||||
@@ -322,10 +322,11 @@ enum Request {
|
|||||||
},
|
},
|
||||||
/// Rebuild the database from `project.yaml` + `data/`
|
/// Rebuild the database from `project.yaml` + `data/`
|
||||||
/// (ADR-0015 §7). Used by the runtime when the `.db` file
|
/// (ADR-0015 §7). Used by the runtime when the `.db` file
|
||||||
/// is missing on project open. Iteration 4's `rebuild`
|
/// is missing on project open and by the explicit
|
||||||
/// app-level command will reuse the same request.
|
/// `rebuild` app-level command (Iteration 4).
|
||||||
RebuildFromText {
|
RebuildFromText {
|
||||||
project_path: std::path::PathBuf,
|
project_path: std::path::PathBuf,
|
||||||
|
source: Option<String>,
|
||||||
reply: oneshot::Sender<Result<(), DbError>>,
|
reply: oneshot::Sender<Result<(), DbError>>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -543,16 +544,22 @@ impl Database {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Rebuild the database from `project.yaml` + `data/`
|
/// Rebuild the database from `project.yaml` + `data/`
|
||||||
/// (ADR-0015 §7). Called by the runtime on a missing `.db`
|
/// (ADR-0015 §7).
|
||||||
/// at startup; Iteration 4 will also expose this via the
|
///
|
||||||
/// `rebuild` app-level command.
|
/// Called by the runtime on a missing `.db` at startup
|
||||||
|
/// (with `source = None`, no history entry) and by the
|
||||||
|
/// explicit `rebuild` app-level command (with
|
||||||
|
/// `source = Some("rebuild")`, which appends to
|
||||||
|
/// `history.log` on success).
|
||||||
pub async fn rebuild_from_text(
|
pub async fn rebuild_from_text(
|
||||||
&self,
|
&self,
|
||||||
project_path: std::path::PathBuf,
|
project_path: std::path::PathBuf,
|
||||||
|
source: Option<String>,
|
||||||
) -> Result<(), DbError> {
|
) -> Result<(), DbError> {
|
||||||
let (reply, recv) = oneshot::channel();
|
let (reply, recv) = oneshot::channel();
|
||||||
self.send(Request::RebuildFromText {
|
self.send(Request::RebuildFromText {
|
||||||
project_path,
|
project_path,
|
||||||
|
source,
|
||||||
reply,
|
reply,
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
@@ -821,8 +828,17 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req
|
|||||||
&table,
|
&table,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Request::RebuildFromText { project_path, reply } => {
|
Request::RebuildFromText {
|
||||||
let _ = reply.send(do_rebuild_from_text(conn, &project_path));
|
project_path,
|
||||||
|
source,
|
||||||
|
reply,
|
||||||
|
} => {
|
||||||
|
let _ = reply.send(do_rebuild_from_text(
|
||||||
|
conn,
|
||||||
|
persistence,
|
||||||
|
source.as_deref(),
|
||||||
|
&project_path,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2437,14 +2453,29 @@ fn read_relationships_inbound(
|
|||||||
///
|
///
|
||||||
/// The on-disk text is the authoritative source: this function
|
/// The on-disk text is the authoritative source: this function
|
||||||
/// recreates schema, metadata, and rows so the resulting `.db`
|
/// recreates schema, metadata, and rows so the resulting `.db`
|
||||||
/// reflects them exactly. Persistence callbacks are NOT invoked;
|
/// reflects them exactly. Persistence callbacks are NOT invoked
|
||||||
/// we're loading, not changing user-visible state.
|
/// for the schema/data writes; we're loading, not changing
|
||||||
|
/// user-visible state. The exception is `history.log`: when
|
||||||
|
/// `source` is `Some`, the rebuild was user-initiated (via the
|
||||||
|
/// `rebuild` app-level command) and is appended as a successful
|
||||||
|
/// command per ADR-0015 §5.
|
||||||
|
///
|
||||||
|
/// Existing user tables and metadata rows are wiped at the
|
||||||
|
/// start of the rebuild so this function works on both fresh
|
||||||
|
/// and populated databases — the silent on-load case (empty
|
||||||
|
/// db) sees a no-op wipe; the explicit `rebuild` command
|
||||||
|
/// replaces whatever was there.
|
||||||
///
|
///
|
||||||
/// FK enforcement is disabled for the load and re-enabled at
|
/// FK enforcement is disabled for the load and re-enabled at
|
||||||
/// the end (regardless of success). A `foreign_key_check`
|
/// the end (regardless of success). A `foreign_key_check`
|
||||||
/// before commit verifies the loaded data is consistent — any
|
/// before commit verifies the loaded data is consistent — any
|
||||||
/// violation aborts with a fatal error.
|
/// violation aborts with a fatal error.
|
||||||
fn do_rebuild_from_text(conn: &Connection, project_path: &Path) -> Result<(), DbError> {
|
fn do_rebuild_from_text(
|
||||||
|
conn: &Connection,
|
||||||
|
persistence: Option<&Persistence>,
|
||||||
|
source: Option<&str>,
|
||||||
|
project_path: &Path,
|
||||||
|
) -> Result<(), DbError> {
|
||||||
let yaml_path = project_path.join(PROJECT_YAML);
|
let yaml_path = project_path.join(PROJECT_YAML);
|
||||||
let data_dir = project_path.join(DATA_DIR);
|
let data_dir = project_path.join(DATA_DIR);
|
||||||
|
|
||||||
@@ -2468,6 +2499,25 @@ fn do_rebuild_from_text(conn: &Connection, project_path: &Path) -> Result<(), Db
|
|||||||
.unchecked_transaction()
|
.unchecked_transaction()
|
||||||
.map_err(DbError::from_rusqlite)?;
|
.map_err(DbError::from_rusqlite)?;
|
||||||
|
|
||||||
|
// 0. Wipe any existing user tables + metadata so the
|
||||||
|
// rebuild can start from a clean slate. This step
|
||||||
|
// is a no-op on a freshly-created database (the
|
||||||
|
// silent rebuild on missing-`.db`); on the explicit
|
||||||
|
// `rebuild` command it replaces the live state with
|
||||||
|
// the text-source state per ADR-0015 §7.
|
||||||
|
let existing_tables = do_list_tables(&tx)?;
|
||||||
|
for name in &existing_tables {
|
||||||
|
tx.execute_batch(&format!(
|
||||||
|
"DROP TABLE {ident};",
|
||||||
|
ident = quote_ident(name)
|
||||||
|
))
|
||||||
|
.map_err(DbError::from_rusqlite)?;
|
||||||
|
}
|
||||||
|
tx.execute_batch(&format!(
|
||||||
|
"DELETE FROM {META_TABLE}; DELETE FROM {REL_TABLE};"
|
||||||
|
))
|
||||||
|
.map_err(DbError::from_rusqlite)?;
|
||||||
|
|
||||||
// 1. Recreate user tables with FK constraints inline.
|
// 1. Recreate user tables with FK constraints inline.
|
||||||
for table in &snapshot.tables {
|
for table in &snapshot.tables {
|
||||||
let read_schema = build_read_schema(table, &snapshot.relationships);
|
let read_schema = build_read_schema(table, &snapshot.relationships);
|
||||||
@@ -2554,6 +2604,14 @@ fn do_rebuild_from_text(conn: &Connection, project_path: &Path) -> Result<(), Db
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 7. Append `history.log` if this rebuild was
|
||||||
|
// user-initiated (the silent on-load case has
|
||||||
|
// `source = None`).
|
||||||
|
if let (Some(p), Some(text)) = (persistence, source) {
|
||||||
|
p.append_history(text)
|
||||||
|
.map_err(DbError::from_persistence)?;
|
||||||
|
}
|
||||||
|
|
||||||
tx.commit().map_err(DbError::from_rusqlite)?;
|
tx.commit().map_err(DbError::from_rusqlite)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -55,4 +55,21 @@ pub enum AppEvent {
|
|||||||
path: std::path::PathBuf,
|
path: std::path::PathBuf,
|
||||||
message: String,
|
message: String,
|
||||||
},
|
},
|
||||||
|
/// Runtime has computed the rebuild summary from
|
||||||
|
/// `project.yaml` + `data/` and is ready for the user to
|
||||||
|
/// confirm. App opens the confirmation modal.
|
||||||
|
RebuildPrepared {
|
||||||
|
summary: String,
|
||||||
|
},
|
||||||
|
/// Rebuild completed successfully. App closes the modal,
|
||||||
|
/// surfaces a friendly outcome message, and refreshes the
|
||||||
|
/// table list.
|
||||||
|
RebuildSucceeded {
|
||||||
|
summary: String,
|
||||||
|
},
|
||||||
|
/// Rebuild failed in a non-fatal way (e.g., user-visible
|
||||||
|
/// constraint problem) — surfaced like other DSL failures.
|
||||||
|
RebuildFailed {
|
||||||
|
error: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+115
-3
@@ -46,7 +46,8 @@ pub async fn run(args: Args) -> Result<()> {
|
|||||||
.context("open or create project")?;
|
.context("open or create project")?;
|
||||||
let db_path = project.db_path();
|
let db_path = project.db_path();
|
||||||
let display_name = project.display_name().to_string();
|
let display_name = project.display_name().to_string();
|
||||||
let persistence = crate::persistence::Persistence::new(project.path().to_path_buf());
|
let project_path = project.path().to_path_buf();
|
||||||
|
let persistence = crate::persistence::Persistence::new(project_path.clone());
|
||||||
// Capture whether the .db file existed BEFORE we open it —
|
// Capture whether the .db file existed BEFORE we open it —
|
||||||
// sqlite creates it on connect, so this is the only honest
|
// sqlite creates it on connect, so this is the only honest
|
||||||
// signal that we need to rebuild from text (ADR-0015 §7).
|
// signal that we need to rebuild from text (ADR-0015 §7).
|
||||||
@@ -54,7 +55,9 @@ pub async fn run(args: Args) -> Result<()> {
|
|||||||
let database = Database::open_with_persistence(db_path.as_path(), persistence)
|
let database = Database::open_with_persistence(db_path.as_path(), persistence)
|
||||||
.context("open database")?;
|
.context("open database")?;
|
||||||
if !db_existed
|
if !db_existed
|
||||||
&& let Err(e) = database.rebuild_from_text(project.path().to_path_buf()).await
|
&& let Err(e) = database
|
||||||
|
.rebuild_from_text(project_path.clone(), None)
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
// The terminal is still in cooked mode here (we haven't
|
// The terminal is still in cooked mode here (we haven't
|
||||||
// entered the alternate screen yet), so writing to
|
// entered the alternate screen yet), so writing to
|
||||||
@@ -72,7 +75,14 @@ pub async fn run(args: Args) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut terminal = setup_terminal().context("setup terminal")?;
|
let mut terminal = setup_terminal().context("setup terminal")?;
|
||||||
let result = run_loop(&mut terminal, args.theme, database, display_name).await;
|
let result = run_loop(
|
||||||
|
&mut terminal,
|
||||||
|
args.theme,
|
||||||
|
database,
|
||||||
|
display_name,
|
||||||
|
project_path,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
if let Err(e) = teardown_terminal(&mut terminal) {
|
if let Err(e) = teardown_terminal(&mut terminal) {
|
||||||
// Teardown failures should not mask the primary error.
|
// Teardown failures should not mask the primary error.
|
||||||
warn!(error = %e, "terminal teardown failed");
|
warn!(error = %e, "terminal teardown failed");
|
||||||
@@ -95,6 +105,7 @@ async fn run_loop(
|
|||||||
theme: Theme,
|
theme: Theme,
|
||||||
database: Database,
|
database: Database,
|
||||||
project_display_name: String,
|
project_display_name: String,
|
||||||
|
project_path: std::path::PathBuf,
|
||||||
) -> Result<Option<String>> {
|
) -> Result<Option<String>> {
|
||||||
let (event_tx, mut event_rx) = mpsc::channel::<AppEvent>(EVENT_CHANNEL_CAPACITY);
|
let (event_tx, mut event_rx) = mpsc::channel::<AppEvent>(EVENT_CHANNEL_CAPACITY);
|
||||||
let reader_handle = spawn_event_reader(event_tx.clone());
|
let reader_handle = spawn_event_reader(event_tx.clone());
|
||||||
@@ -125,6 +136,17 @@ async fn run_loop(
|
|||||||
Action::ExecuteDsl { command, source } => {
|
Action::ExecuteDsl { command, source } => {
|
||||||
spawn_dsl_dispatch(database.clone(), event_tx.clone(), command, source);
|
spawn_dsl_dispatch(database.clone(), event_tx.clone(), command, source);
|
||||||
}
|
}
|
||||||
|
Action::PrepareRebuild => {
|
||||||
|
spawn_prepare_rebuild(project_path.clone(), event_tx.clone());
|
||||||
|
}
|
||||||
|
Action::Rebuild { source } => {
|
||||||
|
spawn_rebuild(
|
||||||
|
database.clone(),
|
||||||
|
project_path.clone(),
|
||||||
|
event_tx.clone(),
|
||||||
|
source,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
terminal
|
terminal
|
||||||
@@ -152,6 +174,96 @@ async fn seed_initial_tables(database: &Database, event_tx: &mpsc::Sender<AppEve
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read `project.yaml` + `data/` to compute the rebuild
|
||||||
|
/// summary that the confirmation modal shows. Runs off the
|
||||||
|
/// event loop so the brief I/O doesn't stall input handling
|
||||||
|
/// even on slow filesystems.
|
||||||
|
fn spawn_prepare_rebuild(
|
||||||
|
project_path: std::path::PathBuf,
|
||||||
|
event_tx: mpsc::Sender<AppEvent>,
|
||||||
|
) {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let summary = match summarize_project(&project_path) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => format!("(could not read project sources: {e})"),
|
||||||
|
};
|
||||||
|
let _ = event_tx.send(AppEvent::RebuildPrepared { summary }).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summarize_project(project_path: &std::path::Path) -> Result<String, String> {
|
||||||
|
let yaml_path = project_path.join(crate::project::PROJECT_YAML);
|
||||||
|
let yaml = std::fs::read_to_string(&yaml_path).map_err(|e| e.to_string())?;
|
||||||
|
let snapshot = crate::persistence::parse_schema(&yaml).map_err(|e| e.to_string())?;
|
||||||
|
let table_count = snapshot.tables.len();
|
||||||
|
let data_dir = project_path.join(crate::project::DATA_DIR);
|
||||||
|
let mut row_count: usize = 0;
|
||||||
|
for table in &snapshot.tables {
|
||||||
|
let csv_path = data_dir.join(format!("{}.csv", table.name));
|
||||||
|
let Ok(body) = std::fs::read_to_string(&csv_path) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
// Header line + one line per row (per Iteration 2's
|
||||||
|
// "no CSV when empty" rule, this is exact).
|
||||||
|
row_count += body.lines().count().saturating_sub(1);
|
||||||
|
}
|
||||||
|
Ok(format!(
|
||||||
|
"{table_count} table{} and {row_count} row{} will be reconstructed; \
|
||||||
|
the existing playground.db will be replaced",
|
||||||
|
if table_count == 1 { "" } else { "s" },
|
||||||
|
if row_count == 1 { "" } else { "s" },
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn the actual rebuild and forward the typed outcome
|
||||||
|
/// back as an `AppEvent`.
|
||||||
|
fn spawn_rebuild(
|
||||||
|
database: Database,
|
||||||
|
project_path: std::path::PathBuf,
|
||||||
|
event_tx: mpsc::Sender<AppEvent>,
|
||||||
|
source: String,
|
||||||
|
) {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
match database
|
||||||
|
.rebuild_from_text(project_path.clone(), Some(source))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => {
|
||||||
|
let summary = summarize_project(&project_path)
|
||||||
|
.unwrap_or_else(|_| "rebuild complete".to_string());
|
||||||
|
let _ = event_tx
|
||||||
|
.send(AppEvent::RebuildSucceeded { summary })
|
||||||
|
.await;
|
||||||
|
// Refresh the table list so the items panel
|
||||||
|
// reflects whatever the rebuild produced.
|
||||||
|
if let Ok(tables) = database.list_tables().await {
|
||||||
|
let _ = event_tx.send(AppEvent::TablesRefreshed(tables)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(DbError::PersistenceFatal {
|
||||||
|
operation,
|
||||||
|
path,
|
||||||
|
message,
|
||||||
|
}) => {
|
||||||
|
let _ = event_tx
|
||||||
|
.send(AppEvent::PersistenceFatal {
|
||||||
|
operation: operation.to_string(),
|
||||||
|
path,
|
||||||
|
message,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
Err(other) => {
|
||||||
|
let _ = event_tx
|
||||||
|
.send(AppEvent::RebuildFailed {
|
||||||
|
error: other.friendly_message(),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Spawn a task that runs a DSL command against the database
|
/// Spawn a task that runs a DSL command against the database
|
||||||
/// and forwards the result back as an `AppEvent`.
|
/// and forwards the result back as an `AppEvent`.
|
||||||
fn spawn_dsl_dispatch(
|
fn spawn_dsl_dispatch(
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
source: src/ui.rs
|
||||||
|
assertion_line: 561
|
||||||
|
expression: snapshot
|
||||||
|
---
|
||||||
|
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
|
||||||
|
│(none yet) ││ │
|
||||||
|
│ ││ │
|
||||||
|
│ ││ │
|
||||||
|
│ ││ │
|
||||||
|
│ ││ │
|
||||||
|
│ ││ │
|
||||||
|
│ ╭ Rebuild project ─────────────────────────────────────────╮ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │3 tables and 47 rows will be reconstructed; the existing │ │
|
||||||
|
│ │playground.db will be replaced │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │Continue? │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │[Y] yes [N] no Esc cancel │ │
|
||||||
|
│ ╰──────────────────────────────────────────────────────────╯─────────╯
|
||||||
|
│ │╭ SIMPLE ──────────────────────────────────────────╮
|
||||||
|
│ ││ │
|
||||||
|
│ │╰──────────────────────────────────────────────────╯
|
||||||
|
│ │╭ Hint ────────────────────────────────────────────╮
|
||||||
|
│ ││(no active hint) │
|
||||||
|
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
|
||||||
|
Project: Term Planner
|
||||||
|
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||||
@@ -48,6 +48,117 @@ pub fn render(app: &mut App, theme: &Theme, frame: &mut Frame<'_>) {
|
|||||||
render_right_column(app, theme, frame, columns[1]);
|
render_right_column(app, theme, frame, columns[1]);
|
||||||
render_project_label(app, theme, frame, outer[1]);
|
render_project_label(app, theme, frame, outer[1]);
|
||||||
render_status_bar(app, theme, frame, outer[2]);
|
render_status_bar(app, theme, frame, outer[2]);
|
||||||
|
|
||||||
|
// Modal dialogs (rebuild confirm, save-as prompt, load
|
||||||
|
// picker, …) are drawn last so they overlay the rest of
|
||||||
|
// the frame.
|
||||||
|
if let Some(modal) = app.modal.as_ref() {
|
||||||
|
render_modal(modal, theme, frame, area);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_modal(modal: &crate::app::Modal, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
|
||||||
|
use crate::app::Modal;
|
||||||
|
match modal {
|
||||||
|
Modal::RebuildConfirm(m) => render_rebuild_confirm(&m.summary, theme, frame, area),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Centred dialog with a one-paragraph body and a [Y]es/[N]o
|
||||||
|
/// hint at the bottom. Sized at min(60 cols, area.width-4)
|
||||||
|
/// wide and tall enough to fit the wrapped body plus 4 rows
|
||||||
|
/// of chrome.
|
||||||
|
fn render_rebuild_confirm(summary: &str, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
|
||||||
|
let dialog_w = area.width.min(60).saturating_sub(0);
|
||||||
|
let dialog_w = dialog_w.max(20);
|
||||||
|
let inner_w = dialog_w.saturating_sub(4) as usize;
|
||||||
|
|
||||||
|
let body_lines: Vec<String> = wrap_lines(summary, inner_w);
|
||||||
|
let body_height = body_lines.len() as u16;
|
||||||
|
// Title row + blank + body + blank + prompt + blank + keys + borders (2).
|
||||||
|
let dialog_h = body_height.saturating_add(7).min(area.height);
|
||||||
|
|
||||||
|
let x = area.x + (area.width.saturating_sub(dialog_w)) / 2;
|
||||||
|
let y = area.y + (area.height.saturating_sub(dialog_h)) / 2;
|
||||||
|
let dialog_area = Rect {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width: dialog_w,
|
||||||
|
height: dialog_h,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Solid background panel so we cover whatever was beneath.
|
||||||
|
let bg = ratatui::widgets::Clear;
|
||||||
|
frame.render_widget(bg, dialog_area);
|
||||||
|
|
||||||
|
let title_style = Style::default()
|
||||||
|
.fg(theme.fg)
|
||||||
|
.add_modifier(Modifier::BOLD);
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_type(BorderType::Rounded)
|
||||||
|
.border_style(Style::default().fg(theme.fg))
|
||||||
|
.title(Line::from(vec![Span::styled(" Rebuild project ", title_style)]))
|
||||||
|
.style(Style::default().bg(theme.bg).fg(theme.fg));
|
||||||
|
|
||||||
|
let mut text_lines: Vec<Line<'_>> = Vec::new();
|
||||||
|
text_lines.push(Line::from(""));
|
||||||
|
for line in body_lines {
|
||||||
|
text_lines.push(Line::from(line));
|
||||||
|
}
|
||||||
|
text_lines.push(Line::from(""));
|
||||||
|
text_lines.push(Line::from("Continue?"));
|
||||||
|
text_lines.push(Line::from(""));
|
||||||
|
text_lines.push(Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
"[Y]",
|
||||||
|
Style::default()
|
||||||
|
.fg(theme.fg)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::raw(" yes "),
|
||||||
|
Span::styled(
|
||||||
|
"[N]",
|
||||||
|
Style::default()
|
||||||
|
.fg(theme.fg)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::raw(" no "),
|
||||||
|
Span::styled("Esc", Style::default().fg(theme.muted)),
|
||||||
|
Span::styled(" cancel", Style::default().fg(theme.muted)),
|
||||||
|
]));
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new(text_lines)
|
||||||
|
.block(block)
|
||||||
|
.wrap(Wrap { trim: false });
|
||||||
|
frame.render_widget(paragraph, dialog_area);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Greedy word-wrap to `width` columns. Sufficient for the
|
||||||
|
/// short prose modals carry; we don't try to be Unicode-aware
|
||||||
|
/// (display-width-wise) since the strings we generate are
|
||||||
|
/// ASCII-friendly.
|
||||||
|
fn wrap_lines(s: &str, width: usize) -> Vec<String> {
|
||||||
|
if width == 0 {
|
||||||
|
return vec![s.to_string()];
|
||||||
|
}
|
||||||
|
let mut lines: Vec<String> = Vec::new();
|
||||||
|
let mut current = String::new();
|
||||||
|
for word in s.split_whitespace() {
|
||||||
|
if !current.is_empty() && current.len() + 1 + word.len() <= width {
|
||||||
|
current.push(' ');
|
||||||
|
} else if !current.is_empty() {
|
||||||
|
lines.push(std::mem::take(&mut current));
|
||||||
|
}
|
||||||
|
current.push_str(word);
|
||||||
|
}
|
||||||
|
if !current.is_empty() {
|
||||||
|
lines.push(current);
|
||||||
|
}
|
||||||
|
if lines.is_empty() {
|
||||||
|
lines.push(String::new());
|
||||||
|
}
|
||||||
|
lines
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_project_label(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
|
fn render_project_label(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
|
||||||
@@ -433,6 +544,20 @@ mod tests {
|
|||||||
insta::assert_snapshot!("one_shot_advanced_dark", snapshot);
|
insta::assert_snapshot!("one_shot_advanced_dark", snapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rebuild_confirm_modal_snapshot() {
|
||||||
|
use crate::app::{Modal, RebuildConfirmModal};
|
||||||
|
let mut app = App::new();
|
||||||
|
app.modal = Some(Modal::RebuildConfirm(RebuildConfirmModal {
|
||||||
|
summary: "3 tables and 47 rows will be reconstructed; \
|
||||||
|
the existing playground.db will be replaced"
|
||||||
|
.to_string(),
|
||||||
|
}));
|
||||||
|
let theme = Theme::dark();
|
||||||
|
let snapshot = render_to_string(&mut app, &theme, 80, 24);
|
||||||
|
insta::assert_snapshot!("rebuild_confirm_modal_dark", snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn populated_with_table_snapshot() {
|
fn populated_with_table_snapshot() {
|
||||||
// Items panel lists tables; output panel shows the
|
// Items panel lists tables; output panel shows the
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ fn rebuild_restores_schema_only_project() {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
rt().block_on(async {
|
rt().block_on(async {
|
||||||
db.rebuild_from_text(project.path().to_path_buf())
|
db.rebuild_from_text(project.path().to_path_buf(), None)
|
||||||
.await
|
.await
|
||||||
.expect("rebuild");
|
.expect("rebuild");
|
||||||
});
|
});
|
||||||
@@ -137,7 +137,7 @@ fn rebuild_restores_rows_from_csv() {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
rt().block_on(async {
|
rt().block_on(async {
|
||||||
db.rebuild_from_text(project.path().to_path_buf())
|
db.rebuild_from_text(project.path().to_path_buf(), None)
|
||||||
.await
|
.await
|
||||||
.expect("rebuild");
|
.expect("rebuild");
|
||||||
});
|
});
|
||||||
@@ -226,7 +226,7 @@ fn rebuild_restores_relationships_and_cascade_behaviour() {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
rt().block_on(async {
|
rt().block_on(async {
|
||||||
db.rebuild_from_text(project.path().to_path_buf())
|
db.rebuild_from_text(project.path().to_path_buf(), None)
|
||||||
.await
|
.await
|
||||||
.expect("rebuild");
|
.expect("rebuild");
|
||||||
});
|
});
|
||||||
@@ -303,7 +303,7 @@ fn rebuild_reports_fatal_error_on_bad_csv_row() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let err = rt()
|
let err = rt()
|
||||||
.block_on(async {
|
.block_on(async {
|
||||||
db.rebuild_from_text(project.path().to_path_buf()).await
|
db.rebuild_from_text(project.path().to_path_buf(), None).await
|
||||||
})
|
})
|
||||||
.expect_err("must fail with row-level error");
|
.expect_err("must fail with row-level error");
|
||||||
let msg = format!("{err}");
|
let msg = format!("{err}");
|
||||||
@@ -363,7 +363,7 @@ fn rebuild_preserves_created_at_from_yaml() {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
rt().block_on(async {
|
rt().block_on(async {
|
||||||
db.rebuild_from_text(project.path().to_path_buf())
|
db.rebuild_from_text(project.path().to_path_buf(), None)
|
||||||
.await
|
.await
|
||||||
.expect("rebuild");
|
.expect("rebuild");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
//! Iteration-4a integration tests: the explicit `rebuild`
|
||||||
|
//! app-level command (ADR-0015 §7, §11).
|
||||||
|
//!
|
||||||
|
//! Covers the App-level dispatch (typing `rebuild` opens the
|
||||||
|
//! confirmation modal) and the worker-level wipe-and-rebuild
|
||||||
|
//! against a populated database. The runtime's spawn glue
|
||||||
|
//! is exercised manually here since we don't boot a Tokio
|
||||||
|
//! event loop in tests; we drive `Database::rebuild_from_text`
|
||||||
|
//! directly to verify it works on a populated db.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||||
|
|
||||||
|
use rdbms_playground::action::Action;
|
||||||
|
use rdbms_playground::app::{App, Modal, RebuildConfirmModal};
|
||||||
|
use rdbms_playground::db::Database;
|
||||||
|
use rdbms_playground::dsl::{ColumnSpec, Type, Value};
|
||||||
|
use rdbms_playground::event::AppEvent;
|
||||||
|
use rdbms_playground::persistence::Persistence;
|
||||||
|
use rdbms_playground::project;
|
||||||
|
|
||||||
|
const fn key(code: KeyCode) -> AppEvent {
|
||||||
|
AppEvent::Key(KeyEvent {
|
||||||
|
code,
|
||||||
|
modifiers: KeyModifiers::NONE,
|
||||||
|
kind: KeyEventKind::Press,
|
||||||
|
state: crossterm::event::KeyEventState::NONE,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn type_str(app: &mut App, s: &str) {
|
||||||
|
for c in s.chars() {
|
||||||
|
app.update(key(KeyCode::Char(c)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn submit(app: &mut App) -> Vec<Action> {
|
||||||
|
app.update(key(KeyCode::Enter))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tempdir() -> tempfile::TempDir {
|
||||||
|
tempfile::tempdir().expect("create tempdir")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rt() -> tokio::runtime::Runtime {
|
||||||
|
tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.expect("tokio rt")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn typing_rebuild_emits_prepare_action() {
|
||||||
|
let mut app = App::new();
|
||||||
|
type_str(&mut app, "rebuild");
|
||||||
|
let actions = submit(&mut app);
|
||||||
|
assert_eq!(actions, vec![Action::PrepareRebuild]);
|
||||||
|
// No modal yet — the runtime still has to compute the
|
||||||
|
// summary and post `RebuildPrepared` back.
|
||||||
|
assert!(app.modal.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rebuild_prepared_event_opens_modal_with_summary() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.update(AppEvent::RebuildPrepared {
|
||||||
|
summary: "3 tables and 47 rows will be reconstructed".to_string(),
|
||||||
|
});
|
||||||
|
match app.modal.as_ref() {
|
||||||
|
Some(Modal::RebuildConfirm(RebuildConfirmModal { summary })) => {
|
||||||
|
assert!(summary.contains("3 tables"));
|
||||||
|
}
|
||||||
|
other => panic!("expected RebuildConfirm modal, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn modal_y_emits_rebuild_action_and_closes() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.update(AppEvent::RebuildPrepared {
|
||||||
|
summary: "summary".to_string(),
|
||||||
|
});
|
||||||
|
let actions = app.update(key(KeyCode::Char('Y')));
|
||||||
|
assert_eq!(actions.len(), 1);
|
||||||
|
let Action::Rebuild { source } = &actions[0] else {
|
||||||
|
panic!("expected Rebuild action, got {:?}", actions[0]);
|
||||||
|
};
|
||||||
|
assert_eq!(source, "rebuild");
|
||||||
|
assert!(app.modal.is_none(), "modal should close on confirm");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn modal_n_or_esc_dismisses_without_action() {
|
||||||
|
for code in [KeyCode::Char('N'), KeyCode::Esc] {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.update(AppEvent::RebuildPrepared {
|
||||||
|
summary: "summary".to_string(),
|
||||||
|
});
|
||||||
|
let actions = app.update(key(code));
|
||||||
|
assert!(actions.is_empty(), "no actions emitted on dismiss");
|
||||||
|
assert!(app.modal.is_none(), "modal should close on dismiss");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn modal_swallows_unrelated_keys() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.update(AppEvent::RebuildPrepared {
|
||||||
|
summary: "summary".to_string(),
|
||||||
|
});
|
||||||
|
// A regular character key should not type into the input
|
||||||
|
// field while the modal is up.
|
||||||
|
app.update(key(KeyCode::Char('x')));
|
||||||
|
assert!(app.input.is_empty(), "modal should swallow key input");
|
||||||
|
assert!(app.modal.is_some(), "modal still active after unrelated key");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rebuild_against_populated_db_wipes_and_reloads() {
|
||||||
|
let data = tempdir();
|
||||||
|
let project_path = {
|
||||||
|
let project = project::open_or_create(None, Some(data.path())).unwrap();
|
||||||
|
let path = project.path().to_path_buf();
|
||||||
|
let db = Database::open_with_persistence(
|
||||||
|
project.db_path(),
|
||||||
|
Persistence::new(path.clone()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
rt().block_on(async {
|
||||||
|
db.create_table(
|
||||||
|
"Customers".to_string(),
|
||||||
|
vec![
|
||||||
|
ColumnSpec { name: "id".to_string(), ty: Type::Serial },
|
||||||
|
ColumnSpec { name: "Name".to_string(), ty: Type::Text },
|
||||||
|
],
|
||||||
|
vec!["id".to_string()],
|
||||||
|
Some("create".to_string()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
db.insert(
|
||||||
|
"Customers".to_string(),
|
||||||
|
None,
|
||||||
|
vec![Value::Text("Alice".to_string())],
|
||||||
|
Some("insert".to_string()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
});
|
||||||
|
drop(db);
|
||||||
|
drop(project);
|
||||||
|
path
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hand-edit the CSV to introduce a different row content.
|
||||||
|
// Rebuild should pick up the edited content.
|
||||||
|
let csv_path = project_path.join("data").join("Customers.csv");
|
||||||
|
let edited = fs::read_to_string(&csv_path).unwrap().replace("Alice", "Edna");
|
||||||
|
fs::write(&csv_path, edited).unwrap();
|
||||||
|
|
||||||
|
// Reopen with persistence (the .db still exists but has
|
||||||
|
// "Alice"). Run rebuild — it should wipe and reload.
|
||||||
|
let project = project::Project::open(&project_path).unwrap();
|
||||||
|
let db = Database::open_with_persistence(
|
||||||
|
project.db_path(),
|
||||||
|
Persistence::new(project.path().to_path_buf()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
rt().block_on(async {
|
||||||
|
db.rebuild_from_text(project.path().to_path_buf(), Some("rebuild".to_string()))
|
||||||
|
.await
|
||||||
|
.expect("rebuild");
|
||||||
|
});
|
||||||
|
let rows = rt()
|
||||||
|
.block_on(async { db.query_data("Customers".to_string(), None).await })
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(rows.rows.len(), 1);
|
||||||
|
assert_eq!(rows.rows[0][1].as_deref(), Some("Edna"));
|
||||||
|
|
||||||
|
// history.log should contain the rebuild entry.
|
||||||
|
let history = fs::read_to_string(project_path.join("history.log")).unwrap();
|
||||||
|
assert!(
|
||||||
|
history.lines().any(|l| l.ends_with("|ok|rebuild")),
|
||||||
|
"history.log missing rebuild entry:\n{history}",
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user