easysql 0.2.1

getting to the sql prompt, quick and easy - saved connections, their passwords and their tunnels in one CLI + TUI
<!--
AI-ONLY DOCUMENT. This file exists to give an AI agent the COMPLETE operating picture for this repo. Optimize for completeness and precision for the agent, not for human readability. Humans read README.md instead. Do not remove detail to make this nicer, err toward more explicit, not less. FORMAT: machine-read, not a formatted human doc. Do NOT hard-wrap lines to a column width for readability; put each rule/point on ONE line, however long.
-->
# AGENTS.md

Working brief for an AI coding agent, not documentation for people (the README covers that): the rules, invariants and gotchas needed to change this project correctly without rediscovering them.

**What does not belong here.** How to write code - comments, tests, error wording, how to answer, when to commit - is not project knowledge: it comes from `~/dotfiles/development/MYRULES.md`, imported ahead of this file and outranking it. A rule that would be true in any repo is a sign it belongs there instead. Where the two genuinely contradict, ask rather than pick.

## Hard rules
- **easysql is a front end and never a client.** It does not speak any wire protocol, does not link a driver, and never invents a store where the client already has one: it writes `~/.pg_service.conf`, `~/.pgpass`, `~/.my.cnf` and hands the terminal to the real `psql`/`mysql`/`sqlite3`. That is what keeps `\c`, `.psqlrc`, every script and every GUI on the machine working, and it is what makes uninstalling easysql cost nothing. A feature that requires holding a connection open is out of scope, not a missing dependency.
- **Never leave somebody with a tool that does not work.** `cargo install easysql` cannot bring the clients, because they are not Rust; that is a reason to handle it, not an excuse. Anywhere a missing client can be discovered, easysql names the *one* command this machine needs (`engines::install_argv` walks PATH for apt-get/pacman/dnf/zypper/apk) and, in the TUI, offers to run it suspended so sudo and the package manager can both prompt. Printing three distros' worth of guesses is not help, and an errno from a failed spawn is not an error message. When a manager or a package name is not known for certain, `package()` returns `None` and we name the program rather than inventing a package that does not exist.
- **A password is written, never held.** It is typed into a `Kind::Secret` field, goes straight into the file that engine's client reads, and is never echoed, never previewed, never logged, never put in an environment variable and never put in an argv - `ps` shows a command line to every user on both machines, which is why MySQL itself warns about `-p<password>`. Nothing in this crate ever reads a stored password back out; the UI only ever shows *that* one exists.
- Tests never touch the real `~/.pg_service.conf`, `~/.pgpass`, `~/.my.cnf` or `~/.config/easysql`: use `App::empty()` and the `*_in(path, ...)` / `load_from(path)` helpers, which keep everything on temp files and in-memory data.
- Do not grow the CLI. It holds connect, `ls` and `self` only, all things faster to type than to click. Anything a user would have to look up belongs in the TUI, which is the whole point of the tool.

## Invariants and gotchas
- When touching a connection file: every write goes through `ini::upsert`/`ini::remove`, which back the file up to `<name>.bak.<epoch>` first and rewrite only the one section, copying every other line, comment and blank through verbatim. A user's hand-written `connect_timeout` and their ordering are not ours to reformat.
- When rewriting a section: keys the wizard did not ask about survive, because `Section::rest(&KNOWN)` hands them back and the caller appends them. Adding a field to a wizard means adding its key to that engine's `KNOWN` array in the same edit, or the value gets written twice - once as a field and once as a leftover extra.
- `sslmode` is the exception: it is an extra that the Postgres wizard *does* own, so `submit_prompt` strips it from `extra` and re-adds it from the choice field. Index 0 of `SSLMODES` is "(unset)" and means removing the key, not writing the string.
- When touching MySQL: only `[clientNAME]` groups are ours. `[client]` itself is read by every MySQL tool on the machine, so writing a host into it would silently redirect `mysqldump` and everything else; `name_of` returns `None` for it and for `[mysqld]`. An edit preserves the group's existing `password` key, because the connection wizard never shows it and a rewrite would otherwise delete it.
- When writing `~/.my.cnf` or `~/.pgpass`: chmod 0600, via `ini::harden`. This is not hygiene - libpq flatly refuses a world-readable `.pgpass` and MySQL ignores a world-writable `.my.cnf`, so the file simply does not work without it.
- `.pgpass` is `host:port:database:user:password` with `\` escaping `:` and `\`, and `*` as a wildcard in any of the first four. `set_pg` replaces the entry whose first four fields match rather than appending, because libpq takes the first match and a second line would never be reached. `Source::Pgpass(i)` counts *entries*, not lines, so comments cannot shift which row a delete removes.
- The `.pgpass` Database field defaults to `*` when saving a password, because a Postgres password belongs to the *role* and roles are cluster-wide: the same secret unlocks every database on that server, so naming one only makes `\c elsewhere` prompt. It stays editable rather than being forced, because behind a pooler (pgbouncer, an RDS proxy) the database name really does select a different backend with different credentials.
- `e` on the Passwords tab moves an entry's match fields and keeps its secret (`creds::rekey_pg`, keyed by entry index). The password is copied line-to-line and never surfaced, which is the same handling every write already gives it: rewriting the file at all means the other entries pass through memory. The invariant is that easysql never *shows* a stored password, not that it never touches the bytes. Only `.pgpass` has entries of its own - a MySQL password lives inside its connection's `[clientNAME]` group, so `e` there points at the connection instead.
- `o` on the Passwords tab opens `~/.pgpass` or `~/.my.cnf` in `$VISUAL`/`$EDITOR`, suspended like a session, after `ini::backup`. It exists because libpq takes the *first* matching line, so which of two overlapping entries wins is decided by their order and no wizard field can express that; the backup is because an editor is the one write path easysql does not control. The editor string is split on whitespace like a client command, so `EDITOR="code -w"` works. `refresh_all` after the run re-reads the file, so nothing else is needed.
- Snippets are plain `.sql` files in `~/.config/easysql/snippets`, run as `esql <conn> :<name>` with each engine's own flag (`Engine::query_flag`: `-c`, `-e`, `-Q`, and `None` for sqlite3, which takes the query as a bare argument). The `:` sigil is psql's own and cannot collide with a client flag or a database name, so a bare word after the connection still passes through untouched. They are deliberately not engine-scoped: a Postgres-only query simply fails on MySQL, and the server's error is better than any dialect guess easysql could make.
- `snippets::sync_psqlrc` regenerates a marked block in `~/.psqlrc` (`\set name '…'`) so psql expands `:name` at its own prompt too - psql is the only client with variables, which is why the shortcut has to live at the shell for the other three. It is called from `refresh_snippets`, so every reload (startup, `r`, save, delete, returning from `o`) keeps it in step; it writes nothing when the content is unchanged, which matters because it also runs on the way into every session and would otherwise drop a `.psqlrc.bak.<epoch>` per connect. Flattening strips `--` comments, or they would swallow the rest of the one-line value.
- `run_suspended` sets SIGINT/SIGQUIT to `SIG_IGN` in the parent and back to `SIG_DFL` in the child's `pre_exec`, the way `system(3)` does. Ctrl-C at the terminal goes to the whole foreground group, so without it Ctrl-C inside psql kills easysql mid-suspend and the shell is left in raw mode. `SIG_IGN` survives exec, so the child must reset it or Ctrl-C stops working in the client too.
- The `hints` setting prints one orientation line before the terminal is handed over, from `Engine::hint()` (each client's own words for list databases, switch, list tables, describe, help, quit) wrapped by `engines::hint_line`, which appends this machine's `:names` only when the *configured* client really is psql - the same gate the install offer uses, because somebody who pointed `psql_command` at pgcli gets a client that reads neither `~/.psqlrc` nor `\set`, and offering `:name` there advertises a syntax error. The decision lives in `hint_line_from`, kept apart from the `snippets::list()` that reads the disk so it can be tested without a real config dir. It fires only when a session is being opened, never for `esql <conn> :snippet` or `esql <conn> -c '...'`, because a one-shot prints its own result and a hint above it is noise in a pipe. In the TUI it is printed *after* the alt-screen is gone, or it would be wiped along with it.
- A new tab means a `View` variant, its `title()`, a state field, a `*_rows()`, and arms in `render`, `input`, `detail` and `move_sel`. The tab strip is built from `VIEWS` via `View::title()` on purpose: it used to be a second hardcoded list beside it, and adding Snippets made every tab after it render under the previous one's name. **Both tapes count `Tab` presses**, so a new view between two existing ones silently re-aims every screenshot after it.
- When identifying a connection: use `Conn::key()` (`<slug>:<name>`), never the bare name. Two engines are allowed to both have a `prod`, which is why history, selection and `reach` are all keyed on it and why `engines::find` refuses an ambiguous bare name instead of guessing.
- `Conn::connect_argv` is the single source of what runs. The CLI's `exec`, the TUI's suspended run and the wizard's live preview must all go through it, or the preview lies about what Enter will do.
- When touching the failure path: `tui/probe.rs` re-asks the server non-interactively (`select 1`, `-w` so nothing can prompt, `PGCONNECT_TIMEOUT`/`--connect-timeout`) and classifies the client's *own literal strings*. Keep those strings verbatim; paraphrasing them turns an honest diagnosis into a guess. The probe runs only after a failed connect, so the success path costs nothing.
- `Confirm` is two of the six kinds of box, told apart by its `danger` flag: `Confirm::new` is a gate in front of something irreversible, red, starting on **No**; `Confirm::offer` is the app volunteering a fix after the client already failed, cyan, starting on **Yes**. An offered fix opens the wizard that fixes the thing with every field pre-filled, rather than doing anything itself. A failure with no fix to propose is not a `Confirm` at all: it is an alert (`tui::alert`, yellow, dismiss only), which is where a client that could not be started ends up.
- When probing reachability: only `Engine::networked()` connections are probed, and a target whose port will not parse is dropped rather than probed on port 0. A TCP connect answers "is anything listening", never "can I log in" or "does that database exist", and the labels must not claim more. Answers carry `reach_gen`; one from an older generation is discarded rather than repainting a list that has moved on.
- When filtering: `/` starts a query over the current list only, and it is dropped when you switch view. A selection indexes the *filtered* rows, so every `selected_*` goes through `*_rows()`; indexing the raw vec picks the wrong entry the moment a filter hides anything. `filter::matches` filters and never re-ranks, since the Connections order is already earned by `history`.
- **A dot is only true while nothing has moved, so re-probe whenever something has.** `start_probes` runs on `App::new`, on `r`, on a settings change, after adding or editing a connection, **after any tunnel goes up or down** (the wizard, `vias::ensure` on the way into a session, and the Tunnels tab's `enter`/`d`) and **on return from a suspended session**. Miss one and the list lies in the most confusing direction available: a connection shows red `down` while pressing Enter on it works perfectly, because the forward came up after the only probe that ever ran.
- When touching list order: Connections is sorted by `history::rank` (most recently opened first, then never-used alphabetically), so `refresh_conns` must call `sort_conns` and `App::new` must load the history *before* the first refresh. `ConnOrder` has three answers, not two: `Engine` groups by `Engine::idx()` (the order `ENGINES` declares, not the slug - `lite/my/pg` is an abbreviation nobody asked to read) and then by name inside each, while `Name` sorts on the name alone and falls back to the engine only to keep the order total. `Engine` used to be called "alphabetical", which described what it does *within* an engine and hid the grouping, so `set` still accepts that old spelling and maps it to `Engine` rather than silently reordering an existing settings file. `esql ls` stays in that fixed order: a script's output must not depend on what you did yesterday.
- When an action makes something, jump to its view with `goto_view` and select the new row (`select_conn`, `select_tunnel`). Assigning `self.view` directly skips the filter clearing and can land you on a tab that hides the very thing you just made.
- When adding a setting: put it in `settings.rs` only - a field, a default, a `set` arm and a `rows()` entry - and the Settings tab, the file and the `d`-to-default key all pick it up with no UI work. A setting whose default is the empty string needs an explicit arm in `reset`, because `set` reads an empty value as "leave it alone". A setting the running app acts on must also be re-applied in `apply_settings`, or it only takes effect at the next launch.
- `package(PackageManager, Engine)` is exhaustive with **no catch-all arm**, on purpose: adding an engine or a package manager is then a compile error naming exactly which cells are still owed, instead of a feature that silently ships half-wired. Keep both sides enums and never introduce a `_ =>`; a cell we genuinely do not know is an explicit `None`, not a fallthrough. Detection probes PATH for each manager's program rather than reading `/etc/os-release`, because what is installed is the fact that matters and a distro a machine claims to be is not always the thing managing it.
- Only the apt-get row of `package()` has been verified against a real machine. The pacman, dnf, zypper and apk names are asserted, not tested, and the check is one `pacman -Si <pkg>` / `dnf info <pkg>` per row on a box that has each. Do not add a manager whose package names you have not actually looked up.
- The install offer only fires for the *default* client. Somebody who pointed `psql_command` at `pgcli` or a docker wrapper meant it, and installing `postgresql-client` is not what they asked for; that case gets a status naming the setting instead. `ConfirmAction::InstallClient` is also the one confirm arm that returns a `PendingRun`, because the install has to own the terminal like a session does.
- When adding a TUI action: `c` creates in every view, `d` deletes or kills, `e` edits, `r` refreshes, `y` yanks the command and `Y` the URL, `p` saves a password, `t` opens a tunnel, and on the Tunnels tab `↵` turns a forward on or off (the same key does the same thing in easyssh, and normalising the two was deliberate). Destructive persistent actions get a yes/no gate; trivially redone ones act at once.
- When an action needs a value the app already knows, open a picker instead of a text field. The engine of a new connection, the connection a password is for, and the ssh host a tunnel goes through are all picked; the tunnel's host field is typed OR picked, with `Ctrl-o` opening the picker on it (matched by the label containing "ssh host", so renaming that label silently breaks it).
- When spawning anything: children run without a shell, so a typed `~/db.sqlite` reaches `sqlite3` as a directory literally named `~`. Expand with `ini::expand_tilde` before spawning, and use `ini::collapse_tilde` for display only.
- When suspending the TUI: call `show_cursor()` after leaving the alt-screen, because the draw loop hides the cursor and leaving the alt-screen does not restore it, so the client would otherwise run with an invisible cursor.
- Status messages are transient (`set_status`, cleared after `STATUS_TTL`); the event loop polls while a message is showing or a probe is in flight and blocks otherwise, so an idle TUI costs nothing.
- Every overlay goes through the house box in `tui::widgets` (`box_block`, `box_buttons`, `box_hint`, `box_width`, `box_inner_width`, `box_height`, `box_area`), never a hand-rolled `Block`. Measure the body with `wrapped_line_count(text, box_inner_width(width))` and hand the *wrapped* count to `box_height`, which adds the chrome; counting lines instead is what clipped the buttons off. The percentage-width `centered` and `PROMPT_PCT` were deleted rather than kept beside it, because two ways to size a box is how the padding stopped being accounted for in one of them.
- The detail panel renders only above `MIN_WIDTH_FOR_DETAIL` and reads only what is already loaded: never a fresh query, never a connection of its own. The one exception is a `Path::exists` on a local SQLite file, which is a stat on this machine and cannot block.
- A connection can *remember* the forward it needs, in `~/.config/easysql/vias`, keyed by `Conn::key()`. That file is ours and not the client's for a reason that was tested rather than assumed: libpq validates every keyword in `~/.pg_service.conf` and refuses the whole file over one it does not know (`psql: error: syntax error in service file`), so a `via=` key there would break the connection for psql, pgAdmin and every driver on the machine. `vias::ensure` is called on the way into a session from *both* the CLI and the TUI, and it is a no-op when `tunnels::carrying(local)` already finds a live `-L` on that port, so a second run never digs a second tunnel. Deleting a connection deletes its via, or the file fills with forwards for things that no longer exist.
- A connection whose via is recorded but whose forward is not running is yellow, never red and **never green**: `detail::sleeping_via` is the single test (it returns the `Via`, so the line can name the port), and the list mark and the detail line both ask it. Red would blame the database for a tunnel that is one keypress from opening; green is the dangerous one, because a probe that answers on the near end of a forward that is not running is answering for whatever else holds that port - a local Postgres on 5432, say - and going in without saying so is how a migration lands on the wrong server. The detail line says which of the two it is.
- `vias::points_at_it` catches the other half of that disagreement: a via is recorded but the connection still asks for the far address, so `ensure` opens a forward nobody uses. The detail panel says so and never repairs it, because which half is wrong is the user's call.
- The tunnel wizard's first field falls back to `vias::default_host` when the `tunnel_host` setting is empty: this connection's own last hop, else the single host every other via uses, else nothing. It never guesses between two bastions, and it never overwrites the setting, because somebody who named one meant it.
- A rename changes `Conn::key()`, so **everything keyed on it has to move in the same save**: `vias::rename` and `history::rename` are both called from the edit path. Miss one and the symptom is silent - the connection forgets the tunnel it depends on, or drops to the bottom of the list as though it had never been opened. Anything new that is keyed on `key()` belongs in that same block.
- The tunnel wizard carries the key of the connection it was opened for (`Action::Forward { key }`), which is what lets it record the via and offer the repoint the moment the forward is up. Without that, the connection still points at the far address and only finds out by failing a second time.
- The far end of a `-L` is resolved *by the ssh host*, not by us, so `prompt::forward_target` rewrites the target to `127.0.0.1` when `sshhosts::is_same_machine` says the database is on the very machine being hopped through. This is the common case and getting it wrong is invisible: the tunnel comes up fine and carries nothing, because a server bound to loopback does not answer on its own LAN address even from itself. It is re-resolved on *leaving* the ssh-host field (`leave_field`), not only when the picker fills it, because that field is typed or picked.
- Tunnel liveness reads `/proc` and shells out to `kill`, so it is Linux-only and macOS or BSD would need a different check first. It matches `/proc/<pid>/cmdline` against *this* forward's flag, spec and host rather than only asking whether `/proc/<pid>` exists: the state file outlives a reboot, and a recycled pid would otherwise read as up and be what `d` kills. A refused forward does *not* kill ssh, so `forwarding_failed` matches OpenSSH's own phrases and cleans up, or a phantom tunnel is listed carrying nothing.
- SQL Server is the exception to almost everything, twice over. **Its list is ours**, in `~/.config/easysql/mssql.conf`, because no file both clients read exists: `go-sqlcmd` keeps YAML contexts in `~/.sqlcmd/sqlconfig` and the classic ODBC `sqlcmd` from `mssql-tools18` has no config file at all, so `mssql::flags` builds plain `-S host,port -d db -U user`, which both builds understand. Note the comma in `-S host,port`: sqlcmd does not take a colon. **And it stores no password**: `-P` is never passed (Microsoft's own docs call it insecure) and `SQLCMDPASSWORD` would mean holding a secret and handing it to a child, so sqlcmd prompts on the terminal we already hand over. `Engine::stores_password()` is the gate, and it is what the `p` key, the password picker and the detail panel all ask - never `networked()`, which SQL Server also answers yes to.
- `trust_cert` is to SQL Server what `sslmode` is to Postgres: an extra the wizard owns, stripped from `extra` and rebuilt from the choice field on every save, with index 0 meaning the key goes away. It defaults to validating, because ODBC driver 18 encrypts and validates by default and waiving that silently would be easysql weakening somebody's connection for them; `-C` is a deliberate answer for a self-signed dev server.
- SQL Server has **no distro package**, on any manager: `package()` returns `None` for all five, so no install offer can fire and `install_note` names Microsoft's repo and `https://aka.ms/go-sqlcmd` instead. Anything that promises the offer (the CLI's third line) must check `install_argv(...).is_some()` first. `mssql-tools18` installs to `/opt/mssql-tools18/bin`, which is not on PATH, so a machine that has it still looks empty until the user adds it or points `sqlcmd_command` at the full path.
- The sqlcmd strings in `tui/probe.rs` ("Login failed for user", "TCP Provider", "Login timeout expired", "Cannot open database") are asserted from the documented SQL Server errors, **not verified against a real server** the way the libpq ones were. Check them against an actual `sqlcmd` before trusting the diagnosis, and treat a wrong classification here as a bug, not a tuning question.
- Which wizard fields carry the red `*`, matching what `submit_prompt` refuses: the connection Name in both engine shapes and the SQLite Database file (a server connection needs no database up front, since `\c` moves between them); a snippet's Name and its SQL; the password field itself, in both the Postgres and the MySQL shape; and the tunnel's ssh host, database host and database port, but not its local port, which falls back to the database port. The `.pgpass` matching fields are never starred: a blank there is a real answer, since the line is widened with `*` rather than filled in.

## Build / lint / test
- `cargo build --release`, binary at `target/release/esql`.
- `dev/db.sh up` starts a throwaway postgres and mariadb in podman (or docker) to point connections at; `dev/db.sh down` removes them. The *clients* still have to be installed on the machine, because easysql never talks to a database itself.

## The README pictures
- `demo/` is the rig that renders every image in `readme-assets/`, and it is committed because it is the *source* of those assets the way a build script is the source of a binary. Never capture a frame by hand and never crop one: change the tool, rerun the tape.
- `demo/stage.sh up` builds a fake HOME in `demo/home/` (gitignored), seeds every file easysql reads, starts two throwaway podman containers on 127.0.0.1:55432 and :53306 so a session really opens, and stamps `.easysql-demo-stage`; `down` refuses to delete anything without that marker. `./stage.sh shell` opens a shell where `esql` is this build, and it **refuses to open one when the stage is missing** - without that guard a mistyped path renders the real `~/.pg_service.conf` into a published image, which has already happened once in this repo.
- Three tapes, run one at a time (two sharing the stage delete each other's fixtures mid-take, and the symptom is identical images rather than an error): `shots.tape` for the TUI stills, `cli.tape` for the CLI ones (its own tape because the frame is sized to its content), `demo.tape` for the GIF. All three set `Catppuccin Mocha` and `JetBrainsMono NF`, which is the house style shared with the sibling crates.
- Tabs move with `Tab`, never a number key; a tape that types `2` silently captures the same frame four times.
- The README is never allowed to mention `demo/`: it is read by people installing the crate, who got a package with `demo/` excluded.

## Overview
Layout:
- `src/main.rs` - the clap `Cmd` enum and the dispatch match, nothing else.
- `src/commands/<verb>.rs` - one file per CLI command (`connect`, `ls`, `selfcmd`), each exposing `run`.
- `src/engines/` - the seam. `mod.rs` owns `Engine`, `Conn`, `NewConn` and the merged list; `pg.rs`, `mysql.rs`, `sqlite.rs` and `mssql.rs` each know exactly one thing: which file that client already reads, and which argv it wants. Nothing above this line knows which database it is about to open.
- `src/tui/` - the toolbox: `mod.rs` owns `App`, the event loop and the terminal handling, `input.rs` dispatches keys per view, `wizard.rs` is what a submitted prompt does, `prompt.rs` the field machinery, `picker.rs` and `confirm.rs` their overlays, `render.rs` the frame, `detail.rs` the panel beside the list, `filter.rs` what `/` keeps, `widgets.rs` the domain-blind furniture, `probe.rs` reading why a connection failed.
- Domain modules at the top level: `ini` (the `[section]` files two engines share), `creds` (`~/.pgpass` and MySQL group passwords), `tunnels` (`ssh -L`), `vias` (which forward a connection needs, `~/.config/easysql/vias`, and `vias::rows()`, the join of what is running with what is only remembered, which is what the Tunnels tab lists), `snippets` (the `.sql` files and the `~/.psqlrc` block), `history` (when you last opened what), `reach` (background port probes), `sshhosts` (read-only `~/.ssh/config` aliases), `clip` (the system clipboard), `settings` (`~/.config/easysql/settings`).

`easysql` is a Rust CLI and TUI that makes databases simple: one binary `esql` in place of `psql`, `mysql`, `sqlite3`, `sqlcmd`, a password manager and hand-editing each of their config files. It is a smart front end rather than a reimplementation, so every action shells out to the real client and every file it writes is one that client already reads. Bare `esql` opens a ratatui toolbox with Connections, Passwords, Tunnels, Snippets and Settings tabs and their wizards, while the CLI keeps connect, `ls` and `self`. Crate `easysql`, binary `esql`, repo `git@gitlab.com:safteinzz/easysql.git`, AGPL-3.0-only. It is the sibling of `easyssh`, and the TUI chassis is deliberately the same one: a change to how a tab, a wizard or a modal behaves in one is usually worth making in both.

## Self-repair
If anything here contradicts the code, the code wins; fix AGENTS.md in the same session you notice the drift.