use crate::core::config::{Config, DEFAULT_S3_REGION, KNOWN_S3_REGIONS};
use crate::core::transfer::EndpointKind;
use crate::db;
use anyhow::Result;
use rusqlite::Connection;
use serde_json::{Map, Value};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SettingsCategory {
S3,
Scanner,
Performance,
General,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SettingsFieldId {
S3Profile,
S3ProfileName,
S3Endpoint,
S3Bucket,
S3Region,
S3Prefix,
S3AccessKey,
S3SecretKey,
ScannerHost,
ScannerPort,
ScannerChunkSize,
ScannerEnabled,
PerformancePartSize,
PerformanceUploadConcurrency,
PerformanceScanConcurrency,
PerformanceUploadPartConcurrency,
PerformanceScanPartConcurrency,
PerformanceDeleteSource,
PerformanceShowMetrics,
Theme,
SetupWizardAction,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SettingsFieldKind {
Text,
SecretText,
Toggle,
Selector,
Action,
}
impl SettingsFieldKind {
pub fn is_text_input(self) -> bool {
matches!(self, Self::Text | Self::SecretText)
}
pub fn is_selector(self) -> bool {
matches!(self, Self::Selector)
}
pub fn is_toggle(self) -> bool {
matches!(self, Self::Toggle)
}
}
#[derive(Debug, Clone, Copy)]
pub struct SettingsFieldDef {
pub id: SettingsFieldId,
pub title: &'static str,
pub kind: SettingsFieldKind,
pub save_label: &'static str,
}
const S3_FIELD_DEFS: [SettingsFieldDef; 8] = [
SettingsFieldDef {
id: SettingsFieldId::S3Profile,
title: "Profile",
kind: SettingsFieldKind::Selector,
save_label: "S3 Profile",
},
SettingsFieldDef {
id: SettingsFieldId::S3ProfileName,
title: "Profile Name",
kind: SettingsFieldKind::Text,
save_label: "S3 Profile Name",
},
SettingsFieldDef {
id: SettingsFieldId::S3Endpoint,
title: "S3 Endpoint",
kind: SettingsFieldKind::Text,
save_label: "S3 Endpoint",
},
SettingsFieldDef {
id: SettingsFieldId::S3Bucket,
title: "S3 Bucket",
kind: SettingsFieldKind::Text,
save_label: "S3 Bucket",
},
SettingsFieldDef {
id: SettingsFieldId::S3Region,
title: "S3 Region",
kind: SettingsFieldKind::Selector,
save_label: "S3 Region",
},
SettingsFieldDef {
id: SettingsFieldId::S3Prefix,
title: "S3 Prefix",
kind: SettingsFieldKind::Text,
save_label: "Prefix",
},
SettingsFieldDef {
id: SettingsFieldId::S3AccessKey,
title: "S3 Access Key",
kind: SettingsFieldKind::Text,
save_label: "Access Key",
},
SettingsFieldDef {
id: SettingsFieldId::S3SecretKey,
title: "S3 Secret Key",
kind: SettingsFieldKind::SecretText,
save_label: "Secret Key",
},
];
const SCANNER_FIELD_DEFS: [SettingsFieldDef; 4] = [
SettingsFieldDef {
id: SettingsFieldId::ScannerHost,
title: "ClamAV Host",
kind: SettingsFieldKind::Text,
save_label: "ClamAV Host",
},
SettingsFieldDef {
id: SettingsFieldId::ScannerPort,
title: "ClamAV Port",
kind: SettingsFieldKind::Text,
save_label: "ClamAV Port",
},
SettingsFieldDef {
id: SettingsFieldId::ScannerChunkSize,
title: "Scan Chunk Size (MB)",
kind: SettingsFieldKind::Text,
save_label: "Chunk Size",
},
SettingsFieldDef {
id: SettingsFieldId::ScannerEnabled,
title: "Enable Scanner",
kind: SettingsFieldKind::Toggle,
save_label: "Enable Scanner",
},
];
const PERFORMANCE_FIELD_DEFS: [SettingsFieldDef; 7] = [
SettingsFieldDef {
id: SettingsFieldId::PerformancePartSize,
title: "Part Size (MB)",
kind: SettingsFieldKind::Text,
save_label: "Part Size",
},
SettingsFieldDef {
id: SettingsFieldId::PerformanceUploadConcurrency,
title: "Global Upload Concurrency (Files)",
kind: SettingsFieldKind::Text,
save_label: "Global Upload Concurrency",
},
SettingsFieldDef {
id: SettingsFieldId::PerformanceScanConcurrency,
title: "Global Scan Concurrency (Files)",
kind: SettingsFieldKind::Text,
save_label: "Global Scan Concurrency",
},
SettingsFieldDef {
id: SettingsFieldId::PerformanceUploadPartConcurrency,
title: "Upload Part Concurrency (Streams/File)",
kind: SettingsFieldKind::Text,
save_label: "Upload Part Concurrency",
},
SettingsFieldDef {
id: SettingsFieldId::PerformanceScanPartConcurrency,
title: "Scanner Concurrency (Chunks/File)",
kind: SettingsFieldKind::Text,
save_label: "Scanner Concurrency",
},
SettingsFieldDef {
id: SettingsFieldId::PerformanceDeleteSource,
title: "Delete Source After Upload",
kind: SettingsFieldKind::Toggle,
save_label: "Delete Source",
},
SettingsFieldDef {
id: SettingsFieldId::PerformanceShowMetrics,
title: "Show Metrics",
kind: SettingsFieldKind::Toggle,
save_label: "Metrics",
},
];
const GENERAL_FIELD_DEFS: [SettingsFieldDef; 2] = [
SettingsFieldDef {
id: SettingsFieldId::Theme,
title: "Theme",
kind: SettingsFieldKind::Selector,
save_label: "Theme",
},
SettingsFieldDef {
id: SettingsFieldId::SetupWizardAction,
title: "Setup Wizard",
kind: SettingsFieldKind::Action,
save_label: "Setup Wizard",
},
];
fn field_defs_for_category(category: SettingsCategory) -> &'static [SettingsFieldDef] {
match category {
SettingsCategory::S3 => &S3_FIELD_DEFS,
SettingsCategory::Scanner => &SCANNER_FIELD_DEFS,
SettingsCategory::Performance => &PERFORMANCE_FIELD_DEFS,
SettingsCategory::General => &GENERAL_FIELD_DEFS,
}
}
impl SettingsCategory {
pub fn field_count(&self) -> usize {
field_defs_for_category(*self).len()
}
pub fn next(self) -> Self {
match self {
SettingsCategory::S3 => SettingsCategory::Scanner,
SettingsCategory::Scanner => SettingsCategory::Performance,
SettingsCategory::Performance => SettingsCategory::General,
SettingsCategory::General => SettingsCategory::S3,
}
}
pub fn prev(self) -> Self {
match self {
SettingsCategory::S3 => SettingsCategory::General,
SettingsCategory::Scanner => SettingsCategory::S3,
SettingsCategory::Performance => SettingsCategory::Scanner,
SettingsCategory::General => SettingsCategory::Performance,
}
}
pub fn from_sidebar_index(idx: usize) -> Self {
match idx {
0 => SettingsCategory::S3,
1 => SettingsCategory::Scanner,
2 => SettingsCategory::Performance,
3 => SettingsCategory::General,
_ => SettingsCategory::S3,
}
}
}
#[derive(Debug, Clone)]
pub struct S3ProfileEntry {
pub id: i64,
pub name: String,
pub endpoint: String,
pub bucket: String,
pub region: String,
pub prefix: String,
pub access_key: String,
pub secret_key: String,
pub is_default_source: bool,
pub is_default_destination: bool,
}
pub struct SettingsState {
pub endpoint: String,
pub bucket: String,
pub region: String,
pub s3_region_custom: bool,
pub s3_region_filter: String,
pub prefix: String,
pub access_key: String,
pub secret_key: String,
pub s3_profile_id: Option<i64>,
pub s3_profile_name: String,
pub s3_profile_is_default_source: bool,
pub s3_profile_is_default_destination: bool,
pub s3_profiles: Vec<S3ProfileEntry>,
pub s3_profile_index: usize,
pub clamd_host: String,
pub clamd_port: String,
pub scan_chunk_size: String,
pub part_size: String,
pub concurrency_global: String,
pub concurrency_scan_global: String,
pub concurrency_upload_parts: String,
pub concurrency_scan_parts: String,
pub active_category: SettingsCategory,
pub selected_field: usize,
pub editing: bool,
pub theme: String,
pub original_theme: Option<String>,
pub scanner_enabled: bool,
pub host_metrics_enabled: bool,
pub delete_source_after_upload: bool,
}
impl SettingsState {
pub const S3_REGION_OTHER_LABEL: &'static str = "Custom";
pub fn known_s3_regions() -> &'static [&'static str] {
&KNOWN_S3_REGIONS
}
fn is_known_s3_region(value: &str) -> bool {
let trimmed = value.trim();
!trimmed.is_empty() && KNOWN_S3_REGIONS.contains(&trimmed)
}
fn s3_region_custom_for_value(value: &str) -> bool {
!Self::is_known_s3_region(value)
}
pub fn s3_region_other_index(&self) -> usize {
KNOWN_S3_REGIONS.len()
}
pub fn selected_s3_region_selector_index(&self) -> usize {
self.selected_s3_region_known_index()
.unwrap_or(self.s3_region_other_index())
}
pub fn clear_s3_region_filter(&mut self) {
self.s3_region_filter.clear();
}
pub fn s3_region_filter(&self) -> &str {
self.s3_region_filter.as_str()
}
pub fn filtered_s3_region_selector_indices(&self) -> Vec<usize> {
let filter = self.s3_region_filter.trim().to_ascii_lowercase();
let mut indices: Vec<usize> = KNOWN_S3_REGIONS
.iter()
.enumerate()
.filter_map(|(idx, region)| {
if filter.is_empty() || region.to_ascii_lowercase().contains(&filter) {
Some(idx)
} else {
None
}
})
.collect();
indices.push(self.s3_region_other_index());
indices
}
fn sync_s3_region_selection_to_filter(&mut self) {
if self.s3_region_custom {
return;
}
let filter = self.s3_region_filter.trim().to_ascii_lowercase();
if filter.is_empty() {
return;
}
if let Some((idx, _)) = KNOWN_S3_REGIONS
.iter()
.enumerate()
.find(|(_, region)| region.to_ascii_lowercase().contains(&filter))
{
self.region = KNOWN_S3_REGIONS[idx].to_string();
}
}
pub fn push_char_to_s3_region_filter(&mut self, c: char) {
self.s3_region_filter.push(c);
self.sync_s3_region_selection_to_filter();
}
pub fn pop_char_from_s3_region_filter(&mut self) {
self.s3_region_filter.pop();
self.sync_s3_region_selection_to_filter();
}
pub fn field_defs(category: SettingsCategory) -> &'static [SettingsFieldDef] {
field_defs_for_category(category)
}
pub fn active_field_defs(&self) -> &'static [SettingsFieldDef] {
Self::field_defs(self.active_category)
}
pub fn field_def(
category: SettingsCategory,
index: usize,
) -> Option<&'static SettingsFieldDef> {
Self::field_defs(category).get(index)
}
pub fn active_field_def(&self) -> Option<&'static SettingsFieldDef> {
Self::field_def(self.active_category, self.selected_field)
}
pub fn field_value(&self, field_id: SettingsFieldId, reveal_secret: bool) -> String {
match field_id {
SettingsFieldId::S3Profile => self.selected_s3_profile_label().to_string(),
SettingsFieldId::S3ProfileName => self.selected_s3_profile_name().to_string(),
SettingsFieldId::S3Endpoint => self.selected_s3_endpoint().to_string(),
SettingsFieldId::S3Bucket => self.selected_s3_bucket().to_string(),
SettingsFieldId::S3Region => self.selected_s3_region().to_string(),
SettingsFieldId::S3Prefix => self.selected_s3_prefix().to_string(),
SettingsFieldId::S3AccessKey => self.selected_s3_access_key().to_string(),
SettingsFieldId::S3SecretKey => self
.selected_s3_secret_key_display(reveal_secret)
.to_string(),
SettingsFieldId::ScannerHost => self.clamd_host.clone(),
SettingsFieldId::ScannerPort => self.clamd_port.clone(),
SettingsFieldId::ScannerChunkSize => self.scan_chunk_size.clone(),
SettingsFieldId::ScannerEnabled => {
if self.scanner_enabled {
"[X] Enabled".to_string()
} else {
"[ ] Disabled".to_string()
}
}
SettingsFieldId::PerformancePartSize => self.part_size.clone(),
SettingsFieldId::PerformanceUploadConcurrency => self.concurrency_global.clone(),
SettingsFieldId::PerformanceScanConcurrency => self.concurrency_scan_global.clone(),
SettingsFieldId::PerformanceUploadPartConcurrency => {
self.concurrency_upload_parts.clone()
}
SettingsFieldId::PerformanceScanPartConcurrency => self.concurrency_scan_parts.clone(),
SettingsFieldId::PerformanceDeleteSource => {
if self.delete_source_after_upload {
"[X] Enabled".to_string()
} else {
"[ ] Disabled".to_string()
}
}
SettingsFieldId::PerformanceShowMetrics => {
if self.host_metrics_enabled {
"[X] Enabled".to_string()
} else {
"[ ] Disabled".to_string()
}
}
SettingsFieldId::Theme => self.theme.clone(),
SettingsFieldId::SetupWizardAction => "Run guided setup".to_string(),
}
}
pub fn push_char_to_field(&mut self, field_id: SettingsFieldId, c: char) -> bool {
match field_id {
SettingsFieldId::S3ProfileName => self.selected_s3_profile_name_mut().push(c),
SettingsFieldId::S3Endpoint => self.selected_s3_endpoint_mut().push(c),
SettingsFieldId::S3Bucket => self.selected_s3_bucket_mut().push(c),
SettingsFieldId::S3Region => self.selected_s3_region_mut().push(c),
SettingsFieldId::S3Prefix => self.selected_s3_prefix_mut().push(c),
SettingsFieldId::S3AccessKey => self.selected_s3_access_key_mut().push(c),
SettingsFieldId::S3SecretKey => self.selected_s3_secret_key_mut().push(c),
SettingsFieldId::ScannerHost => self.clamd_host.push(c),
SettingsFieldId::ScannerPort => self.clamd_port.push(c),
SettingsFieldId::ScannerChunkSize => self.scan_chunk_size.push(c),
SettingsFieldId::PerformancePartSize => self.part_size.push(c),
SettingsFieldId::PerformanceUploadConcurrency => self.concurrency_global.push(c),
SettingsFieldId::PerformanceScanConcurrency => self.concurrency_scan_global.push(c),
SettingsFieldId::PerformanceUploadPartConcurrency => {
self.concurrency_upload_parts.push(c)
}
SettingsFieldId::PerformanceScanPartConcurrency => self.concurrency_scan_parts.push(c),
_ => return false,
}
true
}
pub fn pop_char_from_field(&mut self, field_id: SettingsFieldId) -> bool {
match field_id {
SettingsFieldId::S3ProfileName => {
self.selected_s3_profile_name_mut().pop();
}
SettingsFieldId::S3Endpoint => {
self.selected_s3_endpoint_mut().pop();
}
SettingsFieldId::S3Bucket => {
self.selected_s3_bucket_mut().pop();
}
SettingsFieldId::S3Region => {
self.selected_s3_region_mut().pop();
}
SettingsFieldId::S3Prefix => {
self.selected_s3_prefix_mut().pop();
}
SettingsFieldId::S3AccessKey => {
self.selected_s3_access_key_mut().pop();
}
SettingsFieldId::S3SecretKey => {
self.selected_s3_secret_key_mut().pop();
}
SettingsFieldId::ScannerHost => {
self.clamd_host.pop();
}
SettingsFieldId::ScannerPort => {
self.clamd_port.pop();
}
SettingsFieldId::ScannerChunkSize => {
self.scan_chunk_size.pop();
}
SettingsFieldId::PerformancePartSize => {
self.part_size.pop();
}
SettingsFieldId::PerformanceUploadConcurrency => {
self.concurrency_global.pop();
}
SettingsFieldId::PerformanceScanConcurrency => {
self.concurrency_scan_global.pop();
}
SettingsFieldId::PerformanceUploadPartConcurrency => {
self.concurrency_upload_parts.pop();
}
SettingsFieldId::PerformanceScanPartConcurrency => {
self.concurrency_scan_parts.pop();
}
_ => return false,
}
true
}
pub fn toggle_field(&mut self, field_id: SettingsFieldId) -> Option<String> {
match field_id {
SettingsFieldId::ScannerEnabled => {
self.scanner_enabled = !self.scanner_enabled;
Some(format!(
"Scanner {}",
if self.scanner_enabled {
"Enabled"
} else {
"Disabled"
}
))
}
SettingsFieldId::PerformanceDeleteSource => {
self.delete_source_after_upload = !self.delete_source_after_upload;
Some(format!(
"Delete Source: {}",
if self.delete_source_after_upload {
"Enabled"
} else {
"Disabled"
}
))
}
SettingsFieldId::PerformanceShowMetrics => {
self.host_metrics_enabled = !self.host_metrics_enabled;
Some(format!(
"Metrics: {}",
if self.host_metrics_enabled {
"Enabled"
} else {
"Disabled"
}
))
}
_ => None,
}
}
pub fn from_config(cfg: &Config) -> Self {
let region = cfg
.s3_region
.clone()
.unwrap_or_else(|| DEFAULT_S3_REGION.to_string());
Self {
endpoint: cfg.s3_endpoint.clone().unwrap_or_default(),
bucket: cfg.s3_bucket.clone().unwrap_or_default(),
s3_region_custom: Self::s3_region_custom_for_value(®ion),
s3_region_filter: String::new(),
region,
prefix: cfg.s3_prefix.clone().unwrap_or_default(),
access_key: cfg.s3_access_key.clone().unwrap_or_default(),
secret_key: cfg.s3_secret_key.clone().unwrap_or_default(),
s3_profile_id: None,
s3_profile_name: "Unconfigured".to_string(),
s3_profile_is_default_source: false,
s3_profile_is_default_destination: false,
s3_profiles: Vec::new(),
s3_profile_index: 0,
clamd_host: cfg.clamd_host.clone(),
clamd_port: cfg.clamd_port.to_string(),
scan_chunk_size: cfg.scan_chunk_size_mb.to_string(),
part_size: cfg.part_size_mb.to_string(),
concurrency_global: cfg.concurrency_upload_global.to_string(),
concurrency_scan_global: cfg.concurrency_scan_global.to_string(),
concurrency_upload_parts: cfg.concurrency_upload_parts.to_string(),
concurrency_scan_parts: cfg.concurrency_scan_parts.to_string(),
active_category: SettingsCategory::S3,
selected_field: 0,
editing: false,
theme: cfg.theme.clone(),
original_theme: None,
scanner_enabled: cfg.scanner_enabled,
host_metrics_enabled: cfg.host_metrics_enabled,
delete_source_after_upload: cfg.delete_source_after_upload,
}
}
pub fn apply_to_config(&self, cfg: &mut Config) {
if self.s3_profile_is_default_destination {
cfg.s3_endpoint = if self.endpoint.trim().is_empty() {
None
} else {
Some(self.endpoint.trim().to_string())
};
cfg.s3_bucket = if self.bucket.trim().is_empty() {
None
} else {
Some(self.bucket.trim().to_string())
};
cfg.s3_region = if self.region.trim().is_empty() {
None
} else {
Some(self.region.trim().to_string())
};
cfg.s3_prefix = if self.prefix.trim().is_empty() {
None
} else {
Some(self.prefix.trim().to_string())
};
cfg.s3_access_key = if self.access_key.trim().is_empty() {
None
} else {
Some(self.access_key.trim().to_string())
};
cfg.s3_secret_key = if self.secret_key.trim().is_empty() {
None
} else {
Some(self.secret_key.trim().to_string())
};
}
cfg.clamd_host = self.clamd_host.trim().to_string();
if let Ok(p) = self.clamd_port.trim().parse() {
cfg.clamd_port = p;
}
if let Ok(v) = self.scan_chunk_size.trim().parse() {
cfg.scan_chunk_size_mb = v;
}
if let Ok(v) = self.part_size.trim().parse() {
cfg.part_size_mb = v;
}
if let Ok(v) = self.concurrency_global.trim().parse() {
cfg.concurrency_upload_global = v;
}
if let Ok(v) = self.concurrency_scan_global.trim().parse() {
cfg.concurrency_scan_global = v;
}
if let Ok(v) = self.concurrency_upload_parts.trim().parse() {
cfg.concurrency_upload_parts = v;
}
if let Ok(v) = self.concurrency_scan_parts.trim().parse() {
cfg.concurrency_scan_parts = v;
}
cfg.theme = self.theme.clone();
cfg.scanner_enabled = self.scanner_enabled;
cfg.host_metrics_enabled = self.host_metrics_enabled;
cfg.delete_source_after_upload = self.delete_source_after_upload;
}
fn trim_non_empty(value: &str) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
fn read_config(profile: &db::EndpointProfileRow, key: &str) -> String {
profile
.config
.get(key)
.and_then(|v| v.as_str())
.map(str::trim)
.unwrap_or("")
.to_string()
}
fn profile_entry_from_row(
conn: &Connection,
profile: db::EndpointProfileRow,
) -> Result<S3ProfileEntry> {
let secret_key = if let Some(secret_ref) = profile.credential_ref.as_deref() {
db::get_secret(conn, secret_ref)?.unwrap_or_default()
} else {
String::new()
};
Ok(S3ProfileEntry {
id: profile.id,
name: profile.name.clone(),
endpoint: Self::read_config(&profile, "endpoint"),
bucket: Self::read_config(&profile, "bucket"),
region: Self::read_config(&profile, "region"),
prefix: Self::read_config(&profile, "prefix"),
access_key: Self::read_config(&profile, "access_key"),
secret_key,
is_default_source: profile.is_default_source,
is_default_destination: profile.is_default_destination,
})
}
fn apply_profile_index(&mut self, index: usize) {
if self.s3_profiles.is_empty() || index >= self.s3_profiles.len() {
self.s3_profile_id = None;
self.s3_profile_name = "Unconfigured".to_string();
self.s3_profile_is_default_source = false;
self.s3_profile_is_default_destination = false;
self.endpoint.clear();
self.bucket.clear();
self.region = DEFAULT_S3_REGION.to_string();
self.s3_region_custom = false;
self.s3_region_filter.clear();
self.prefix.clear();
self.access_key.clear();
self.secret_key.clear();
self.s3_profile_index = 0;
return;
}
let profile = self.s3_profiles[index].clone();
self.s3_profile_id = Some(profile.id);
self.s3_profile_name = profile.name;
self.s3_profile_is_default_source = profile.is_default_source;
self.s3_profile_is_default_destination = profile.is_default_destination;
self.endpoint = profile.endpoint;
self.bucket = profile.bucket;
self.region = if profile.region.trim().is_empty() {
DEFAULT_S3_REGION.to_string()
} else {
profile.region
};
self.s3_region_custom = Self::s3_region_custom_for_value(&self.region);
self.s3_region_filter.clear();
self.prefix = profile.prefix;
self.access_key = profile.access_key;
self.secret_key = profile.secret_key;
self.s3_profile_index = index;
}
pub fn selected_s3_profile_label(&self) -> &str {
self.s3_profile_name.as_str()
}
pub fn selected_s3_profile_name(&self) -> &str {
self.s3_profile_name.as_str()
}
pub fn selected_s3_profile_name_mut(&mut self) -> &mut String {
&mut self.s3_profile_name
}
pub fn cycle_s3_profile_selection(&mut self) {
if self.s3_profiles.is_empty() {
return;
}
let next = (self.s3_profile_index + 1) % self.s3_profiles.len();
self.apply_profile_index(next);
}
pub fn cycle_s3_profile_selection_prev(&mut self) {
if self.s3_profiles.is_empty() {
return;
}
let next = (self.s3_profile_index + self.s3_profiles.len() - 1) % self.s3_profiles.len();
self.apply_profile_index(next);
}
pub fn set_s3_profile_selection_index(&mut self, index: usize) {
self.apply_profile_index(index);
}
pub fn s3_profile_count(&self) -> usize {
self.s3_profiles.len()
}
pub fn selected_s3_endpoint_mut(&mut self) -> &mut String {
&mut self.endpoint
}
pub fn selected_s3_bucket_mut(&mut self) -> &mut String {
&mut self.bucket
}
pub fn selected_s3_region_mut(&mut self) -> &mut String {
&mut self.region
}
pub fn selected_s3_region_known_index(&self) -> Option<usize> {
if self.s3_region_custom {
return None;
}
let region = self.region.trim();
if region.is_empty() {
return None;
}
KNOWN_S3_REGIONS.iter().position(|v| *v == region)
}
pub fn is_s3_region_other_selected(&self) -> bool {
self.s3_region_custom
}
pub fn set_s3_region_selector_index(&mut self, index: usize) {
if index < KNOWN_S3_REGIONS.len() {
self.region = KNOWN_S3_REGIONS[index].to_string();
self.s3_region_custom = false;
} else {
if !self.s3_region_custom {
self.region.clear();
}
self.s3_region_custom = true;
self.s3_region_filter.clear();
}
}
pub fn cycle_s3_region_selection(&mut self) {
let selector_indices = self.filtered_s3_region_selector_indices();
if selector_indices.is_empty() {
return;
}
let selected_idx = self.selected_s3_region_selector_index();
let next_pos = if let Some(current_pos) =
selector_indices.iter().position(|idx| *idx == selected_idx)
{
(current_pos + 1) % selector_indices.len()
} else {
0
};
self.set_s3_region_selector_index(selector_indices[next_pos]);
}
pub fn cycle_s3_region_selection_prev(&mut self) {
let selector_indices = self.filtered_s3_region_selector_indices();
if selector_indices.is_empty() {
return;
}
let selected_idx = self.selected_s3_region_selector_index();
let prev_pos = if let Some(current_pos) =
selector_indices.iter().position(|idx| *idx == selected_idx)
{
(current_pos + selector_indices.len().saturating_sub(1)) % selector_indices.len()
} else {
selector_indices.len().saturating_sub(1)
};
self.set_s3_region_selector_index(selector_indices[prev_pos]);
}
pub fn selected_s3_prefix_mut(&mut self) -> &mut String {
&mut self.prefix
}
pub fn selected_s3_access_key_mut(&mut self) -> &mut String {
&mut self.access_key
}
pub fn selected_s3_secret_key_mut(&mut self) -> &mut String {
&mut self.secret_key
}
pub fn selected_s3_endpoint(&self) -> &str {
self.endpoint.as_str()
}
pub fn selected_s3_bucket(&self) -> &str {
self.bucket.as_str()
}
pub fn selected_s3_region(&self) -> &str {
self.region.as_str()
}
pub fn selected_s3_prefix(&self) -> &str {
self.prefix.as_str()
}
pub fn selected_s3_access_key(&self) -> &str {
self.access_key.as_str()
}
pub fn selected_s3_secret_key_display(&self, reveal: bool) -> &str {
if reveal {
self.secret_key.as_str()
} else {
"*******"
}
}
pub fn apply_selected_s3_profile_to_config(&self, cfg: &mut Config) {
cfg.s3_endpoint = Self::trim_non_empty(&self.endpoint);
cfg.s3_bucket = Self::trim_non_empty(&self.bucket);
cfg.s3_region = Self::trim_non_empty(&self.region);
cfg.s3_prefix = Self::trim_non_empty(&self.prefix);
cfg.s3_access_key = Self::trim_non_empty(&self.access_key);
cfg.s3_secret_key = Self::trim_non_empty(&self.secret_key);
}
pub fn load_secondary_profile_from_db(&mut self, conn: &Connection) -> Result<()> {
let target_profile_id = self.s3_profile_id;
let profiles = db::list_endpoint_profiles(conn)?
.into_iter()
.filter(|profile| profile.kind == EndpointKind::S3)
.map(|profile| Self::profile_entry_from_row(conn, profile))
.collect::<Result<Vec<_>>>()?;
self.s3_profiles = profiles;
if self.s3_profiles.is_empty() {
self.apply_profile_index(usize::MAX);
return Ok(());
}
let selected = if let Some(id) = target_profile_id {
self.s3_profiles.iter().position(|profile| profile.id == id)
} else {
None
}
.or_else(|| {
self.s3_profiles
.iter()
.position(|profile| profile.is_default_destination)
})
.unwrap_or(0);
self.apply_profile_index(selected);
Ok(())
}
fn next_profile_name(&self) -> String {
let mut n = self.s3_profiles.len() + 1;
loop {
let candidate = format!("S3 Profile {}", n);
if !self
.s3_profiles
.iter()
.any(|profile| profile.name == candidate)
{
return candidate;
}
n += 1;
}
}
fn ensure_unique_profile_name(&self, requested: &str, exclude_id: Option<i64>) -> String {
if requested.trim().is_empty() {
return self.next_profile_name();
}
let base = requested.trim().to_string();
if !self.s3_profiles.iter().any(|profile| {
if Some(profile.id) == exclude_id {
return false;
}
profile.name == base
}) {
return base;
}
let mut n = 2usize;
loop {
let candidate = format!("{} ({})", base, n);
if !self.s3_profiles.iter().any(|profile| {
if Some(profile.id) == exclude_id {
return false;
}
profile.name == candidate
}) {
return candidate;
}
n += 1;
}
}
pub fn create_s3_profile(&mut self, conn: &Connection) -> Result<()> {
let name = self.next_profile_name();
let new_id = db::create_endpoint_profile(
conn,
&db::NewEndpointProfile {
name,
kind: EndpointKind::S3,
config: serde_json::json!({ "region": DEFAULT_S3_REGION }),
credential_ref: None,
is_default_source: false,
is_default_destination: false,
},
)?;
self.s3_profile_id = Some(new_id);
self.load_secondary_profile_from_db(conn)?;
Ok(())
}
pub fn delete_current_s3_profile(&mut self, conn: &Connection) -> Result<bool> {
let Some(profile_id) = self.s3_profile_id else {
return Ok(false);
};
db::delete_endpoint_profile(conn, profile_id)?;
self.s3_profile_id = None;
self.load_secondary_profile_from_db(conn)?;
Ok(true)
}
pub fn save_secondary_profile_to_db(&self, conn: &Connection) -> Result<()> {
let profile_id = if let Some(id) = self.s3_profile_id {
id
} else {
return Ok(());
};
let profile_name = self.ensure_unique_profile_name(&self.s3_profile_name, Some(profile_id));
db::rename_endpoint_profile(conn, profile_id, &profile_name)?;
let mut config = Map::new();
if let Some(v) = Self::trim_non_empty(&self.bucket) {
config.insert("bucket".to_string(), Value::String(v));
}
if let Some(v) = Self::trim_non_empty(&self.region) {
config.insert("region".to_string(), Value::String(v));
}
if let Some(v) = Self::trim_non_empty(&self.endpoint) {
config.insert("endpoint".to_string(), Value::String(v));
}
if let Some(v) = Self::trim_non_empty(&self.prefix) {
config.insert("prefix".to_string(), Value::String(v));
}
if let Some(v) = Self::trim_non_empty(&self.access_key) {
config.insert("access_key".to_string(), Value::String(v));
}
let secret_ref = format!("s3_secret_profile_{}", profile_id);
let credential_ref = if let Some(secret) = Self::trim_non_empty(&self.secret_key) {
db::set_secret(conn, &secret_ref, &secret)?;
Some(secret_ref)
} else {
None
};
db::update_endpoint_profile(
conn,
profile_id,
&Value::Object(config),
credential_ref.as_deref(),
self.s3_profile_is_default_source,
self.s3_profile_is_default_destination,
)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn known_region_uses_selector_mode() {
let cfg = Config {
s3_region: Some("us-west-2".to_string()),
..Config::default()
};
let state = SettingsState::from_config(&cfg);
assert_eq!(state.selected_s3_region(), "us-west-2");
assert!(!state.is_s3_region_other_selected());
assert!(state.selected_s3_region_known_index().is_some());
}
#[test]
fn unknown_region_uses_custom_mode() {
let cfg = Config {
s3_region: Some("vendor-special-1".to_string()),
..Config::default()
};
let state = SettingsState::from_config(&cfg);
assert_eq!(state.selected_s3_region(), "vendor-special-1");
assert!(state.is_s3_region_other_selected());
assert_eq!(
state.selected_s3_region_selector_index(),
state.s3_region_other_index()
);
}
#[test]
fn filtered_cycle_selects_first_match_then_custom() {
let cfg = Config {
s3_region: Some("ap-southeast-2".to_string()),
..Config::default()
};
let mut state = SettingsState::from_config(&cfg);
state.set_s3_region_selector_index(state.s3_region_other_index());
for c in "us-west-2".chars() {
state.push_char_to_s3_region_filter(c);
}
state.cycle_s3_region_selection();
assert_eq!(state.selected_s3_region(), "us-west-2");
assert!(!state.is_s3_region_other_selected());
state.cycle_s3_region_selection();
assert!(state.is_s3_region_other_selected());
assert_eq!(
state.selected_s3_region_selector_index(),
state.s3_region_other_index()
);
}
#[test]
fn selecting_custom_allows_manual_region_input() {
let mut state = SettingsState::from_config(&Config::default());
state.set_s3_region_selector_index(state.s3_region_other_index());
assert!(state.is_s3_region_other_selected());
assert!(state.region.is_empty());
for c in "my-region-1".chars() {
let _ = state.push_char_to_field(SettingsFieldId::S3Region, c);
}
assert_eq!(state.selected_s3_region(), "my-region-1");
assert!(state.is_s3_region_other_selected());
}
}