feat: initial Gridline implementation (#1)
* chore: configure Tailwind, Vitest, Rust deps, dialog plugin (Task 1)
* feat: shared types and validation/filter utilities (Task 2)
* feat: Rust models, SQLite store, and migrations (Task 3)
* feat: Tauri commands for connections and folders (Task 4)
* feat: tag, settings, import/export Tauri commands (Task 5)
* feat: frontend command wrappers and Zustand stores (Task 6)
* feat: useSearch debounce and useFilteredConnections hooks (Task 7)
* feat: App view router with load-on-mount (Task 8)
* feat: UI primitives Button, Input, Badge, Card (Task 9)
* feat: SearchBar, ActionRow, ImportExportMenu (Task 10)
* feat: ConnectionCard, ConnectionGrid, TagBadge (Task 11)
* fix: resolve TypeScript errors across utils, Input, and test files
* feat: FolderTree, CreateFolderDialog, NewConnectionForm, SettingsPage, HomeScreen (Task 12)
* feat: Tauri file dialog integration for import/export (Task 13)
* feat: error/loading states and full App router integration (Task 14)
* chore: final integration gate — full test + build green (Task 15)
* fix: move folders inline, rearrange ActionRow layout, add borders to buttons
* feat: upgrade to Tailwind v4, apply new color palette
* Landing page restyle, folder explorer, selection/delete, and icon update
- Black-and-white color scheme with blue accent
- Glassmorphic New Folder modal with auto-parent
- Nested folder explorer with breadcrumb navigation
- Folder/connection selection with hover checkboxes
- Select All / Clear Selection / Delete dropdown
- Delete reparents children to parent folder
- Fixed Rust serde camelCase mismatch (models now use snake_case)
- Updated app icons from Apple Icon Composer exports
- Search bar with ⌘K badge
- Button styling: rounded pills, borders, cursor-pointer
- Prevent drag selection across app
* Tag system, folder edit/delete, searchable tag picker, delete confirmation, tag reordering
- Added folder_tags table for folder-tag associations
- Tags now show configured colors on folder and connection cards
- Searchable tag picker with text filtering in New/Edit Folder dialogs
- Edit Folder dialog with name and tag management
- Edit/Delete buttons next to breadcrumb with icons
- Delete confirmation dialog with reparenting notice
- Tag reordering in Settings with up/down buttons, persisted via tag_order setting
- useSortedTags hook for consistent tag ordering
- Cmd+K focuses search input
- Selection dropdown simplified to Select All/Clear/Delete
- Click-outside closes all dropdowns
- cursor-pointer on all interactables
* deps: install motion for animations
* feat(types): add system theme option
* feat(settings): default stored theme to system
* feat(ui): add Toggle primitive
* feat(ui): add Select primitive
* feat(ui): add SettingsRow and SettingsSection primitives
* fix(ui): improve SettingsRow and SettingsSection quality
* feat(ui): add ThemePicker primitive
* feat(ui): add AnimatedModal primitive with motion
* refactor(dialogs): use AnimatedModal for enter/exit animations
* fix(dialogs): ensure exit animations and Escape handling work with AnimatedModal
* feat(settings): redesign page with multi-section layout
* refactor(settings): split tabs, improve accessibility and animation
* fix(tauri): set window background color to dark canvas
* fix(settings): remove fake traffic lights, add header background, transparent title bar
* fix(settings): move Back into sidebar, add Settings heading, dynamic window title
* fix(tauri): show native window title so Home/Settings labels are visible
* fix(ui): disable overscroll on both axes
* test(dialogs): verify CreateFolderDialog shows and saves tags
* chore: ignore .worktrees directory
* refactor: centralize db type icons and labels
* feat: add connection string parser and detection
* test: add edge cases for connection string parser
* fix: preserve absolute paths in SQLite connection strings
* types: add new connection form fields
* feat: add folder path label helper
* test: cover missing folder in path label helper
* feat: add stub connection string parse and test commands
* feat: add stub test connection command
* feat: add new connection form primitives
* feat: add connection form shell
* feat: add shared form type and simple connection form
* fix: keep SimpleConnectionForm fully controlled
* style: format and tighten types for simple connection form
* feat: add detailed connection form
* fix: export DetailedConnectionFormProps and assert port update
* feat: add new connection screen container
* fix: clean up useEffect dependencies in new connection screen
* fix: address quality feedback on new connection screen
* feat: wire new connection screen into app and remove old form
* feat: open new connection screen from pasted db url in search
* test: add home search URL shortcut integration test
* refactor: replace FolderSelect and EnvironmentSelect with shared SelectDropdown
Add a reusable SelectDropdown component that keeps the select-styled
trigger while using the ActionRow-style popover menu. Update both
FolderSelect and EnvironmentSelect to use it.
* refactor: remove db type icon and label from connection form shell
Drop DbTypeHeader and the db_type prop from ConnectionFormShell so the
form header no longer displays the database icon/type. Update
NewConnectionScreen and its test accordingly.
* feat: home screen redesign and connection form updates
- Redesign HomeScreen layout and settings page
- Update connection card, grid, and simple form
- Replace selects with SelectDropdown component
- Remove DbTypeHeader from connection form
- Fix tests to match updated components
* fix: hide folders during search, update placeholder, and set window titles
- ConnectionGrid now hides folders when hasSearch is true so only
matching connections are shown.
- SearchBar placeholder now mentions typing a database URL to create
a new connection.
- App window titles set to Gridline (home), Settings, and New Connection.
* fix: shorter placeholder, dynamic window title, and startup flash
- Shorten SearchBar placeholder to mention DB URL creation concisely.
- Add core:window:allow-set-title permission and set document.title so
the window title updates per view (Gridline, Settings, New Connection).
- Add inline dark background style to index.html to prevent white flash
before CSS loads.
* feat: extend connection models with SSH/SSL/database fields, add db_viewer models (Task 1a)
* feat: extend frontend types with SSH/SSL fields and DB viewer types (Task 1b)
* feat: migrate connections table with SSH/SSL columns (Task 1c)
* chore: add new Rust dependencies (tokio, sqlx, redis, ssh2, indexmap, etc.) for Phase 2
* feat: connection pool manager with LRU eviction (Task 2a)
* feat: schema introspection query builders for PG, MySQL, SQLite (Task 2b)
* feat: test connection command (all 4 DB types) and SSH tunnel manager (Task 2c)
* feat: db viewer Rust commands and AppState refactor (Task 2d)
* feat: frontend command wrappers for DB viewer and updated validation (Task 3a)
* feat: dbViewer store with tabs, changes queue, pagination; uiStore activeConnectionId (Task 3b)
* feat: ConnectionCard navigates to DB Viewer, App routes to DbViewerScreen (Task 3c)
* feat: tooltip UI primitive (Task 4a)
* feat: SSH/SSL form tabs in DetailedConnectionForm (Task 4b)
* feat: DbViewerScreen shell with sidebar navigation (Task 4c)
* feat: toolbar, table tree, and overflow menu (Task 4d)
* fix: align TableInfo interface between frontend types and test files
* feat: tab bar, data grid, and pagination controls (Task 4e)
* feat: changes queue panel with cancel per change (Task 4f)
* feat: wire real IPC connect/disconnect/load in DbViewerScreen (Task 5a)
* feat: error banner, guard dialogs for destructive actions (Task 5b)
* feat: changes queue commit all with per-change IPC and error handling (Task 5c)
* chore: suppress expected dead-code warnings during multi-phase development
* fix: wire ConnectionCard click through to DB Viewer (HomeScreen -> ConnectionGrid -> ConnectionCard -> App route)
* fix: click connection opens DB viewer when nothing selected, toggles selection when items already selected; fix delete connections + generic confirm message
* fix: align ConnectionTestResult field name (ok vs success) and fix testConnection invoke param name (input vs config) to match Rust backend
* feat: implement DB Viewer Tauri commands (db_connect, get_databases, etc.)
* fix: align DB viewer IPC param names (snake_case), implement execute_change + refresh_connection, snake_case Change enum tags
* fix: use camelCase IPC keys for multi-word Tauri command params (Tauri v2 converts snake_case Rust -> camelCase)
* fix: surface full postgres error detail and redact credentials instead of opaque 'db error'; URL-encode pg connection strings
* fix: cache passwords per-session so saved connections can auto-connect; surface full postgres error detail
* fix: auto-fetch table data, full-row click, fill viewport, load columns on expand, database switching
* fix: align ColumnInfo/QueryResult types with Rust backend (columns=ColumnInfo[], rows=unknown[][], field names match)
* feat: truncate cell text, resizable columns with drag handles, tab bar no-wrap horizontal scroll
* fix: QueryResult uses total_rows/page/page_size (match Rust), brighter table borders, pagination NaN fix
* fix: table horizontal scrolling, viewport fills screen height (h-screen), remove clipping overflow-hidden
* fix: constrain layout to viewport with overflow-hidden on content column; flex-1 fills height; sidebar+grid scroll independently
* fix: move overflow-hidden down to grid wrapper so DataGrid scrollbars surface properly
* fix: add min-w-0 to DataGrid wrapper so flexbox allows shrinking for horizontal scroll
* fix: add overflow-hidden to flex row and right column to clip at viewport, DataGrid scrolls inside
* fix: use w-0 on right column to force width:0 flex-basis, preventing any content-based expansion
* fix: overscroll-contain on DataGrid and TableTree scroll areas to prevent bounce
* fix: inline overscroll-behavior:none + WebkitOverflowScrolling:auto for reliable macOS bounce prevention
* feat: page-size selector (50/100/200) in pagination bar, setPageSize resets to page 1 and triggers re-fetch
* fix: column resize with refs, FK cell click opens table with filter, vertical borders, tooltip positioning (right/bottom), sidebar transparent bg
* fix: sidebar bg-canvas, remove overflow-hidden from toolbar parent so dropdown tooltips aren't clipped
* chore: rename sidebar label from DB Viewer to Explorer
* feat: resizable table panel (180-600px) with drag handle on right edge
* feat: double-click panel resize handle resets to default 280px
* feat: column visibility dropdown in filter bar, max column width 800px, double-click resets column width
* fix: enforce column width on th cells, remove min-width:100% so columns stay at set width instead of stretching
* fix: removable resize handle always clickable (remove opacity-0), maxWidth+overflow on th/td to prevent column blowout
* feat: add environment label to connections (production/staging/development) with badge on cards
* feat: OS keychain integration for connection passwords via tauri-plugin-keyring-store
* style: monospace font for table data cells
* style: use Space Mono (font-heading) for table data cells
* feat: unified table controls bar (insert, refresh, auto-refresh, filter modal, sort modal, export, columns, pagination); fix pagination setPage not clearing data
* fix: auto-refresh defaults to off, click opens dropdown to pick interval
* fix: pagination/refresh keeps existing data visible, shows subtle loading bar instead of blank
* fix: new tabs start with loading:true so auto-fetch triggers instead of stuck on 'loading table data'
* feat: checkbox column for row selection (header=all, row=individual, selected state tracks indices)
* feat: selected row count in toolbar with clear button
* feat: bulk actions dropdown on selection (Copy JSON, Copy CSV, Copy SQL INSERT, Delete rows)
* docs: add changes-queue-before-execution rule to AGENTS.md guardrails
* docs: broaden CRUD guardrail to cover all destructive operations (DB data via queue, app entities via confirm dialog)
* feat: auto-create demo SQLite DB on first launch with sample e-commerce schema (users, products, orders, order_items)
* fix: demo DB startup panic (state before manage), add settings: re-add demo, table refresh rate, table page size
* feat: Cmd+W closes tab or navigates home, edit connection modal with update_connection backend, action queue button with count badge and dropdown
* fix: columns button icon-only, wire settings.table_page_size and table_refresh_rate to actual table behavior
* feat: shortcuts settings tab showing all keyboard shortcuts (Cmd+K, Cmd+W, Esc, Enter, Space, click, header checkbox)
* feat: editable keyboard shortcuts - click pencil to record new keybinding, persisted to settings, useShortcut hook for dynamic binding
* feat: add Tags & Env tab to edit connection modal (environment, folder, tag picker)
* feat(db-viewer): UX polish - cursor pointers, icon-only toolbar, top border
- Add cursor-pointer to all interactive elements across db-viewer components
- Simplify TableControls: Filter/Sort/Export show icons only with tooltips
- Add border-t to DbViewerScreen for visual separation from window frame
* fix(db-viewer): style EditConnectionModal, fix test connection, working refresh button
- Match EditConnectionModal styling to home screen modals (AnimatedModal, Button, no dividers)
- Fix test connection sending null password by fetching from keychain first
- Make refresh database button functional with success/error visual feedback and spin animation
* feat(db-viewer): compact ghost-style DB/schema dropdowns in single row
- Add variant prop to SelectDropdown (pill | ghost) for minimal text-only style
- Place DB and schema dropdowns side-by-side on one row with | separator
* fix(db-viewer): proper FK/enum detection and improved schema tree display
Rust backend:
- PostgreSQL: fix column query to detect FKs (was hardcoded false) and map
USER-DEFINED types to udt_name for enum display
- SQLite: use PRAGMA table_info for types/PK/NOT NULL/defaults and
PRAGMA foreign_key_list for FK detection
Frontend:
- FK columns show orange key icon in TableTree
- Data type abbreviations (varchar, int, bool, timestamptz, etc.) with
full type name on hover tooltip
- Column icons use shrink-0 to maintain size
* feat(db-viewer): PK/FK icons and shorthand types in DataGrid headers
* fix(db-viewer): fix NULL values for UUID and timestamp columns, FK underline style
- Add uuid::Uuid, chrono types to pg_value_to_json chain so UUID PKs/FKs
and timestamp columns display correctly instead of NULL
- Enable with-serde_json-1 feature on tokio-postgres
- Change FK cell styling from blue text to dotted underline
* feat(db-viewer): FK preview popover with inline filter
- Replace direct FK navigation with popover showing the referenced row
- New get_fk_preview Rust command fetches single row by column value
- FkPreviewPopover component displays columns with PK/FK icons
- 'Open' button creates filtered tab, visible in existing Filter UI
- Consolidate columnFilter into filterRules (no duplicate filter logic)
* feat(db-viewer): selectable cell text, JSON/JSONB popover with formatted/raw views
- Add select-text to cell contents for copy support
- New JsonCellPopover component with Formatted/Raw tabs and copy button
- JSON/JSONB columns show brief preview ({ N keys } / [ N items ])
- Click JSON cells to open popover with pretty-printed or raw output
* feat(db-viewer): smart default sort adds newest-first sorting automatically
- 12-tier priority system detects recency/ordering columns
- Prefers updated_at, created_at, *_at suffixes, last_* prefixes
- Falls back to timestamp types, numeric IDs, sequence/position cols
- Also covers rank, version, count/quantity columns
- Applied once per tab, visible in Sort dropdown for manual override
* feat(db-viewer): search tables input with slide animation and tree filtering
- Search icon in toolbar toggles animated input with slide-down effect
- Filters TableTree by table name as user types
- Clean border-bottom styling, search icon on left, X clear on right
- Auto-hides on blur when empty, stays when content present
- Search icon highlights when active
* docs: add comprehensive implementation status to AGENTS.md
- Status matrix across 6 areas: connections, home, db viewer, object explorer, query editor, backup/restore, settings, onboarding
- Covers 60+ features with ✅/🟡/❌ markers and details
- Identifies remaining work: query editor, object explorer, virtualized grid, MySQL browsing, SSH tunnels, backup/restore
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
use crate::models::{Connection, ConnectionInput};
|
||||
use crate::store::Store;
|
||||
use std::sync::Mutex;
|
||||
|
||||
const VALID_DB_TYPES: [&str; 4] = ["postgresql", "mysql", "sqlite", "redis"];
|
||||
|
||||
fn validate(input: &ConnectionInput) -> Result<(), String> {
|
||||
if input.name.is_empty() || input.name.chars().count() > 100 {
|
||||
return Err("name is required and must be 100 chars or fewer".into());
|
||||
}
|
||||
if !VALID_DB_TYPES.contains(&input.db_type.as_str()) {
|
||||
return Err(format!(
|
||||
"db_type must be one of: {}",
|
||||
VALID_DB_TYPES.join(", ")
|
||||
));
|
||||
}
|
||||
if input.host.is_empty() || input.host.chars().count() > 255 {
|
||||
return Err("host is required and must be 255 chars or fewer".into());
|
||||
}
|
||||
if input.db_type != "sqlite" {
|
||||
match input.port {
|
||||
Some(p) if (1..=65535).contains(&p) => {}
|
||||
_ => {
|
||||
return Err(
|
||||
"port must be an integer between 1 and 65535 for this db_type".into(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(u) = &input.username {
|
||||
if u.chars().count() > 100 {
|
||||
return Err("username must be 100 chars or fewer".into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_connections_inner(state: &Mutex<Store>) -> Result<Vec<Connection>, String> {
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.get_connections()
|
||||
}
|
||||
|
||||
pub fn create_connection_inner(
|
||||
state: &Mutex<Store>,
|
||||
input: ConnectionInput,
|
||||
) -> Result<Connection, String> {
|
||||
validate(&input)?;
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.create_connection(input)
|
||||
}
|
||||
|
||||
pub fn update_connection_inner(
|
||||
state: &Mutex<Store>,
|
||||
id: String,
|
||||
input: ConnectionInput,
|
||||
) -> Result<Connection, String> {
|
||||
validate(&input)?;
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.update_connection(&id, input)
|
||||
}
|
||||
|
||||
pub fn delete_connection_inner(state: &Mutex<Store>, id: &str) -> Result<(), String> {
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.delete_connection(id)
|
||||
}
|
||||
|
||||
pub fn add_connection_tags_inner(
|
||||
state: &Mutex<Store>,
|
||||
connection_id: String,
|
||||
tag_ids: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.add_connection_tags(&connection_id, &tag_ids)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_connections(state: tauri::State<crate::AppState>) -> Result<Vec<Connection>, String> {
|
||||
get_connections_inner(&state.db_store)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_connection(
|
||||
state: tauri::State<crate::AppState>,
|
||||
input: ConnectionInput,
|
||||
) -> Result<Connection, String> {
|
||||
create_connection_inner(&state.db_store, input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_connection(
|
||||
state: tauri::State<crate::AppState>,
|
||||
id: String,
|
||||
input: ConnectionInput,
|
||||
) -> Result<Connection, String> {
|
||||
update_connection_inner(&state.db_store, id, input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_connection(state: tauri::State<crate::AppState>, id: String) -> Result<(), String> {
|
||||
delete_connection_inner(&state.db_store, &id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn add_connection_tags(
|
||||
state: tauri::State<crate::AppState>,
|
||||
connection_id: String,
|
||||
tag_ids: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
add_connection_tags_inner(&state.db_store, connection_id, tag_ids)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::ConnectionInput;
|
||||
use crate::store::Store;
|
||||
|
||||
fn state() -> std::sync::Mutex<Store> {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
crate::store::migrations::run_migrations(&conn).unwrap();
|
||||
std::sync::Mutex::new(Store::from_connection(conn))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_connections_returns_list() {
|
||||
let st = state();
|
||||
let result = get_connections_inner(&st);
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_connection_command_returns_connection() {
|
||||
let st = state();
|
||||
let input = ConnectionInput {
|
||||
name: "Prod".into(),
|
||||
db_type: "postgresql".into(),
|
||||
host: "h".into(),
|
||||
port: Some(5432),
|
||||
username: None,
|
||||
folder_id: None,
|
||||
password: None,
|
||||
database: None,
|
||||
environment: None,
|
||||
ssh_host: None,
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
tag_ids: vec![],
|
||||
};
|
||||
let result = create_connection_inner(&st, input.clone()).unwrap();
|
||||
assert_eq!(result.name, "Prod");
|
||||
assert_eq!(get_connections_inner(&st).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_connection_rejects_invalid_db_type() {
|
||||
let st = state();
|
||||
let input = ConnectionInput {
|
||||
name: "X".into(),
|
||||
db_type: "mongodb".into(),
|
||||
host: "h".into(),
|
||||
port: Some(5432),
|
||||
username: None,
|
||||
folder_id: None,
|
||||
password: None,
|
||||
database: None,
|
||||
environment: None,
|
||||
ssh_host: None,
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
tag_ids: vec![],
|
||||
};
|
||||
assert!(create_connection_inner(&st, input).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_connection_command_removes_it() {
|
||||
let st = state();
|
||||
let input = ConnectionInput {
|
||||
name: "X".into(),
|
||||
db_type: "postgresql".into(),
|
||||
host: "h".into(),
|
||||
port: Some(5432),
|
||||
username: None,
|
||||
folder_id: None,
|
||||
password: None,
|
||||
database: None,
|
||||
environment: None,
|
||||
ssh_host: None,
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
tag_ids: vec![],
|
||||
};
|
||||
let conn = create_connection_inner(&st, input).unwrap();
|
||||
delete_connection_inner(&st, &conn.id).unwrap();
|
||||
assert_eq!(get_connections_inner(&st).unwrap().len(), 0);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,220 @@
|
||||
use crate::models::ConnectionInput;
|
||||
use crate::store::Store;
|
||||
use crate::AppState;
|
||||
use rusqlite::Connection;
|
||||
use std::sync::Mutex;
|
||||
use tauri::Manager;
|
||||
|
||||
const DEMO_DB_FILENAME: &str = "demo.db";
|
||||
const DEMO_CONNECTION_NAME: &str = "Demo (SQLite)";
|
||||
|
||||
/// Ensure the demo SQLite database exists and a corresponding connection is
|
||||
/// registered. Safe to call on every app start — it's idempotent.
|
||||
pub fn ensure_demo_db(app_handle: &tauri::AppHandle, store: &Mutex<Store>) -> Result<(), String> {
|
||||
// Check if the demo connection already exists
|
||||
{
|
||||
let s = store.lock().map_err(|e| e.to_string())?;
|
||||
let existing = s.get_connections()?;
|
||||
if existing.iter().any(|c| c.name == DEMO_CONNECTION_NAME) {
|
||||
return Ok(()); // already set up
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve app data directory
|
||||
let data_dir = app_handle
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| e.to_string())?;
|
||||
std::fs::create_dir_all(&data_dir).map_err(|e| e.to_string())?;
|
||||
|
||||
let db_path = data_dir.join(DEMO_DB_FILENAME);
|
||||
|
||||
// Create the demo SQLite file if it doesn't exist
|
||||
if !db_path.exists() {
|
||||
let conn =
|
||||
Connection::open(&db_path).map_err(|e| format!("Failed to create demo DB: {e}"))?;
|
||||
|
||||
conn.execute_batch(&get_demo_schema())
|
||||
.map_err(|e| format!("Failed to seed demo DB: {e}"))?;
|
||||
}
|
||||
|
||||
// Create the demo connection
|
||||
let input = ConnectionInput {
|
||||
name: DEMO_CONNECTION_NAME.to_string(),
|
||||
db_type: "sqlite".to_string(),
|
||||
host: db_path.to_string_lossy().to_string(),
|
||||
port: None,
|
||||
username: None,
|
||||
password: None,
|
||||
database: None,
|
||||
folder_id: None,
|
||||
tag_ids: vec![],
|
||||
environment: Some("development".to_string()),
|
||||
ssh_host: None,
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
};
|
||||
|
||||
let s = store.lock().map_err(|e| e.to_string())?;
|
||||
s.create_connection(input)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tauri command to re-add the demo connection from the settings screen.
|
||||
#[tauri::command]
|
||||
pub fn recreate_demo_db(state: tauri::State<AppState>) -> Result<String, String> {
|
||||
let store = &state.db_store;
|
||||
ensure_demo_db_inner(store)
|
||||
.map(|()| "Demo database connection re-created.".to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Internal helper that does not need an AppHandle (for settings usage).
|
||||
fn ensure_demo_db_inner(store: &Mutex<Store>) -> Result<(), String> {
|
||||
// Use a temp dir since we don't have the app handle
|
||||
let data_dir = std::env::temp_dir().join("gridline-demo");
|
||||
std::fs::create_dir_all(&data_dir).map_err(|e| e.to_string())?;
|
||||
|
||||
let db_path = data_dir.join(DEMO_DB_FILENAME);
|
||||
|
||||
// Check if file already exists
|
||||
if db_path.exists() {
|
||||
// Just re-create the connection if it was deleted
|
||||
let s = store.lock().map_err(|e| e.to_string())?;
|
||||
let existing = s.get_connections()?;
|
||||
if !existing.iter().any(|c| c.name == DEMO_CONNECTION_NAME) {
|
||||
drop(s);
|
||||
let input = ConnectionInput {
|
||||
name: DEMO_CONNECTION_NAME.to_string(),
|
||||
db_type: "sqlite".to_string(),
|
||||
host: db_path.to_string_lossy().to_string(),
|
||||
port: None,
|
||||
username: None,
|
||||
password: None,
|
||||
database: None,
|
||||
folder_id: None,
|
||||
tag_ids: vec![],
|
||||
environment: Some("development".to_string()),
|
||||
ssh_host: None,
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
};
|
||||
let s = store.lock().map_err(|e| e.to_string())?;
|
||||
s.create_connection(input)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Create demo DB file and seed it
|
||||
let conn = Connection::open(&db_path)
|
||||
.map_err(|e| format!("Failed to create demo DB: {e}"))?;
|
||||
|
||||
conn.execute_batch(&get_demo_schema())
|
||||
.map_err(|e| format!("Failed to seed demo DB: {e}"))?;
|
||||
|
||||
let input = ConnectionInput {
|
||||
name: DEMO_CONNECTION_NAME.to_string(),
|
||||
db_type: "sqlite".to_string(),
|
||||
host: db_path.to_string_lossy().to_string(),
|
||||
port: None,
|
||||
username: None,
|
||||
password: None,
|
||||
database: None,
|
||||
folder_id: None,
|
||||
tag_ids: vec![],
|
||||
environment: Some("development".to_string()),
|
||||
ssh_host: None,
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
};
|
||||
|
||||
let s = store.lock().map_err(|e| e.to_string())?;
|
||||
s.create_connection(input)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_demo_schema() -> String {
|
||||
"
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS products (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
price REAL NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
stock INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS orders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
total REAL NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS order_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_id INTEGER NOT NULL REFERENCES orders(id),
|
||||
product_id INTEGER NOT NULL REFERENCES products(id),
|
||||
quantity INTEGER NOT NULL DEFAULT 1,
|
||||
unit_price REAL NOT NULL
|
||||
);
|
||||
INSERT OR IGNORE INTO users (id, name, email, role) VALUES
|
||||
(1, 'Alice Johnson', 'alice@example.com', 'admin'),
|
||||
(2, 'Bob Smith', 'bob@example.com', 'user'),
|
||||
(3, 'Carol Davis', 'carol@example.com', 'user'),
|
||||
(4, 'Dan Wilson', 'dan@example.com', 'user'),
|
||||
(5, 'Eve Martinez', 'eve@example.com', 'moderator');
|
||||
INSERT OR IGNORE INTO products (id, name, price, category, stock) VALUES
|
||||
(1, 'Wireless Mouse', 29.99, 'Electronics', 150),
|
||||
(2, 'Mechanical Keyboard', 89.99, 'Electronics', 75),
|
||||
(3, 'USB-C Hub', 34.99, 'Accessories', 200),
|
||||
(4, '27\" 4K Monitor', 449.99, 'Electronics', 30),
|
||||
(5, 'Laptop Stand', 49.99, 'Accessories', 100),
|
||||
(6, 'Webcam 1080p', 59.99, 'Electronics', 60),
|
||||
(7, 'Desk Lamp LED', 39.99, 'Office', 120),
|
||||
(8, 'Ergonomic Chair', 599.99, 'Office', 15);
|
||||
INSERT OR IGNORE INTO orders (id, user_id, total, status) VALUES
|
||||
(1, 1, 119.98, 'completed'),
|
||||
(2, 2, 484.98, 'pending'),
|
||||
(3, 3, 59.99, 'completed'),
|
||||
(4, 1, 89.99, 'shipped'),
|
||||
(5, 4, 689.98, 'pending');
|
||||
INSERT OR IGNORE INTO order_items (order_id, product_id, quantity, unit_price) VALUES
|
||||
(1, 1, 2, 29.99),
|
||||
(1, 3, 1, 34.99),
|
||||
(2, 4, 1, 449.99),
|
||||
(2, 5, 1, 49.99),
|
||||
(3, 6, 1, 59.99),
|
||||
(4, 2, 1, 89.99),
|
||||
(5, 8, 1, 599.99),
|
||||
(5, 1, 3, 29.99);
|
||||
".to_string()
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
use crate::models::{Folder, FolderInput};
|
||||
use crate::store::Store;
|
||||
use std::sync::Mutex;
|
||||
|
||||
fn validate(input: &FolderInput) -> Result<(), String> {
|
||||
if input.name.is_empty() || input.name.chars().count() > 100 {
|
||||
return Err("name is required and must be 100 chars or fewer".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_folders_inner(state: &Mutex<Store>) -> Result<Vec<Folder>, String> {
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.get_folders()
|
||||
}
|
||||
|
||||
pub fn create_folder_inner(
|
||||
state: &Mutex<Store>,
|
||||
input: FolderInput,
|
||||
) -> Result<Folder, String> {
|
||||
validate(&input)?;
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.create_folder(input)
|
||||
}
|
||||
|
||||
pub fn delete_folder_inner(state: &Mutex<Store>, id: &str) -> Result<(), String> {
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.delete_folder(id)
|
||||
}
|
||||
|
||||
pub fn add_folder_tags_inner(
|
||||
state: &Mutex<Store>,
|
||||
folder_id: String,
|
||||
tag_ids: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.add_folder_tags(&folder_id, &tag_ids)
|
||||
}
|
||||
|
||||
pub fn update_folder_inner(
|
||||
state: &Mutex<Store>,
|
||||
id: String,
|
||||
input: FolderInput,
|
||||
) -> Result<Folder, String> {
|
||||
validate(&input)?;
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.update_folder(&id, input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_folders(state: tauri::State<crate::AppState>) -> Result<Vec<Folder>, String> {
|
||||
get_folders_inner(&state.db_store)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_folder(
|
||||
state: tauri::State<crate::AppState>,
|
||||
input: FolderInput,
|
||||
) -> Result<Folder, String> {
|
||||
create_folder_inner(&state.db_store, input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_folder(state: tauri::State<crate::AppState>, id: String) -> Result<(), String> {
|
||||
delete_folder_inner(&state.db_store, &id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn add_folder_tags(
|
||||
state: tauri::State<crate::AppState>,
|
||||
folder_id: String,
|
||||
tag_ids: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
add_folder_tags_inner(&state.db_store, folder_id, tag_ids)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_folder(
|
||||
state: tauri::State<crate::AppState>,
|
||||
id: String,
|
||||
input: FolderInput,
|
||||
) -> Result<Folder, String> {
|
||||
update_folder_inner(&state.db_store, id, input)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::FolderInput;
|
||||
use crate::store::Store;
|
||||
|
||||
fn state() -> std::sync::Mutex<Store> {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
crate::store::migrations::run_migrations(&conn).unwrap();
|
||||
std::sync::Mutex::new(Store::from_connection(conn))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_folder_command_works() {
|
||||
let st = state();
|
||||
let folder =
|
||||
create_folder_inner(&st, FolderInput { tag_ids: None,
|
||||
name: "Work".into(),
|
||||
parent_id: None,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(get_folders_inner(&st).unwrap().len(), 1);
|
||||
assert_eq!(folder.name, "Work");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_folder_rejects_empty_name() {
|
||||
let st = state();
|
||||
let result = create_folder_inner(
|
||||
&st,
|
||||
FolderInput { tag_ids: None,
|
||||
name: "".into(),
|
||||
parent_id: None,
|
||||
},
|
||||
);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_folder_command_works() {
|
||||
let st = state();
|
||||
let folder =
|
||||
create_folder_inner(&st, FolderInput { tag_ids: None,
|
||||
name: "Work".into(),
|
||||
parent_id: None,
|
||||
})
|
||||
.unwrap();
|
||||
delete_folder_inner(&st, &folder.id).unwrap();
|
||||
assert_eq!(get_folders_inner(&st).unwrap().len(), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
use crate::models::ConnectionInput;
|
||||
use crate::store::Store;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Mutex;
|
||||
|
||||
const VALID_DB_TYPES: [&str; 4] = ["postgresql", "mysql", "sqlite", "redis"];
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ImportRecord {
|
||||
name: Option<String>,
|
||||
db_type: String,
|
||||
host: String,
|
||||
port: Option<i64>,
|
||||
username: Option<String>,
|
||||
folder_id: Option<String>,
|
||||
tag_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SkippedRecord {
|
||||
pub index: usize,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ImportResult {
|
||||
pub imported: usize,
|
||||
pub skipped: usize,
|
||||
pub skipped_records: Vec<SkippedRecord>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn parse_import(json: &str) -> Result<Vec<ImportRecord>, String> {
|
||||
let records: Vec<ImportRecord> = serde_json::from_str(json).map_err(|e| format!("invalid JSON: {}", e))?;
|
||||
for (i, rec) in records.iter().enumerate() {
|
||||
if rec.name.as_deref().unwrap_or("").is_empty() {
|
||||
return Err(format!("record {}: name is required", i));
|
||||
}
|
||||
if !VALID_DB_TYPES.contains(&rec.db_type.as_str()) {
|
||||
return Err(format!("record {}: invalid db_type: {}", i, rec.db_type));
|
||||
}
|
||||
}
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
pub fn import_connections_inner(state: &Mutex<Store>, json: String) -> Result<ImportResult, String> {
|
||||
let records: Vec<ImportRecord> = serde_json::from_str(&json).map_err(|e| format!("invalid JSON: {}", e))?;
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
let mut imported = 0usize;
|
||||
let mut skipped_records = Vec::new();
|
||||
for (i, rec) in records.iter().enumerate() {
|
||||
let name = match &rec.name {
|
||||
Some(n) if !n.is_empty() => n.clone(),
|
||||
_ => {
|
||||
skipped_records.push(SkippedRecord { index: i, reason: "missing or empty name".into() });
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if !VALID_DB_TYPES.contains(&rec.db_type.as_str()) {
|
||||
skipped_records.push(SkippedRecord { index: i, reason: format!("invalid db_type: {}", rec.db_type) });
|
||||
continue;
|
||||
}
|
||||
if rec.host.is_empty() {
|
||||
skipped_records.push(SkippedRecord { index: i, reason: "missing or empty host".into() });
|
||||
continue;
|
||||
}
|
||||
let input = ConnectionInput {
|
||||
name,
|
||||
db_type: rec.db_type.clone(),
|
||||
host: rec.host.clone(),
|
||||
port: rec.port,
|
||||
username: rec.username.clone(),
|
||||
folder_id: rec.folder_id.clone(),
|
||||
password: None,
|
||||
database: None,
|
||||
ssh_host: None,
|
||||
ssh_port: None,
|
||||
ssh_user: None,
|
||||
ssh_auth_method: None,
|
||||
ssh_private_key_path: None,
|
||||
ssh_passphrase: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
environment: None,
|
||||
tag_ids: rec.tag_ids.clone().unwrap_or_default(),
|
||||
};
|
||||
match store.create_connection(input) {
|
||||
Ok(_) => imported += 1,
|
||||
Err(e) => skipped_records.push(SkippedRecord { index: i, reason: e }),
|
||||
}
|
||||
}
|
||||
Ok(ImportResult { imported, skipped: skipped_records.len(), skipped_records })
|
||||
}
|
||||
|
||||
pub fn export_connections_inner(state: &Mutex<Store>) -> Result<String, String> {
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
let conns = store.get_connections()?;
|
||||
let export = serde_json::json!({ "version": 1, "connections": conns });
|
||||
serde_json::to_string_pretty(&export).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn import_connections(state: tauri::State<crate::AppState>, json: String) -> Result<ImportResult, String> {
|
||||
import_connections_inner(&state.db_store, json)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn export_connections(state: tauri::State<crate::AppState>) -> Result<String, String> {
|
||||
export_connections_inner(&state.db_store)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::store::Store;
|
||||
use crate::models::ConnectionInput;
|
||||
|
||||
fn state() -> std::sync::Mutex<Store> {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
crate::store::migrations::run_migrations(&conn).unwrap();
|
||||
std::sync::Mutex::new(Store::from_connection(conn))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_import_validates_required_fields() {
|
||||
let json = r#"[{ "name": "X", "db_type": "postgresql", "host": "h", "port": 5432 }]"#;
|
||||
let parsed = parse_import(json).unwrap();
|
||||
assert_eq!(parsed.len(), 1);
|
||||
assert_eq!(parsed[0].name.as_deref(), Some("X"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_import_rejects_missing_name() {
|
||||
let json = r#"[{ "db_type": "postgresql", "host": "h", "port": 5432 }]"#;
|
||||
assert!(parse_import(json).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_import_rejects_invalid_db_type() {
|
||||
let json = r#"[{ "name": "X", "db_type": "mongodb", "host": "h", "port": 5432 }]"#;
|
||||
assert!(parse_import(json).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_connections_inserts_all() {
|
||||
let st = state();
|
||||
let json = r#"[{ "name": "A", "db_type": "postgresql", "host": "h", "port": 5432 }, { "name": "B", "db_type": "redis", "host": "r", "port": 6379 }]"#;
|
||||
let result = import_connections_inner(&st, json.to_string()).unwrap();
|
||||
assert_eq!(result.imported, 2);
|
||||
assert_eq!(result.skipped, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_connections_skips_invalid_keeps_valid() {
|
||||
let st = state();
|
||||
let json = r#"[{ "name": "A", "db_type": "postgresql", "host": "h", "port": 5432 }, { "db_type": "postgresql", "host": "h", "port": 5432 }, { "name": "B", "db_type": "redis", "host": "r", "port": 6379 }]"#;
|
||||
let result = import_connections_inner(&st, json.to_string()).unwrap();
|
||||
assert_eq!(result.imported, 2);
|
||||
assert_eq!(result.skipped, 1);
|
||||
assert_eq!(result.skipped_records[0].reason, "missing or empty name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_connections_returns_json() {
|
||||
let st = state();
|
||||
let _ = st.lock().unwrap().create_connection(ConnectionInput {
|
||||
name: "A".into(), db_type: "postgresql".into(), host: "h".into(),
|
||||
port: Some(5432), username: None, folder_id: None,
|
||||
password: None, database: None,
|
||||
ssh_host: None, ssh_port: None, ssh_user: None, ssh_auth_method: None,
|
||||
ssh_private_key_path: None, ssh_passphrase: None,
|
||||
ssl_mode: None, ssl_ca_path: None, ssl_cert_path: None, ssl_key_path: None,
|
||||
environment: None,
|
||||
tag_ids: vec![],
|
||||
});
|
||||
let json = export_connections_inner(&st).unwrap();
|
||||
assert!(json.contains("\"name\""));
|
||||
assert!(json.contains("\"version\""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
use tauri_plugin_keyring_store::KeyringExt;
|
||||
|
||||
/// Store a connection password in the OS keychain.
|
||||
/// The connection ID is used as the keyring account name.
|
||||
#[tauri::command]
|
||||
pub fn save_connection_password(
|
||||
app: tauri::AppHandle,
|
||||
connection_id: String,
|
||||
password: String,
|
||||
) -> Result<(), String> {
|
||||
app.keyring()
|
||||
.store
|
||||
.set_password(&connection_id, &password)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Retrieve a connection password from the OS keychain.
|
||||
/// Returns None if no password was stored for this connection.
|
||||
#[tauri::command]
|
||||
pub fn get_connection_password(
|
||||
app: tauri::AppHandle,
|
||||
connection_id: String,
|
||||
) -> Result<Option<String>, String> {
|
||||
app.keyring()
|
||||
.store
|
||||
.get_password(&connection_id)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Delete a connection password from the OS keychain.
|
||||
#[tauri::command]
|
||||
pub fn delete_connection_password(
|
||||
app: tauri::AppHandle,
|
||||
connection_id: String,
|
||||
) -> Result<(), String> {
|
||||
app.keyring()
|
||||
.store
|
||||
.delete(&connection_id)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
pub mod connections;
|
||||
pub mod db_viewer;
|
||||
pub mod folders;
|
||||
pub mod tags;
|
||||
pub mod settings;
|
||||
pub mod import_export;
|
||||
pub mod test_connection;
|
||||
pub mod ssh;
|
||||
pub mod keychain;
|
||||
pub mod demo;
|
||||
@@ -0,0 +1,50 @@
|
||||
use crate::models::Settings;
|
||||
use crate::store::Store;
|
||||
use std::sync::Mutex;
|
||||
|
||||
pub fn get_settings_inner(state: &Mutex<Store>) -> Result<Settings, String> {
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.get_settings()
|
||||
}
|
||||
|
||||
pub fn update_setting_inner(state: &Mutex<Store>, key: &str, value: &str) -> Result<(), String> {
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.update_setting(key, value)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_settings(state: tauri::State<crate::AppState>) -> Result<Settings, String> {
|
||||
get_settings_inner(&state.db_store)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_setting(state: tauri::State<crate::AppState>, key: String, value: String) -> Result<(), String> {
|
||||
update_setting_inner(&state.db_store, &key, &value)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::store::Store;
|
||||
|
||||
fn state() -> std::sync::Mutex<Store> {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
crate::store::migrations::run_migrations(&conn).unwrap();
|
||||
std::sync::Mutex::new(Store::from_connection(conn))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_settings_returns_defaults() {
|
||||
let st = state();
|
||||
let s = get_settings_inner(&st).unwrap();
|
||||
assert_eq!(s.theme, "system");
|
||||
assert_eq!(s.font_size, "medium");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_setting_persists() {
|
||||
let st = state();
|
||||
update_setting_inner(&st, "theme", "light").unwrap();
|
||||
assert_eq!(get_settings_inner(&st).unwrap().theme, "light");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// SSH tunnel configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SshConfig {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub user: String,
|
||||
/// "password" or "key"
|
||||
pub auth_method: String,
|
||||
pub password: Option<String>,
|
||||
pub private_key_path: Option<String>,
|
||||
pub passphrase: Option<String>,
|
||||
}
|
||||
|
||||
impl SshConfig {
|
||||
/// Create a new `SshConfig` with the required fields.
|
||||
pub fn new(
|
||||
host: String,
|
||||
port: u16,
|
||||
user: String,
|
||||
auth_method: String,
|
||||
) -> Self {
|
||||
SshConfig {
|
||||
host,
|
||||
port,
|
||||
user,
|
||||
auth_method,
|
||||
password: None,
|
||||
private_key_path: None,
|
||||
passphrase: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate SSH configuration.
|
||||
///
|
||||
/// Returns `true` if:
|
||||
/// - `host` is not empty
|
||||
/// - `port` is in range 1..=65535 (u16 guarantees <= 65535)
|
||||
/// - `user` is not empty
|
||||
pub fn is_valid(&self) -> bool {
|
||||
!self.host.is_empty() && self.port >= 1 && !self.user.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents an active SSH tunnel connection.
|
||||
#[derive(Debug)]
|
||||
struct SshTunnel {
|
||||
local_port: u16,
|
||||
remote_host: String,
|
||||
remote_port: u16,
|
||||
}
|
||||
|
||||
/// Manages SSH tunnels, mapping connection keys to active tunnels.
|
||||
///
|
||||
/// This is a placeholder implementation. Real SSH connectivity (via `ssh2`
|
||||
/// or `async-ssh2`) will be added in a later task. Currently the manager
|
||||
/// stores mock entries when validation passes.
|
||||
#[derive(Debug)]
|
||||
pub struct SshTunnelManager {
|
||||
tunnels: HashMap<String, SshTunnel>,
|
||||
}
|
||||
|
||||
impl SshTunnelManager {
|
||||
/// Create a new empty tunnel manager.
|
||||
pub fn new() -> Self {
|
||||
SshTunnelManager {
|
||||
tunnels: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open an SSH tunnel for the given config.
|
||||
///
|
||||
/// Returns the local port on success.
|
||||
///
|
||||
/// TODO: Replace placeholder with a real SSH connection via `ssh2` or
|
||||
/// `async-ssh2`. Currently stores a mock entry (`local_port = 15432`)
|
||||
/// when `config.is_valid()` passes.
|
||||
pub fn open_tunnel(&mut self, key: &str, config: &SshConfig) -> Result<u16, String> {
|
||||
if !config.is_valid() {
|
||||
return Err("invalid SSH configuration".to_string());
|
||||
}
|
||||
// TODO: Replace with real SSH tunnel via ssh2::Session + port forwarding.
|
||||
// For now, store a mock entry with local_port = 15432.
|
||||
self.tunnels.insert(
|
||||
key.to_string(),
|
||||
SshTunnel {
|
||||
local_port: 15432,
|
||||
remote_host: config.host.clone(),
|
||||
remote_port: config.port,
|
||||
},
|
||||
);
|
||||
Ok(15432)
|
||||
}
|
||||
|
||||
/// Close and remove the SSH tunnel for the given key.
|
||||
///
|
||||
/// TODO: When real SSH is implemented, this should disconnect the
|
||||
/// session and free the local port.
|
||||
pub fn close_tunnel(&mut self, key: &str) {
|
||||
self.tunnels.remove(key);
|
||||
}
|
||||
|
||||
/// Close all active SSH tunnels.
|
||||
pub fn close_all(&mut self) {
|
||||
self.tunnels.clear();
|
||||
}
|
||||
|
||||
/// Get the local port for an active tunnel, if any.
|
||||
pub fn get_local_port(&self, key: &str) -> Option<u16> {
|
||||
self.tunnels.get(key).map(|t| t.local_port)
|
||||
}
|
||||
|
||||
/// Return the number of active tunnels.
|
||||
pub fn active_count(&self) -> usize {
|
||||
self.tunnels.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// SshConfig validation
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn ssh_config_validation() {
|
||||
// Invalid: empty host
|
||||
let config = SshConfig::new(
|
||||
"".to_string(),
|
||||
22,
|
||||
"user".to_string(),
|
||||
"password".to_string(),
|
||||
);
|
||||
assert!(!config.is_valid(), "empty host should be invalid");
|
||||
|
||||
// Invalid: empty user
|
||||
let config = SshConfig::new(
|
||||
"host.example.com".to_string(),
|
||||
22,
|
||||
"".to_string(),
|
||||
"password".to_string(),
|
||||
);
|
||||
assert!(!config.is_valid(), "empty user should be invalid");
|
||||
|
||||
// Valid: all required fields present
|
||||
let config = SshConfig::new(
|
||||
"host.example.com".to_string(),
|
||||
2222,
|
||||
"tunnel".to_string(),
|
||||
"key".to_string(),
|
||||
);
|
||||
assert!(config.is_valid(), "valid config should be accepted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_config_rejects_non_standard_ports() {
|
||||
// Port 0 is invalid
|
||||
let config = SshConfig::new(
|
||||
"host.example.com".to_string(),
|
||||
0,
|
||||
"user".to_string(),
|
||||
"password".to_string(),
|
||||
);
|
||||
assert!(!config.is_valid(), "port 0 should be invalid");
|
||||
|
||||
// Port 1 is valid (boundary)
|
||||
let config = SshConfig::new(
|
||||
"host.example.com".to_string(),
|
||||
1,
|
||||
"user".to_string(),
|
||||
"password".to_string(),
|
||||
);
|
||||
assert!(config.is_valid(), "port 1 should be valid");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
use crate::models::{Tag, TagInput};
|
||||
use crate::store::Store;
|
||||
use std::sync::Mutex;
|
||||
|
||||
fn validate(input: &TagInput) -> Result<(), String> {
|
||||
if input.name.is_empty() || input.name.chars().count() > 50 {
|
||||
return Err("name is required and must be 50 chars or fewer".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_tags_inner(state: &Mutex<Store>) -> Result<Vec<Tag>, String> {
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.get_tags()
|
||||
}
|
||||
|
||||
pub fn create_tag_inner(state: &Mutex<Store>, input: TagInput) -> Result<Tag, String> {
|
||||
validate(&input)?;
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.create_tag(input)
|
||||
}
|
||||
|
||||
pub fn delete_tag_inner(state: &Mutex<Store>, id: &str) -> Result<(), String> {
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.delete_tag(id)
|
||||
}
|
||||
|
||||
pub fn update_tag_inner(
|
||||
state: &Mutex<Store>,
|
||||
id: String,
|
||||
input: TagInput,
|
||||
) -> Result<Tag, String> {
|
||||
validate(&input)?;
|
||||
let store = state.lock().map_err(|e| e.to_string())?;
|
||||
store.update_tag(&id, input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_tags(state: tauri::State<crate::AppState>) -> Result<Vec<Tag>, String> {
|
||||
get_tags_inner(&state.db_store)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_tag(state: tauri::State<crate::AppState>, input: TagInput) -> Result<Tag, String> {
|
||||
create_tag_inner(&state.db_store, input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_tag(state: tauri::State<crate::AppState>, id: String) -> Result<(), String> {
|
||||
delete_tag_inner(&state.db_store, &id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_tag(
|
||||
state: tauri::State<crate::AppState>,
|
||||
id: String,
|
||||
input: TagInput,
|
||||
) -> Result<Tag, String> {
|
||||
update_tag_inner(&state.db_store, id, input)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::store::Store;
|
||||
use crate::models::TagInput;
|
||||
|
||||
fn state() -> std::sync::Mutex<Store> {
|
||||
let conn = rusqlite::Connection::open_in_memory().unwrap();
|
||||
crate::store::migrations::run_migrations(&conn).unwrap();
|
||||
std::sync::Mutex::new(Store::from_connection(conn))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_tag_command_works() {
|
||||
let st = state();
|
||||
let tag = create_tag_inner(&st, TagInput { name: "prod".into(), color: "#ef4444".into() }).unwrap();
|
||||
assert_eq!(get_tags_inner(&st).unwrap().len(), 1);
|
||||
assert_eq!(tag.name, "prod");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_tag_rejects_long_name() {
|
||||
let st = state();
|
||||
let result = create_tag_inner(&st, TagInput { name: "x".repeat(51), color: "#fff".into() });
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::db::pool::DbConfig;
|
||||
|
||||
/// Result of a test database connection attempt.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TestConnectionResult {
|
||||
pub ok: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Strip credentials and sensitive information from error messages while
|
||||
/// preserving the useful diagnostic detail (severity, message, SQLSTATE).
|
||||
///
|
||||
/// Redacts `password=...`, `user=...`, `postgresql://user:pwd@host` URLs,
|
||||
/// and `@host` credential fragments rather than discarding the whole
|
||||
/// message — so the user can still see e.g. "password authentication
|
||||
/// failed for user 'foo'" without leaking the password itself.
|
||||
pub fn sanitize_error(msg: &str) -> String {
|
||||
let mut out = String::with_capacity(msg.len());
|
||||
let bytes = msg.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
let lower = msg[i..].to_lowercase();
|
||||
if lower.starts_with("postgres://") || lower.starts_with("postgresql://") {
|
||||
out.push_str("[redacted-url://");
|
||||
let scheme_end = i + msg[i..].find("://").unwrap_or(0) + 3;
|
||||
let rest = &msg[scheme_end..];
|
||||
let end = match rest.find(['/', '?']) {
|
||||
Some(pos) => scheme_end + pos,
|
||||
None => msg.len(),
|
||||
};
|
||||
i = end;
|
||||
} else if lower.starts_with("password=") {
|
||||
out.push_str("[redacted]");
|
||||
let rest = &msg[i + "password=".len()..];
|
||||
let skip = rest.find(char::is_whitespace).unwrap_or(rest.len());
|
||||
i += "password=".len() + skip;
|
||||
} else if lower.starts_with("user=") {
|
||||
out.push_str("[redacted]");
|
||||
let rest = &msg[i + "user=".len()..];
|
||||
let skip = rest.find(char::is_whitespace).unwrap_or(rest.len());
|
||||
i += "user=".len() + skip;
|
||||
} else if lower.starts_with("secret") {
|
||||
out.push_str("secret=[redacted]");
|
||||
let rest = &msg[i + "secret".len()..];
|
||||
let skip = rest.find(char::is_whitespace).unwrap_or(rest.len());
|
||||
i += "secret".len() + skip;
|
||||
} else {
|
||||
let ch = msg[i..].chars().next().unwrap();
|
||||
out.push(ch);
|
||||
i += ch.len_utf8();
|
||||
}
|
||||
}
|
||||
// Truncate at 300 characters for safety.
|
||||
if out.len() > 300 {
|
||||
format!("{}...", &out[..297])
|
||||
} else {
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate `DbConfig` before attempting a connection test.
|
||||
///
|
||||
/// Returns `Some(error_message)` if the config is invalid, or `None` if valid.
|
||||
///
|
||||
/// Validation rules:
|
||||
/// - `db_type` must be one of: `postgresql`, `mysql`, `sqlite`, `redis`
|
||||
/// - For `postgresql`, `mysql`, `redis`: `host` must not be empty, `port` must
|
||||
/// be `Some(1..=65535)`
|
||||
/// - For `sqlite`: `host` (file path) must not be empty
|
||||
pub fn validate_test_input(config: &DbConfig) -> Option<String> {
|
||||
let db_type = config.db_type.to_lowercase();
|
||||
|
||||
let valid_types = ["postgresql", "mysql", "sqlite", "redis"];
|
||||
if !valid_types.contains(&db_type.as_str()) {
|
||||
return Some(format!(
|
||||
"unsupported database type: {}. Supported types: {}",
|
||||
config.db_type,
|
||||
valid_types.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
if config.host.is_empty() {
|
||||
return Some("host must not be empty".to_string());
|
||||
}
|
||||
|
||||
// SQLite does not require a port (host is the file path)
|
||||
if db_type != "sqlite" {
|
||||
match config.port {
|
||||
Some(p) if (1..=65535).contains(&p) => {}
|
||||
_ => {
|
||||
return Some(
|
||||
"port must be an integer between 1 and 65535 for this db_type"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Test a database connection for the given configuration.
|
||||
///
|
||||
/// Dispatches to the appropriate type-specific connection test based on
|
||||
/// `config.db_type`. Returns a `TestConnectionResult` indicating success
|
||||
/// or failure with a sanitized error message.
|
||||
pub async fn test_database_connection(config: &DbConfig) -> TestConnectionResult {
|
||||
// Validate input first
|
||||
if let Some(err) = validate_test_input(config) {
|
||||
return TestConnectionResult {
|
||||
ok: false,
|
||||
error: Some(err),
|
||||
};
|
||||
}
|
||||
|
||||
let result = match config.db_type.to_lowercase().as_str() {
|
||||
"postgresql" => test_pg_connection(config).await,
|
||||
"mysql" => test_mysql_connection(config).await,
|
||||
"sqlite" => test_sqlite_connection(config),
|
||||
"redis" => test_redis_connection(config).await,
|
||||
other => TestConnectionResult {
|
||||
ok: false,
|
||||
error: Some(format!("unsupported database type: {other}")),
|
||||
},
|
||||
};
|
||||
|
||||
TestConnectionResult {
|
||||
ok: result.ok,
|
||||
error: result.error.map(|e| sanitize_error(&e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Test a PostgreSQL connection using `tokio-postgres`.
|
||||
///
|
||||
/// Connects without TLS. The connection handler is spawned and immediately
|
||||
/// dropped after confirming the connection is alive.
|
||||
async fn test_pg_connection(config: &DbConfig) -> TestConnectionResult {
|
||||
use tokio_postgres::NoTls;
|
||||
|
||||
let host = &config.host;
|
||||
let port = config.port.unwrap_or(5432) as u16;
|
||||
let user = config.username.as_deref().unwrap_or("postgres");
|
||||
let dbname = config.database.as_deref().unwrap_or("postgres");
|
||||
let password = config.password.as_deref().unwrap_or("");
|
||||
|
||||
// Use a postgres URL rather than libpq key=value format: tokio-postgres
|
||||
// parses URLs reliably and urlencoding handles special chars safely.
|
||||
use urlencoding::encode as enc;
|
||||
let conn_str = format!(
|
||||
"postgresql://{}:{}@{}:{}/{}?connect_timeout=10",
|
||||
enc(user),
|
||||
enc(password),
|
||||
host,
|
||||
port,
|
||||
enc(dbname),
|
||||
);
|
||||
|
||||
match tokio_postgres::connect(&conn_str, NoTls).await {
|
||||
Ok((_client, connection)) => {
|
||||
// Spawn the connection handler so it keeps running while we test
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = connection.await {
|
||||
eprintln!("connection error: {}", e);
|
||||
}
|
||||
});
|
||||
TestConnectionResult { ok: true, error: None }
|
||||
}
|
||||
Err(e) => TestConnectionResult {
|
||||
ok: false,
|
||||
error: Some(e.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Test a MySQL connection using `sqlx`.
|
||||
///
|
||||
/// Uses `MySqlPoolOptions` with a pool size of 1 and a 10-second
|
||||
/// `acquire_timeout`.
|
||||
async fn test_mysql_connection(config: &DbConfig) -> TestConnectionResult {
|
||||
use sqlx::mysql::MySqlPoolOptions;
|
||||
|
||||
let host = &config.host;
|
||||
let port = config.port.unwrap_or(3306);
|
||||
let user = config.username.as_deref().unwrap_or("root");
|
||||
let password = config.password.as_deref().unwrap_or("");
|
||||
let dbname = config.database.as_deref().unwrap_or("mysql");
|
||||
|
||||
let conn_str = format!(
|
||||
"mysql://{}:{}@{}:{}/{}",
|
||||
user, password, host, port, dbname
|
||||
);
|
||||
|
||||
match MySqlPoolOptions::new()
|
||||
.max_connections(1)
|
||||
.acquire_timeout(std::time::Duration::from_secs(10))
|
||||
.connect(&conn_str)
|
||||
.await
|
||||
{
|
||||
Ok(pool) => {
|
||||
pool.close().await;
|
||||
TestConnectionResult { ok: true, error: None }
|
||||
}
|
||||
Err(e) => TestConnectionResult {
|
||||
ok: false,
|
||||
error: Some(e.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Test a SQLite connection using `rusqlite`.
|
||||
///
|
||||
/// Opens the database file at `config.host`. Returns success if the file
|
||||
/// can be opened as a valid SQLite database.
|
||||
fn test_sqlite_connection(config: &DbConfig) -> TestConnectionResult {
|
||||
match rusqlite::Connection::open(&config.host) {
|
||||
Ok(_conn) => TestConnectionResult { ok: true, error: None },
|
||||
Err(e) => TestConnectionResult {
|
||||
ok: false,
|
||||
error: Some(e.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Test a Redis connection using the `redis` crate.
|
||||
///
|
||||
/// Uses `redis::Client::open` followed by `get_async_connection` with a
|
||||
/// 10-second timeout via `tokio::time::timeout`.
|
||||
async fn test_redis_connection(config: &DbConfig) -> TestConnectionResult {
|
||||
use tokio::time::timeout;
|
||||
|
||||
let host = &config.host;
|
||||
let port = config.port.unwrap_or(6379);
|
||||
let password = config.password.as_deref();
|
||||
|
||||
let conn_str = if let Some(pwd) = password {
|
||||
format!("redis://:{}@{}:{}/", pwd, host, port)
|
||||
} else {
|
||||
format!("redis://{}:{}/", host, port)
|
||||
};
|
||||
|
||||
match redis::Client::open(conn_str.as_str()) {
|
||||
Ok(client) => {
|
||||
match timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
client.get_multiplexed_async_connection(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(_conn)) => TestConnectionResult { ok: true, error: None },
|
||||
Ok(Err(e)) => TestConnectionResult {
|
||||
ok: false,
|
||||
error: Some(e.to_string()),
|
||||
},
|
||||
Err(_) => TestConnectionResult {
|
||||
ok: false,
|
||||
error: Some("connection timed out after 10 seconds".to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
Err(e) => TestConnectionResult {
|
||||
ok: false,
|
||||
error: Some(e.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Tauri command to test a database connection.
|
||||
///
|
||||
/// Calls `test_database_connection` and returns the result.
|
||||
#[tauri::command]
|
||||
pub async fn test_connection(config: DbConfig) -> Result<TestConnectionResult, String> {
|
||||
Ok(test_database_connection(&config).await)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// TestConnectionResult serialization
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_connection_result_serialization() {
|
||||
// ok=true result serializes correctly
|
||||
let result = TestConnectionResult { ok: true, error: None };
|
||||
let json = serde_json::to_string(&result).unwrap();
|
||||
assert!(json.contains("\"ok\":true"), "ok=true should appear in JSON");
|
||||
|
||||
// error result includes the error message
|
||||
let result = TestConnectionResult {
|
||||
ok: false,
|
||||
error: Some("connection refused".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&result).unwrap();
|
||||
assert!(json.contains("\"connection refused\""), "error message should appear in JSON");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// sanitize_error
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_connection_sanitizes_error() {
|
||||
let msg = "connection failed: password=secret123 user=admin";
|
||||
let sanitized = sanitize_error(msg);
|
||||
assert!(!sanitized.contains("secret123"), "should not leak password value");
|
||||
assert!(!sanitized.contains("admin"), "should not leak username value");
|
||||
assert!(!sanitized.contains("password="), "should remove password= pattern");
|
||||
assert!(!sanitized.contains("user="), "should remove user= pattern");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// validate_test_input rejection
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn validate_test_input_rejects_invalid() {
|
||||
// Unsupported db type
|
||||
let config = DbConfig {
|
||||
db_type: "mongodb".to_string(),
|
||||
host: "localhost".to_string(),
|
||||
port: Some(27017),
|
||||
username: None,
|
||||
password: None,
|
||||
database: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
};
|
||||
assert!(
|
||||
validate_test_input(&config).is_some(),
|
||||
"mongodb should be rejected"
|
||||
);
|
||||
|
||||
// Empty host
|
||||
let config = DbConfig {
|
||||
db_type: "postgresql".to_string(),
|
||||
host: "".to_string(),
|
||||
port: Some(5432),
|
||||
username: None,
|
||||
password: None,
|
||||
database: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
};
|
||||
assert!(
|
||||
validate_test_input(&config).is_some(),
|
||||
"empty host should be rejected"
|
||||
);
|
||||
|
||||
// Port 0
|
||||
let config = DbConfig {
|
||||
db_type: "postgresql".to_string(),
|
||||
host: "localhost".to_string(),
|
||||
port: Some(0),
|
||||
username: None,
|
||||
password: None,
|
||||
database: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
};
|
||||
assert!(
|
||||
validate_test_input(&config).is_some(),
|
||||
"port 0 should be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// validate_test_input acceptance
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn validate_test_input_accepts_valid() {
|
||||
let config = DbConfig {
|
||||
db_type: "postgresql".to_string(),
|
||||
host: "localhost".to_string(),
|
||||
port: Some(5432),
|
||||
username: Some("user".to_string()),
|
||||
password: None,
|
||||
database: Some("mydb".to_string()),
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
};
|
||||
assert!(
|
||||
validate_test_input(&config).is_none(),
|
||||
"valid postgresql config should be accepted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_accepts_no_port() {
|
||||
// SQLite does not require a port
|
||||
let config = DbConfig {
|
||||
db_type: "sqlite".to_string(),
|
||||
host: "/tmp/test.db".to_string(),
|
||||
port: None,
|
||||
username: None,
|
||||
password: None,
|
||||
database: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
};
|
||||
assert!(
|
||||
validate_test_input(&config).is_none(),
|
||||
"sqlite without port should be accepted"
|
||||
);
|
||||
|
||||
// SQLite should also accept a config with any port (port is ignored)
|
||||
let config = DbConfig {
|
||||
db_type: "sqlite".to_string(),
|
||||
host: "/tmp/test.db".to_string(),
|
||||
port: Some(9999),
|
||||
username: None,
|
||||
password: None,
|
||||
database: None,
|
||||
ssl_mode: None,
|
||||
ssl_ca_path: None,
|
||||
ssl_cert_path: None,
|
||||
ssl_key_path: None,
|
||||
};
|
||||
assert!(
|
||||
validate_test_input(&config).is_none(),
|
||||
"sqlite with any port should be accepted"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user