feat(desktop): add redis gui foundation baseline
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
7
crates/redis-core/Cargo.lock
generated
Normal file
7
crates/redis-core/Cargo.lock
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "redis-core"
|
||||
version = "0.1.0"
|
||||
9
crates/redis-core/Cargo.toml
Normal file
9
crates/redis-core/Cargo.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "redis-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
54
crates/redis-core/src/bootstrap.rs
Normal file
54
crates/redis-core/src/bootstrap.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct BackendBootstrap {
|
||||
pub app_name: String,
|
||||
pub surface: EntrySurface,
|
||||
pub persistence: PersistenceBoundary,
|
||||
pub command_execution: String,
|
||||
pub key_browsing: String,
|
||||
pub next_backend_milestone: String,
|
||||
}
|
||||
|
||||
impl BackendBootstrap {
|
||||
pub fn for_desktop() -> Self {
|
||||
Self {
|
||||
app_name: "Redis GUI Foundation".to_string(),
|
||||
surface: EntrySurface::Desktop,
|
||||
persistence: PersistenceBoundary {
|
||||
connection_catalog: "not_configured".to_string(),
|
||||
secrets: SecretHandling::MemoryOnly.as_str().to_string(),
|
||||
},
|
||||
command_execution: "single_instance_enabled".to_string(),
|
||||
key_browsing: "scan_metadata_enabled".to_string(),
|
||||
next_backend_milestone: "value_surface_and_catalog_persistence".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EntrySurface {
|
||||
Desktop,
|
||||
Cli,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct PersistenceBoundary {
|
||||
pub connection_catalog: String,
|
||||
pub secrets: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SecretHandling {
|
||||
MemoryOnly,
|
||||
}
|
||||
|
||||
impl SecretHandling {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::MemoryOnly => "memory_only",
|
||||
}
|
||||
}
|
||||
}
|
||||
238
crates/redis-core/src/connection.rs
Normal file
238
crates/redis-core/src/connection.rs
Normal file
@@ -0,0 +1,238 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::BackendError;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ConnectionProfileDraft {
|
||||
pub display_name: String,
|
||||
pub target: ConnectionTarget,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ConnectionTarget {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub database: i64,
|
||||
pub username: Option<String>,
|
||||
pub tls_mode: TlsMode,
|
||||
}
|
||||
|
||||
impl Default for ConnectionTarget {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 6379,
|
||||
database: 0,
|
||||
username: None,
|
||||
tls_mode: TlsMode::Disabled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ConnectionTarget {
|
||||
pub fn validate(&self) -> Result<(), BackendError> {
|
||||
if self.host.trim().is_empty() {
|
||||
return Err(BackendError::invalid_connection_config(
|
||||
"Redis host must not be empty.",
|
||||
));
|
||||
}
|
||||
|
||||
if self.database < 0 {
|
||||
return Err(BackendError::invalid_connection_config(
|
||||
"Redis database must be zero or a positive integer.",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RedisConnectionRequest {
|
||||
pub target: ConnectionTarget,
|
||||
pub password: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ConnectionTestResult {
|
||||
pub selected_database: i64,
|
||||
pub authenticated_as: Option<String>,
|
||||
pub round_trip_status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RedisCommandRequest {
|
||||
pub connection: RedisConnectionRequest,
|
||||
pub command: String,
|
||||
#[serde(default)]
|
||||
pub arguments: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct CommandExecutionResult {
|
||||
pub command: String,
|
||||
pub arguments: Vec<String>,
|
||||
pub database: i64,
|
||||
pub response: RedisResponse,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RedisKeyBrowseRequest {
|
||||
pub connection: RedisConnectionRequest,
|
||||
#[serde(default = "default_scan_cursor")]
|
||||
pub cursor: String,
|
||||
#[serde(default)]
|
||||
pub pattern: Option<String>,
|
||||
#[serde(default = "default_scan_page_size")]
|
||||
pub page_size: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RedisKeyBrowseResult {
|
||||
pub database: i64,
|
||||
pub submitted_cursor: String,
|
||||
pub next_cursor: String,
|
||||
pub has_more: bool,
|
||||
pub page_size: u32,
|
||||
pub pattern: Option<String>,
|
||||
pub keys: Vec<RedisKeyMetadata>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RedisKeyMetadataRequest {
|
||||
pub connection: RedisConnectionRequest,
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RedisValueReadRequest {
|
||||
pub connection: RedisConnectionRequest,
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RedisValueWriteRequest {
|
||||
pub connection: RedisConnectionRequest,
|
||||
pub key: String,
|
||||
pub value: RedisValueWriteInput,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RedisKeyTtlUpdateRequest {
|
||||
pub connection: RedisConnectionRequest,
|
||||
pub key: String,
|
||||
pub operation: RedisKeyTtlUpdate,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RedisKeyMetadata {
|
||||
pub name: String,
|
||||
pub database: i64,
|
||||
pub exists: bool,
|
||||
pub key_type: String,
|
||||
pub ttl: RedisKeyTtl,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum RedisKeyTtl {
|
||||
Persistent,
|
||||
Missing,
|
||||
ExpiresInMillis { value: i64 },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RedisValueRecord {
|
||||
pub metadata: RedisKeyMetadata,
|
||||
pub data: RedisValueData,
|
||||
pub write_capability: RedisValueWriteCapability,
|
||||
pub ttl_write_supported: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RedisKeyTtlUpdateResult {
|
||||
pub metadata: RedisKeyMetadata,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RedisFieldValueEntry {
|
||||
pub field: RedisValueContent,
|
||||
pub value: RedisValueContent,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RedisSortedSetEntry {
|
||||
pub member: RedisValueContent,
|
||||
pub score: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct RedisStreamEntry {
|
||||
pub id: String,
|
||||
pub fields: Vec<RedisFieldValueEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum RedisValueContent {
|
||||
String { value: String },
|
||||
Binary { bytes: Vec<u8> },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum RedisValueData {
|
||||
Missing,
|
||||
String { value: RedisValueContent },
|
||||
Hash { entries: Vec<RedisFieldValueEntry> },
|
||||
List { items: Vec<RedisValueContent> },
|
||||
Set { members: Vec<RedisValueContent> },
|
||||
SortedSet { entries: Vec<RedisSortedSetEntry> },
|
||||
Stream { entries: Vec<RedisStreamEntry> },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum RedisValueWriteInput {
|
||||
String { value: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RedisValueWriteCapability {
|
||||
None,
|
||||
ReplaceString,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum RedisKeyTtlUpdate {
|
||||
Persist,
|
||||
ExpiresInMillis { value: u64 },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum RedisResponse {
|
||||
Null,
|
||||
Integer { value: i64 },
|
||||
String { value: String },
|
||||
Binary { bytes: Vec<u8> },
|
||||
Array { items: Vec<RedisResponse> },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TlsMode {
|
||||
Disabled,
|
||||
Preferred,
|
||||
Required,
|
||||
}
|
||||
|
||||
fn default_scan_cursor() -> String {
|
||||
"0".to_string()
|
||||
}
|
||||
|
||||
fn default_scan_page_size() -> u32 {
|
||||
100
|
||||
}
|
||||
60
crates/redis-core/src/error.rs
Normal file
60
crates/redis-core/src/error.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BackendErrorCode {
|
||||
InvalidConnectionConfig,
|
||||
UnsupportedTlsMode,
|
||||
UnsupportedOperation,
|
||||
ConnectionFailed,
|
||||
AuthenticationFailed,
|
||||
CommandFailed,
|
||||
Internal,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Error)]
|
||||
#[error("{message}")]
|
||||
pub struct BackendError {
|
||||
pub code: BackendErrorCode,
|
||||
pub message: String,
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
impl BackendError {
|
||||
pub fn invalid_connection_config(message: impl Into<String>) -> Self {
|
||||
Self::new(BackendErrorCode::InvalidConnectionConfig, message, None)
|
||||
}
|
||||
|
||||
pub fn unsupported_tls_mode(message: impl Into<String>) -> Self {
|
||||
Self::new(BackendErrorCode::UnsupportedTlsMode, message, None)
|
||||
}
|
||||
|
||||
pub fn connection_failed(message: impl Into<String>, detail: Option<String>) -> Self {
|
||||
Self::new(BackendErrorCode::ConnectionFailed, message, detail)
|
||||
}
|
||||
|
||||
pub fn unsupported_operation(message: impl Into<String>, detail: Option<String>) -> Self {
|
||||
Self::new(BackendErrorCode::UnsupportedOperation, message, detail)
|
||||
}
|
||||
|
||||
pub fn authentication_failed(message: impl Into<String>, detail: Option<String>) -> Self {
|
||||
Self::new(BackendErrorCode::AuthenticationFailed, message, detail)
|
||||
}
|
||||
|
||||
pub fn command_failed(message: impl Into<String>, detail: Option<String>) -> Self {
|
||||
Self::new(BackendErrorCode::CommandFailed, message, detail)
|
||||
}
|
||||
|
||||
pub fn internal(message: impl Into<String>, detail: Option<String>) -> Self {
|
||||
Self::new(BackendErrorCode::Internal, message, detail)
|
||||
}
|
||||
|
||||
fn new(code: BackendErrorCode, message: impl Into<String>, detail: Option<String>) -> Self {
|
||||
Self {
|
||||
code,
|
||||
message: message.into(),
|
||||
detail,
|
||||
}
|
||||
}
|
||||
}
|
||||
45
crates/redis-core/src/lib.rs
Normal file
45
crates/redis-core/src/lib.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
mod bootstrap;
|
||||
mod connection;
|
||||
mod error;
|
||||
mod service;
|
||||
|
||||
pub use bootstrap::{BackendBootstrap, EntrySurface, PersistenceBoundary, SecretHandling};
|
||||
pub use connection::{
|
||||
CommandExecutionResult, ConnectionProfileDraft, ConnectionTarget, ConnectionTestResult,
|
||||
RedisCommandRequest, RedisConnectionRequest, RedisFieldValueEntry, RedisKeyBrowseRequest,
|
||||
RedisKeyBrowseResult, RedisKeyMetadata, RedisKeyMetadataRequest, RedisKeyTtl,
|
||||
RedisKeyTtlUpdate, RedisKeyTtlUpdateRequest, RedisKeyTtlUpdateResult, RedisResponse,
|
||||
RedisSortedSetEntry, RedisStreamEntry, RedisValueContent, RedisValueData,
|
||||
RedisValueReadRequest, RedisValueRecord, RedisValueWriteCapability, RedisValueWriteInput,
|
||||
RedisValueWriteRequest, TlsMode,
|
||||
};
|
||||
pub use error::{BackendError, BackendErrorCode};
|
||||
pub use service::{
|
||||
browse_keys, execute_command, inspect_key, read_value, test_connection, update_key_ttl,
|
||||
write_value,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{BackendBootstrap, ConnectionTarget, TlsMode};
|
||||
|
||||
#[test]
|
||||
fn desktop_bootstrap_keeps_persistence_guardrails_stable() {
|
||||
let bootstrap = BackendBootstrap::for_desktop();
|
||||
|
||||
assert_eq!(bootstrap.persistence.connection_catalog, "not_configured");
|
||||
assert_eq!(bootstrap.persistence.secrets, "memory_only");
|
||||
assert_eq!(bootstrap.command_execution, "single_instance_enabled");
|
||||
assert_eq!(bootstrap.key_browsing, "scan_metadata_enabled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_target_defaults_match_local_redis_expectation() {
|
||||
let target = ConnectionTarget::default();
|
||||
|
||||
assert_eq!(target.host, "127.0.0.1");
|
||||
assert_eq!(target.port, 6379);
|
||||
assert_eq!(target.database, 0);
|
||||
assert_eq!(target.tls_mode, TlsMode::Disabled);
|
||||
}
|
||||
}
|
||||
1741
crates/redis-core/src/service.rs
Normal file
1741
crates/redis-core/src/service.rs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user