Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -153,3 +153,6 @@ Libs/ios/

# Claude Code session data
.claude/

# Linux dev sysroot (see linux/scripts/dev-env.sh)
.local-deps/
32 changes: 23 additions & 9 deletions linux/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,17 @@ Native Linux database client. Sister product to the macOS TablePro app, sharing

## Status

Phase 0 — foundation. The technology stack was validated by a 2-day spike in April 2026: Rust + GTK4 + libadwaita + sqlx + GtkColumnView built and scrolled 100,000 rows with no perceptible lag. Real codebase scaffolding has not started yet.
Phases 2 and 3 in progress (see [ROADMAP.md](ROADMAP.md)). The stack (Rust + GTK4 + libadwaita + Relm4 + sqlx / tiberius) runs as an app you can build and use: PostgreSQL, MySQL, SQLite, and MSSQL drivers, workspace tabs, structure editing, SSH tunnels, and query history. It is not beta-shippable yet. Flatpak / Flathub distribution and the remaining hardening items are open.

## Stack

| Layer | Pick |
|---|---|
| Language | Rust 1.93+ |
| GUI toolkit | GTK4 4.14+ + libadwaita 1.5+ |
| GUI toolkit | GTK4 4.14+ + libadwaita 1.6+ + GtkSourceView 5.12+ |
| App architecture | [Relm4](https://relm4.org) — Elm-style components on gtk4-rs |
| Async | tokio (DB drivers) bridged to glib main loop (UI) |
| DB drivers | sqlx (PG / MySQL / SQLite), fred (Redis), official mongodb crate, clickhouse-arrow, etc. |
| DB drivers | sqlx (PG / MySQL / SQLite), tiberius (MSSQL); planned: fred (Redis), official `clickhouse` / mongodb / duckdb crates, etc. |
| Persistence | libsecret (passwords), gio::Settings (prefs), JSON files (connection metadata) |
| Distribution | Flathub primary, .deb / .rpm / AppImage secondary |

Expand All @@ -33,29 +33,43 @@ System dependencies:

```bash
# Ubuntu / Debian
sudo apt install -y build-essential pkg-config libgtk-4-dev libadwaita-1-dev libssl-dev libsecret-1-dev
sudo apt install -y build-essential pkg-config libgtk-4-dev libadwaita-1-dev libgtksourceview-5-dev libssl-dev libsecret-1-dev

# Fedora
sudo dnf install -y gcc pkg-config gtk4-devel libadwaita-devel openssl-devel libsecret-devel
sudo dnf install -y gcc pkg-config gtk4-devel libadwaita-devel gtksourceview5-devel openssl-devel libsecret-devel

# Arch
sudo pacman -S --needed base-devel pkg-config gtk4 libadwaita openssl libsecret
sudo pacman -S --needed base-devel pkg-config gtk4 libadwaita gtksourceview5 openssl libsecret
```

Verify the right versions are present:

```bash
pkg-config --modversion gtk4 libadwaita-1 # need 4.14+ / 1.5+
rustc --version # need 1.93+
pkg-config --modversion gtk4 libadwaita-1 gtksourceview-5 # need 4.14+ / 1.6+ / 5.12+
rustc --version # need 1.93+
```

Build and run (once `crates/app/` exists):
Build and run:

```bash
cd linux
cargo run -p tablepro-app
```

Local CI mirror (fmt + clippy + build + unit tests):

```bash
./scripts/ci-local.sh
```

Driver smoke against a Postgres you already run, no Docker needed:

```bash
./scripts/smoke-postgres.sh
```

Optional: if the system `-dev` packages above are missing, extract the package payloads under `../.local-deps/root/` (so headers land in `../.local-deps/root/usr/include`) and `source scripts/dev-env.sh` before cargo. Debian-family layouts only.

## Documentation index

| Topic | File |
Expand Down
189 changes: 101 additions & 88 deletions linux/ROADMAP.md

Large diffs are not rendered by default.

104 changes: 104 additions & 0 deletions linux/crates/drivers/postgres/tests/smoke_local.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
//! Local smoke against an already-running Postgres (no Docker required).
//!
//! Ignored by default so a plain `cargo test` stays green without a database.
//! Run it through `scripts/smoke-postgres.sh`, or directly:
//!
//! ```text
//! cargo test -p tablepro-driver-postgres --test smoke_local -- --include-ignored
//! ```
//!
//! Env: SMOKE_PG_HOST, SMOKE_PG_PORT, SMOKE_PG_USER, SMOKE_PG_PASS, SMOKE_PG_DB.
//! Defaults match `scripts/smoke-postgres.sh`. `docs/testing.md` has the
//! container one-liner that serves those defaults.
//!
//! The test creates, truncates and drops its own table, so point it at a
//! scratch database.

use drivers_postgres::PgDriver;
use tablepro_core::{ConnectOptions, DatabaseDriver, Value};

const TABLE: &str = "tablepro_smoke_items";
const DEFAULT_PORT: u16 = 54329;

fn opts_from_env() -> ConnectOptions {
ConnectOptions {
host: std::env::var("SMOKE_PG_HOST").unwrap_or_else(|_| "127.0.0.1".into()),
port: port_from_env(),
database: std::env::var("SMOKE_PG_DB").unwrap_or_else(|_| "tablepro".into()),
username: std::env::var("SMOKE_PG_USER").unwrap_or_else(|_| "tablepro".into()),
password: secrecy::SecretString::new(
std::env::var("SMOKE_PG_PASS")
.unwrap_or_else(|_| "tablepro".into())
.into(),
),
use_tls: false,
}
}

fn port_from_env() -> u16 {
let Ok(raw) = std::env::var("SMOKE_PG_PORT") else {
return DEFAULT_PORT;
};
raw.parse()
.unwrap_or_else(|e| panic!("SMOKE_PG_PORT={raw} is not a port number: {e}"))
}

#[tokio::test]
#[ignore = "requires a local postgres; run via scripts/smoke-postgres.sh"]
async fn connect_browse_and_edit_cell() {
let opts = opts_from_env();
let conn = PgDriver.connect(opts).await.expect("connect to smoke postgres");

conn.execute(&format!(
"CREATE TABLE IF NOT EXISTS {TABLE} (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
qty INT DEFAULT 0
)"
))
.await
.expect("create table");

conn.execute(&format!("DELETE FROM {TABLE}")).await.expect("clear");
conn.execute(&format!(
"INSERT INTO {TABLE} (name, qty) VALUES ('alpha', 1), ('beta', 2)"
))
.await
.expect("seed");

let tables = conn.list_tables().await.expect("list_tables");
assert!(
tables.iter().any(|t| t.name == TABLE),
"{TABLE} missing from {:?}",
tables.iter().map(|t| &t.name).collect::<Vec<_>>()
);

let cols = conn.fetch_columns(Some("public"), TABLE).await.expect("fetch_columns");
assert!(cols.iter().any(|c| c.name == "id" && c.primary_key));

let before = conn
.fetch_rows(Some("public"), TABLE, 0, 100)
.await
.expect("fetch_rows");
assert_eq!(before.rows.len(), 2);

let updated = conn
.execute_params(
&format!("UPDATE {TABLE} SET qty = $1 WHERE name = $2"),
&[Value::Int(99), Value::Text("alpha".into())],
)
.await
.expect("edit cell");
assert_eq!(updated.rows_affected, 1);

let after = conn
.query(&format!("SELECT qty FROM {TABLE} WHERE name = 'alpha'"))
.await
.expect("verify");
assert_eq!(after.rows.len(), 1);
assert_eq!(after.rows[0][0], Value::Int(99));

conn.execute(&format!("DROP TABLE {TABLE}")).await.expect("drop table");

conn.close().await.expect("close");
}
69 changes: 53 additions & 16 deletions linux/docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ Three layers, three tools. Each crate's test policy follows from its position in
| `drivers/<engine>` | Real engines | Unit tests + `testcontainers-rs` integration tests | Yes |
| `app` | GTK4 + Relm4 components | Limited; pure logic in `services/` is unit-tested | No |

Two helper scripts sit on top: `scripts/ci-local.sh` runs the fast CI checks, `scripts/smoke-postgres.sh` runs the driver smoke against a Postgres you already have.

## Unit tests

In-crate, in `#[cfg(test)] mod tests` next to the code they cover. Standard Rust idiom.
Expand All @@ -30,9 +32,11 @@ mod tests {
Run all unit tests:

```bash
cargo test --workspace --lib
cargo test --workspace --lib --bins
```

`--bins` is not optional: `tablepro-app` has no `lib.rs`, so `--lib` alone skips every test in the app crate. `scripts/ci-local.sh` runs this command; the CI workflow still passes `--lib` only.

## Integration tests

Per-crate `tests/` directory. One file per scenario.
Expand All @@ -42,27 +46,60 @@ For `storage`, integration tests use `tempfile::TempDir` to run against an isola
For drivers, integration tests use [`testcontainers`](https://docs.rs/testcontainers/latest/testcontainers/) to spin up a real database. The pattern is identical for every driver:

```rust
use testcontainers::{clients::Cli, images::generic::GenericImage};
use testcontainers::ImageExt;
use testcontainers_modules::postgres::Postgres;
use testcontainers_modules::testcontainers::runners::AsyncRunner;

#[tokio::test]
#[ignore = "requires docker"]
async fn list_tables_returns_seeded_tables() {
let docker = Cli::default();
let image = GenericImage::new("postgres", "16")
.with_env_var("POSTGRES_PASSWORD", "test")
.with_exposed_port(5432);
let node = docker.run(image);
let port = node.get_host_port_ipv4(5432);
let container = Postgres::default().with_tag("16-alpine").start().await.unwrap();
let host = container.get_host().await.unwrap().to_string();
let port = container.get_host_port_ipv4(5432).await.unwrap();

let driver = PgDriver;
let conn = driver.connect(opts_for(port)).await.unwrap();
let conn = PgDriver.connect(opts_for(host, port)).await.unwrap();

conn.execute("CREATE TABLE foo (id INT)").await.unwrap();
let tables = conn.list_tables().await.unwrap();
assert!(tables.iter().any(|t| t.name == "foo"));
}
```

Integration tests run in CI. Locally they require Docker and a user that can reach the daemon (see the macOS `CLAUDE.md` notes about Docker group permissions).
Keep the container alive for the whole test: dropping the handle stops it.

Integration tests run in CI. Locally they require a Docker-compatible API socket.

### Docker or Podman

Upstream CI uses Docker. Fedora ships Podman instead, and on Debian it is the easier install; either way, point testcontainers at Podman's rootless socket:

```bash
sudo dnf install -y podman # or: sudo apt install -y podman
systemctl --user enable --now podman.socket
export DOCKER_HOST=unix:///run/user/$(id -u)/podman/podman.sock
cargo test --test integration -p tablepro-driver-postgres -- --include-ignored --test-threads=1
cargo test --test integration -p tablepro-driver-mysql -- --include-ignored --test-threads=1
```

Do not bother with `TESTCONTAINERS_RYUK_DISABLED`. That is a testcontainers-java / go setting; the Rust crate has no Ryuk reaper and stops each container when its handle drops.

`curl --unix-socket "${DOCKER_HOST#unix://}" http://localhost/_ping` should print `OK` before you run the suite. `--unix-socket` takes a filesystem path, so the `unix://` prefix has to come off.

A test that panics hard can still leave a container behind. `podman container prune` clears them.

### Local smoke without a container

`crates/drivers/postgres/tests/smoke_local.rs` runs connect, list tables, fetch rows, edit a cell against a Postgres that is already up. It is `#[ignore]`d like the container suites, so it never runs during a plain `cargo test`.

```bash
podman run -d --name tablepro-smoke -p 54329:5432 \
-e POSTGRES_USER=tablepro -e POSTGRES_PASSWORD=tablepro -e POSTGRES_DB=tablepro \
docker.io/library/postgres:16-alpine

./scripts/smoke-postgres.sh
```

Point it somewhere else with `SMOKE_PG_HOST`, `SMOKE_PG_PORT`, `SMOKE_PG_USER`, `SMOKE_PG_PASS`, `SMOKE_PG_DB`. The test creates, clears and drops `tablepro_smoke_items`, so use a scratch database.

Mark slow integration tests with `#[ignore]` if they take more than ~5 seconds:

Expand Down Expand Up @@ -92,16 +129,16 @@ If a UI bug ships and a regression test would have caught it, write the test the

## End-to-end smoke test

`crates/app/tests/smoke.rs` runs at the top of the CI pipeline. It launches the app under `xvfb-run`, sends a fixed sequence of D-Bus actions through `gtk::Application`'s registered actions, and asserts that the app reaches "connected to PostgreSQL, table list visible" without panicking.
There is no app-level end-to-end test yet. Driving the GTK app under `xvfb-run` through its registered `gtk::Application` actions is the intended shape when we add one.

The smoke test is not exhaustive. Its job is to fail loudly when something fundamental is broken before the slower per-driver integration tests run.
What exists today is the driver-level smoke described above: `scripts/smoke-postgres.sh` against a Postgres you already run.

## CI

GitHub Actions, Linux runner, two jobs:
GitHub Actions (`.github/workflows/build-linux.yml`), Ubuntu runner, two jobs:

1. **Fast** (~2 min) — `cargo fmt --check`, `cargo clippy --all -- -D warnings`, `cargo build --workspace`, `cargo test --workspace --lib`.
2. **Full** (~10 min) — runs after fast passes. Boots Docker, runs all integration tests including the smoke test.
1. **Fast checks**: `cargo fmt --all -- --check`, `cargo clippy --all-targets -- -D warnings`, `cargo build --workspace`, `cargo test --workspace --lib`. Runs in an `ubuntu:25.10` container, which ships the glib version libadwaita 1.6 needs. `scripts/ci-local.sh` runs the same steps, but with `--lib --bins` so the app crate's tests actually run. The workflow should pick up `--bins` too.
2. **Driver integration tests**: runs after fast checks pass. Boots Docker on the host runner and runs the Postgres and MySQL suites with `--include-ignored`. The MSSQL suite exists but is not wired in yet.

PRs only merge when both jobs are green.

Expand Down
27 changes: 27 additions & 0 deletions linux/scripts/ci-local.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Mirror .github/workflows/build-linux.yml "Fast checks" job locally.
set -euo pipefail

ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"

if [[ -f "$ROOT/scripts/dev-env.sh" ]]; then
# shellcheck source=/dev/null
source "$ROOT/scripts/dev-env.sh"
fi

echo "==> cargo fmt --check"
cargo fmt --all -- --check

echo "==> cargo clippy"
cargo clippy --all-targets -- -D warnings

echo "==> cargo build --workspace"
cargo build --workspace

echo "==> cargo test --workspace --lib --bins"
# --bins matters: tablepro-app has no lib.rs, so --lib alone skips its tests.
cargo test --workspace --lib --bins

echo "All fast checks passed."
echo "Driver integration tests are a separate CI job; see docs/testing.md to run them locally."
24 changes: 24 additions & 0 deletions linux/scripts/dev-env.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Source this when system -dev packages for gtksourceview5/libsecret are unavailable.
# Extract the package payloads under <repo>/.local-deps/root first, then:
# source scripts/dev-env.sh
# Every variable below is namespaced and unset again so sourcing does not
# clobber the caller's ROOT or leave state behind.
# ${BASH_SOURCE[0]} is empty under zsh, where $0 holds the sourced path instead.
_dev_env_self="${BASH_SOURCE[0]:-$0}"
_dev_env_root="$(cd "$(dirname "$_dev_env_self")/../.." && pwd)"
_dev_env_deps="$_dev_env_root/.local-deps/root"
if [[ -d "$_dev_env_deps" ]]; then
if command -v dpkg-architecture >/dev/null 2>&1; then
_dev_env_multiarch="$(dpkg-architecture -qDEB_HOST_MULTIARCH)"
else
_dev_env_multiarch="$(uname -m)-linux-gnu"
fi
_dev_env_lib="$_dev_env_deps/usr/lib/$_dev_env_multiarch"
export PKG_CONFIG_PATH="$_dev_env_lib/pkgconfig:$_dev_env_deps/usr/share/pkgconfig${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}"
export LD_LIBRARY_PATH="$_dev_env_lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
export LIBRARY_PATH="$_dev_env_lib${LIBRARY_PATH:+:$LIBRARY_PATH}"
export CPATH="$_dev_env_deps/usr/include${CPATH:+:$CPATH}"
unset _dev_env_multiarch _dev_env_lib
fi
unset _dev_env_root _dev_env_deps
29 changes: 29 additions & 0 deletions linux/scripts/smoke-postgres.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Driver-level smoke: connect, list tables, fetch rows, edit a cell.
# Needs a Postgres already listening on SMOKE_PG_HOST:SMOKE_PG_PORT.
# docs/testing.md has a one-liner that starts one.
set -euo pipefail

ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"

SMOKE_HOST="${SMOKE_PG_HOST:-127.0.0.1}"
SMOKE_PORT="${SMOKE_PG_PORT:-54329}"
SMOKE_USER="${SMOKE_PG_USER:-tablepro}"
SMOKE_PASS="${SMOKE_PG_PASS:-tablepro}"
SMOKE_DB="${SMOKE_PG_DB:-tablepro}"

if [[ -f "$ROOT/scripts/dev-env.sh" ]]; then
# shellcheck source=/dev/null
source "$ROOT/scripts/dev-env.sh"
fi

export SMOKE_PG_HOST="$SMOKE_HOST"
export SMOKE_PG_PORT="$SMOKE_PORT"
export SMOKE_PG_USER="$SMOKE_USER"
export SMOKE_PG_PASS="$SMOKE_PASS"
export SMOKE_PG_DB="$SMOKE_DB"

echo "Smoke against postgres://${SMOKE_USER}@${SMOKE_HOST}:${SMOKE_PORT}/${SMOKE_DB}"
cargo test -p tablepro-driver-postgres --test smoke_local -- --include-ignored --nocapture
echo "Smoke passed."
Loading