use std::collections::HashMap;
use crate::ssh_config::model::{HostEntry, PatternEntry};
use crate::tunnel::{TunnelRule, TunnelType};
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum FormField {
Alias,
Hostname,
User,
Port,
IdentityFile,
ProxyJump,
AskPass,
VaultSsh,
VaultAddr,
Tags,
}
impl FormField {
pub const ALL: [FormField; 10] = [
FormField::Alias,
FormField::Hostname,
FormField::User,
FormField::Port,
FormField::IdentityFile,
FormField::VaultSsh,
FormField::VaultAddr,
FormField::ProxyJump,
FormField::AskPass,
FormField::Tags,
];
#[cfg(test)]
pub fn next(self) -> Self {
let idx = FormField::ALL.iter().position(|f| *f == self).unwrap_or(0);
FormField::ALL[(idx + 1) % FormField::ALL.len()]
}
#[cfg(test)]
pub fn prev(self) -> Self {
let idx = FormField::ALL.iter().position(|f| *f == self).unwrap_or(0);
FormField::ALL[(idx + FormField::ALL.len() - 1) % FormField::ALL.len()]
}
pub fn label(self) -> &'static str {
match self {
FormField::Alias => "Name",
FormField::Hostname => "Host / IP",
FormField::User => "User",
FormField::Port => "Port",
FormField::IdentityFile => "Identity File",
FormField::ProxyJump => "ProxyJump",
FormField::AskPass => "Password Source",
FormField::VaultSsh => "Vault SSH Role",
FormField::VaultAddr => "Vault SSH Address",
FormField::Tags => "Tags",
}
}
}
#[derive(Debug, Clone)]
pub struct HostForm {
pub alias: String,
pub hostname: String,
pub user: String,
pub port: String,
pub identity_file: String,
pub proxy_jump: String,
pub askpass: String,
pub vault_ssh: String,
pub vault_addr: String,
pub tags: String,
pub focused_field: FormField,
pub cursor_pos: usize,
pub form_hint: Option<String>,
pub is_pattern: bool,
pub expanded: bool,
pub inherited: crate::ssh_config::model::InheritedHints,
}
impl HostForm {
pub fn new() -> Self {
Self {
alias: String::new(),
hostname: String::new(),
user: String::new(),
port: "22".to_string(),
identity_file: String::new(),
proxy_jump: String::new(),
askpass: String::new(),
vault_ssh: String::new(),
vault_addr: String::new(),
tags: String::new(),
focused_field: FormField::Alias,
cursor_pos: 0,
form_hint: None,
is_pattern: false,
expanded: false,
inherited: Default::default(),
}
}
pub fn new_pattern() -> Self {
Self {
is_pattern: true,
expanded: true,
..Self::new()
}
}
pub fn from_entry(
entry: &HostEntry,
inherited: crate::ssh_config::model::InheritedHints,
) -> Self {
let alias = entry.alias.clone();
let cursor_pos = alias.chars().count();
Self {
alias,
hostname: entry.hostname.clone(),
user: entry.user.clone(),
port: entry.port.to_string(),
identity_file: entry.identity_file.clone(),
proxy_jump: entry.proxy_jump.clone(),
askpass: entry.askpass.clone().unwrap_or_default(),
vault_ssh: entry.vault_ssh.clone().unwrap_or_default(),
vault_addr: entry.vault_addr.clone().unwrap_or_default(),
tags: entry.tags.join(", "),
focused_field: FormField::Alias,
cursor_pos,
form_hint: None,
is_pattern: false,
expanded: true,
inherited,
}
}
pub fn from_entry_duplicate(
entry: &HostEntry,
inherited: crate::ssh_config::model::InheritedHints,
) -> (Self, bool) {
let mut form = Self::from_entry(entry, inherited);
let had_vault_ssh = !form.vault_ssh.is_empty();
form.vault_ssh.clear();
form.vault_addr.clear();
(form, had_vault_ssh)
}
pub fn from_pattern_entry(entry: &PatternEntry) -> Self {
let alias = entry.pattern.clone();
let cursor_pos = alias.chars().count();
Self {
alias,
hostname: entry.hostname.clone(),
user: entry.user.clone(),
port: entry.port.to_string(),
identity_file: entry.identity_file.clone(),
proxy_jump: entry.proxy_jump.clone(),
askpass: entry.askpass.clone().unwrap_or_default(),
vault_ssh: String::new(),
vault_addr: String::new(),
tags: entry.tags.join(", "),
focused_field: FormField::Alias,
cursor_pos,
form_hint: None,
is_pattern: true,
expanded: true,
inherited: Default::default(),
}
}
pub fn focused_value(&self) -> &str {
match self.focused_field {
FormField::Alias => &self.alias,
FormField::Hostname => &self.hostname,
FormField::User => &self.user,
FormField::Port => &self.port,
FormField::IdentityFile => &self.identity_file,
FormField::ProxyJump => &self.proxy_jump,
FormField::AskPass => &self.askpass,
FormField::VaultSsh => &self.vault_ssh,
FormField::VaultAddr => &self.vault_addr,
FormField::Tags => &self.tags,
}
}
pub fn focused_value_mut(&mut self) -> &mut String {
match self.focused_field {
FormField::Alias => &mut self.alias,
FormField::Hostname => &mut self.hostname,
FormField::User => &mut self.user,
FormField::Port => &mut self.port,
FormField::IdentityFile => &mut self.identity_file,
FormField::ProxyJump => &mut self.proxy_jump,
FormField::AskPass => &mut self.askpass,
FormField::VaultSsh => &mut self.vault_ssh,
FormField::VaultAddr => &mut self.vault_addr,
FormField::Tags => &mut self.tags,
}
}
pub fn visible_fields(&self) -> Vec<FormField> {
let role_set = !self.vault_ssh.trim().is_empty();
FormField::ALL
.iter()
.copied()
.filter(|f| *f != FormField::VaultAddr || role_set)
.collect()
}
pub fn focus_next_visible(&mut self) {
let visible = self.visible_fields();
if visible.is_empty() {
return;
}
self.focused_field = match visible.iter().position(|f| *f == self.focused_field) {
Some(idx) => visible[(idx + 1) % visible.len()],
None => visible[0],
};
}
pub fn focus_prev_visible(&mut self) {
let visible = self.visible_fields();
if visible.is_empty() {
return;
}
self.focused_field = match visible.iter().position(|f| *f == self.focused_field) {
Some(idx) => visible[(idx + visible.len() - 1) % visible.len()],
None => visible[visible.len() - 1],
};
}
pub fn insert_char(&mut self, c: char) {
let pos = self.cursor_pos;
let val = self.focused_value_mut();
let byte_pos = char_to_byte_pos(val, pos);
val.insert(byte_pos, c);
self.cursor_pos = pos + 1;
}
pub fn delete_char_before_cursor(&mut self) {
if self.cursor_pos == 0 {
return;
}
let pos = self.cursor_pos;
let val = self.focused_value_mut();
let byte_pos = char_to_byte_pos(val, pos);
let prev = char_to_byte_pos(val, pos - 1);
val.drain(prev..byte_pos);
self.cursor_pos = pos - 1;
}
pub fn sync_cursor_to_end(&mut self) {
self.cursor_pos = self.focused_value().chars().count();
}
pub fn update_hint(&mut self) {
self.form_hint = match self.focused_field {
FormField::Alias => {
let v = self.alias.trim();
if v.is_empty() {
None } else if self.is_pattern {
if !crate::ssh_config::model::is_host_pattern(v) {
Some("Pattern needs a wildcard (*, ?, [) or multiple hosts".into())
} else {
None
}
} else if v.contains(char::is_whitespace) {
Some("Alias can't contain whitespace".into())
} else if v.contains('#') {
Some("Alias can't contain '#'".into())
} else if crate::ssh_config::model::is_host_pattern(v) {
Some("Alias can't contain pattern characters".into())
} else {
None
}
}
FormField::Hostname => {
let v = self.hostname.trim();
if !v.is_empty() && v.contains(char::is_whitespace) {
Some("Hostname can't contain whitespace".into())
} else {
None
}
}
FormField::User => {
let v = self.user.trim();
if !v.is_empty() && v.contains(char::is_whitespace) {
Some("User can't contain whitespace".into())
} else {
None
}
}
FormField::Port => {
let v = &self.port;
if v.is_empty() || v == "22" {
None
} else {
match v.parse::<u16>() {
Ok(0) => Some("Port must be 1-65535".into()),
Err(_) => Some("Not a valid port number".into()),
_ => None,
}
}
}
_ => None,
};
}
pub fn validate(&self) -> Result<(), String> {
let alias = self.alias.trim();
if alias.is_empty() {
return Err(if self.is_pattern {
"Pattern can't be empty.".to_string()
} else {
"Alias can't be empty. Every host needs a name!".to_string()
});
}
if self.is_pattern && !crate::ssh_config::model::is_host_pattern(alias) {
return Err("Pattern needs a wildcard (*, ?, [) or multiple hosts.".to_string());
} else if !self.is_pattern {
if alias.contains(char::is_whitespace) {
return Err("Alias can't contain whitespace. Keep it simple.".to_string());
}
if alias.contains('#') {
return Err(
"Alias can't contain '#'. That's a comment character in SSH config."
.to_string(),
);
}
if crate::ssh_config::model::is_host_pattern(alias) {
return Err(
"Alias can't contain pattern characters. That creates a match pattern, not a host."
.to_string(),
);
}
}
let fields = [
(
&self.alias,
if self.is_pattern { "Pattern" } else { "Alias" },
),
(&self.hostname, "Hostname"),
(&self.user, "User"),
(&self.port, "Port"),
(&self.identity_file, "Identity File"),
(&self.proxy_jump, "ProxyJump"),
(&self.askpass, "Password Source"),
(&self.vault_ssh, "Vault SSH Role"),
(&self.vault_addr, "Vault SSH Address"),
(&self.tags, "Tags"),
];
for (value, name) in &fields {
if value.chars().any(|c| c.is_control()) {
return Err(format!(
"{} contains control characters. That's not going to work.",
name
));
}
}
if !self.is_pattern && self.hostname.trim().is_empty() {
return Err("Hostname can't be empty. Where should we connect to?".to_string());
}
if self.hostname.trim().contains(char::is_whitespace) {
return Err("Hostname can't contain whitespace.".to_string());
}
if self.user.trim().contains(char::is_whitespace) {
return Err("User can't contain whitespace.".to_string());
}
let port: u16 = self
.port
.parse()
.map_err(|_| "That's not a port number. Ports are 1-65535, not poetry.".to_string())?;
if port == 0 {
return Err("Port 0? Bold choice, but no. Try 1-65535.".to_string());
}
let vault_role = self.vault_ssh.trim();
if !vault_role.is_empty() && !crate::vault_ssh::is_valid_role(vault_role) {
return Err(
"Vault SSH role: only letters, digits, /, _ and - are allowed \
(e.g. ssh-client-signer/sign/my-role)."
.to_string(),
);
}
if !vault_role.is_empty() {
let addr = self.vault_addr.trim();
if !addr.is_empty() && !crate::vault_ssh::is_valid_vault_addr(addr) {
return Err("Vault SSH address: must be a non-empty URL without spaces \
or control characters (e.g. http://127.0.0.1:8200)."
.to_string());
}
}
Ok(())
}
pub fn to_entry(&self) -> HostEntry {
let askpass_trimmed = self.askpass.trim().to_string();
let vault_ssh_trimmed = self.vault_ssh.trim().to_string();
let vault_addr_trimmed = if vault_ssh_trimmed.is_empty() {
String::new()
} else {
self.vault_addr.trim().to_string()
};
HostEntry {
alias: self.alias.trim().to_string(),
hostname: self.hostname.trim().to_string(),
user: self.user.trim().to_string(),
port: self.port.parse().unwrap_or(22),
identity_file: self.identity_file.trim().to_string(),
proxy_jump: self.proxy_jump.trim().to_string(),
tags: self
.tags
.split(',')
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.collect(),
askpass: if askpass_trimmed.is_empty() {
None
} else {
Some(askpass_trimmed)
},
vault_ssh: if vault_ssh_trimmed.is_empty() {
None
} else {
Some(vault_ssh_trimmed)
},
vault_addr: if vault_addr_trimmed.is_empty() {
None
} else {
Some(vault_addr_trimmed)
},
..Default::default()
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ProviderFormField {
Url,
Token,
Profile,
Project,
Compartment,
Regions,
AliasPrefix,
User,
IdentityFile,
VerifyTls,
VaultRole,
VaultAddr,
AutoSync,
}
impl ProviderFormField {
const CLOUD_FIELDS: &[ProviderFormField] = &[
ProviderFormField::Token,
ProviderFormField::AliasPrefix,
ProviderFormField::User,
ProviderFormField::IdentityFile,
ProviderFormField::VaultRole,
ProviderFormField::VaultAddr,
ProviderFormField::AutoSync,
];
const PROXMOX_FIELDS: &[ProviderFormField] = &[
ProviderFormField::Url,
ProviderFormField::Token,
ProviderFormField::AliasPrefix,
ProviderFormField::User,
ProviderFormField::IdentityFile,
ProviderFormField::VerifyTls,
ProviderFormField::VaultRole,
ProviderFormField::VaultAddr,
ProviderFormField::AutoSync,
];
const AWS_FIELDS: &[ProviderFormField] = &[
ProviderFormField::Token,
ProviderFormField::Profile,
ProviderFormField::Regions,
ProviderFormField::AliasPrefix,
ProviderFormField::User,
ProviderFormField::IdentityFile,
ProviderFormField::VaultRole,
ProviderFormField::VaultAddr,
ProviderFormField::AutoSync,
];
const SCALEWAY_FIELDS: &[ProviderFormField] = &[
ProviderFormField::Token,
ProviderFormField::Regions,
ProviderFormField::AliasPrefix,
ProviderFormField::User,
ProviderFormField::IdentityFile,
ProviderFormField::VaultRole,
ProviderFormField::VaultAddr,
ProviderFormField::AutoSync,
];
const GCP_FIELDS: &[ProviderFormField] = &[
ProviderFormField::Token,
ProviderFormField::Project,
ProviderFormField::Regions,
ProviderFormField::AliasPrefix,
ProviderFormField::User,
ProviderFormField::IdentityFile,
ProviderFormField::VaultRole,
ProviderFormField::VaultAddr,
ProviderFormField::AutoSync,
];
const AZURE_FIELDS: &[ProviderFormField] = &[
ProviderFormField::Token,
ProviderFormField::Regions,
ProviderFormField::AliasPrefix,
ProviderFormField::User,
ProviderFormField::IdentityFile,
ProviderFormField::VaultRole,
ProviderFormField::VaultAddr,
ProviderFormField::AutoSync,
];
const ORACLE_FIELDS: &[ProviderFormField] = &[
ProviderFormField::Token,
ProviderFormField::Compartment,
ProviderFormField::Regions,
ProviderFormField::AliasPrefix,
ProviderFormField::User,
ProviderFormField::IdentityFile,
ProviderFormField::VaultRole,
ProviderFormField::VaultAddr,
ProviderFormField::AutoSync,
];
const OVH_FIELDS: &[ProviderFormField] = &[
ProviderFormField::Token,
ProviderFormField::Project,
ProviderFormField::Regions,
ProviderFormField::AliasPrefix,
ProviderFormField::User,
ProviderFormField::IdentityFile,
ProviderFormField::VaultRole,
ProviderFormField::VaultAddr,
ProviderFormField::AutoSync,
];
pub fn fields_for(provider: &str) -> &'static [ProviderFormField] {
match provider {
"proxmox" => Self::PROXMOX_FIELDS,
"aws" => Self::AWS_FIELDS,
"scaleway" => Self::SCALEWAY_FIELDS,
"gcp" => Self::GCP_FIELDS,
"azure" => Self::AZURE_FIELDS,
"oracle" => Self::ORACLE_FIELDS,
"ovh" => Self::OVH_FIELDS,
_ => Self::CLOUD_FIELDS,
}
}
#[cfg(test)]
pub fn required_fields_for(provider: &str) -> Vec<ProviderFormField> {
let all = Self::fields_for(provider);
all.iter()
.filter(|f| Self::is_required_field(**f, provider))
.copied()
.collect()
}
#[cfg(test)]
pub fn optional_fields_for(provider: &str) -> Vec<ProviderFormField> {
let all = Self::fields_for(provider);
all.iter()
.filter(|f| !Self::is_required_field(**f, provider))
.copied()
.collect()
}
pub fn is_mandatory_field(field: ProviderFormField, provider: &str) -> bool {
match field {
ProviderFormField::Url => true,
ProviderFormField::Token => provider != "tailscale",
ProviderFormField::Profile => provider == "aws",
ProviderFormField::Project => matches!(provider, "gcp" | "ovh"),
ProviderFormField::Compartment => provider == "oracle",
ProviderFormField::Regions => {
matches!(provider, "aws" | "scaleway" | "azure" | "ovh")
}
_ => false,
}
}
pub fn is_required_field(field: ProviderFormField, provider: &str) -> bool {
match field {
ProviderFormField::Token => true,
ProviderFormField::Url => provider == "proxmox",
ProviderFormField::Profile => provider == "aws",
ProviderFormField::Project => matches!(provider, "gcp" | "ovh"),
ProviderFormField::Compartment => provider == "oracle",
ProviderFormField::Regions => {
matches!(
provider,
"aws" | "scaleway" | "gcp" | "azure" | "oracle" | "ovh"
)
}
_ => false,
}
}
pub fn next(self, fields: &[Self]) -> Self {
debug_assert!(
fields.contains(&self),
"focused field {:?} not in fields slice",
self
);
let idx = fields.iter().position(|f| *f == self).unwrap_or(0);
fields[(idx + 1) % fields.len()]
}
pub fn prev(self, fields: &[Self]) -> Self {
debug_assert!(
fields.contains(&self),
"focused field {:?} not in fields slice",
self
);
let idx = fields.iter().position(|f| *f == self).unwrap_or(0);
fields[(idx + fields.len() - 1) % fields.len()]
}
pub fn label(self) -> &'static str {
match self {
ProviderFormField::Url => "URL",
ProviderFormField::Token => "Token",
ProviderFormField::Profile => "Profile",
ProviderFormField::Project => "Project ID",
ProviderFormField::Compartment => "Compartment",
ProviderFormField::Regions => "Regions",
ProviderFormField::AliasPrefix => "Alias Prefix",
ProviderFormField::User => "User",
ProviderFormField::IdentityFile => "Identity File",
ProviderFormField::VerifyTls => "Verify TLS",
ProviderFormField::VaultRole => "Vault SSH Role",
ProviderFormField::VaultAddr => "Vault SSH Address",
ProviderFormField::AutoSync => "Auto Sync",
}
}
}
#[derive(Debug, Clone)]
pub struct ProviderFormFields {
pub url: String,
pub token: String,
pub profile: String,
pub project: String,
pub compartment: String,
pub regions: String,
pub alias_prefix: String,
pub user: String,
pub identity_file: String,
pub verify_tls: bool,
pub auto_sync: bool,
pub vault_role: String,
pub vault_addr: String,
pub focused_field: ProviderFormField,
pub cursor_pos: usize,
pub expanded: bool,
}
impl ProviderFormFields {
pub fn new() -> Self {
Self {
url: String::new(),
token: String::new(),
profile: String::new(),
project: String::new(),
compartment: String::new(),
regions: String::new(),
alias_prefix: String::new(),
user: "root".to_string(),
identity_file: String::new(),
verify_tls: true,
auto_sync: true,
vault_role: String::new(),
vault_addr: String::new(),
focused_field: ProviderFormField::Token,
cursor_pos: 0,
expanded: false,
}
}
pub fn focused_value(&self) -> &str {
match self.focused_field {
ProviderFormField::Url => &self.url,
ProviderFormField::Token => &self.token,
ProviderFormField::Profile => &self.profile,
ProviderFormField::Project => &self.project,
ProviderFormField::Compartment => &self.compartment,
ProviderFormField::Regions => &self.regions,
ProviderFormField::AliasPrefix => &self.alias_prefix,
ProviderFormField::User => &self.user,
ProviderFormField::IdentityFile => &self.identity_file,
ProviderFormField::VaultRole => &self.vault_role,
ProviderFormField::VaultAddr => &self.vault_addr,
ProviderFormField::VerifyTls | ProviderFormField::AutoSync => "",
}
}
pub fn focused_value_mut(&mut self) -> Option<&mut String> {
match self.focused_field {
ProviderFormField::Url => Some(&mut self.url),
ProviderFormField::Token => Some(&mut self.token),
ProviderFormField::Profile => Some(&mut self.profile),
ProviderFormField::Project => Some(&mut self.project),
ProviderFormField::Compartment => Some(&mut self.compartment),
ProviderFormField::Regions => Some(&mut self.regions),
ProviderFormField::AliasPrefix => Some(&mut self.alias_prefix),
ProviderFormField::User => Some(&mut self.user),
ProviderFormField::IdentityFile => Some(&mut self.identity_file),
ProviderFormField::VaultRole => Some(&mut self.vault_role),
ProviderFormField::VaultAddr => Some(&mut self.vault_addr),
ProviderFormField::VerifyTls | ProviderFormField::AutoSync => None,
}
}
pub fn visible_fields(&self, provider: &str) -> Vec<ProviderFormField> {
let role_set = !self.vault_role.trim().is_empty();
ProviderFormField::fields_for(provider)
.iter()
.copied()
.filter(|f| *f != ProviderFormField::VaultAddr || role_set)
.collect()
}
pub fn insert_char(&mut self, c: char) {
let pos = self.cursor_pos;
if let Some(val) = self.focused_value_mut() {
let byte_pos = char_to_byte_pos(val, pos);
val.insert(byte_pos, c);
self.cursor_pos = pos + 1;
}
}
pub fn delete_char_before_cursor(&mut self) {
if self.cursor_pos == 0 {
return;
}
let pos = self.cursor_pos;
if let Some(val) = self.focused_value_mut() {
let byte_pos = char_to_byte_pos(val, pos);
let prev = char_to_byte_pos(val, pos - 1);
val.drain(prev..byte_pos);
self.cursor_pos = pos - 1;
}
}
pub fn sync_cursor_to_end(&mut self) {
self.cursor_pos = self.focused_value().chars().count();
}
}
pub(crate) fn char_to_byte_pos(s: &str, char_pos: usize) -> usize {
s.char_indices()
.nth(char_pos)
.map(|(i, _)| i)
.unwrap_or(s.len())
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum TunnelFormField {
Type,
BindPort,
RemoteHost,
RemotePort,
}
impl TunnelFormField {
pub fn next(self, tunnel_type: TunnelType) -> Self {
match (self, tunnel_type) {
(TunnelFormField::Type, _) => TunnelFormField::BindPort,
(TunnelFormField::BindPort, TunnelType::Dynamic) => TunnelFormField::Type,
(TunnelFormField::BindPort, _) => TunnelFormField::RemoteHost,
(TunnelFormField::RemoteHost, _) => TunnelFormField::RemotePort,
(TunnelFormField::RemotePort, _) => TunnelFormField::Type,
}
}
pub fn prev(self, tunnel_type: TunnelType) -> Self {
match (self, tunnel_type) {
(TunnelFormField::Type, TunnelType::Dynamic) => TunnelFormField::BindPort,
(TunnelFormField::Type, _) => TunnelFormField::RemotePort,
(TunnelFormField::BindPort, _) => TunnelFormField::Type,
(TunnelFormField::RemoteHost, _) => TunnelFormField::BindPort,
(TunnelFormField::RemotePort, _) => TunnelFormField::RemoteHost,
}
}
pub fn label(self) -> &'static str {
match self {
TunnelFormField::Type => "Type",
TunnelFormField::BindPort => "Bind Port",
TunnelFormField::RemoteHost => "Remote Host",
TunnelFormField::RemotePort => "Remote Port",
}
}
}
#[derive(Debug, Clone)]
pub struct TunnelForm {
pub tunnel_type: TunnelType,
pub bind_port: String,
pub remote_host: String,
pub remote_port: String,
pub bind_address: String,
pub focused_field: TunnelFormField,
pub cursor_pos: usize,
}
impl TunnelForm {
pub fn new() -> Self {
Self {
tunnel_type: TunnelType::Local,
bind_port: String::new(),
remote_host: "localhost".to_string(),
remote_port: String::new(),
bind_address: String::new(),
focused_field: TunnelFormField::Type,
cursor_pos: 0,
}
}
pub fn from_rule(rule: &TunnelRule) -> Self {
Self {
tunnel_type: rule.tunnel_type,
bind_port: rule.bind_port.to_string(),
remote_host: rule.remote_host.clone(),
remote_port: if rule.remote_port > 0 {
rule.remote_port.to_string()
} else {
String::new()
},
bind_address: rule.bind_address.clone(),
focused_field: TunnelFormField::Type,
cursor_pos: 0,
}
}
pub fn validate(&self) -> Result<(), String> {
let fields = [
(&self.bind_port, "Bind Port"),
(&self.remote_host, "Remote Host"),
(&self.remote_port, "Remote Port"),
];
for (value, name) in &fields {
if value.chars().any(|c| c.is_control()) {
return Err(format!("{} contains control characters.", name));
}
}
let port: u16 = self
.bind_port
.parse()
.map_err(|_| "Bind port must be 1-65535.".to_string())?;
if port == 0 {
return Err("Bind port can't be 0.".to_string());
}
if self.tunnel_type != TunnelType::Dynamic {
let host = self.remote_host.trim();
if host.is_empty() {
return Err("Remote host can't be empty.".to_string());
}
if host.contains(char::is_whitespace) {
return Err("Remote host can't contain spaces.".to_string());
}
let rport: u16 = self
.remote_port
.parse()
.map_err(|_| "Remote port must be 1-65535.".to_string())?;
if rport == 0 {
return Err("Remote port can't be 0.".to_string());
}
}
Ok(())
}
pub fn to_directive(&self) -> (&'static str, String) {
let key = self.tunnel_type.directive_key();
let bind_port: u16 = self.bind_port.parse().unwrap_or(0);
let remote_port: u16 = self.remote_port.parse().unwrap_or(0);
let rule = TunnelRule {
tunnel_type: self.tunnel_type,
bind_address: self.bind_address.clone(),
bind_port,
remote_host: self.remote_host.clone(),
remote_port,
};
(key, rule.to_directive_value())
}
pub fn focused_value(&self) -> Option<&str> {
match self.focused_field {
TunnelFormField::Type => None,
TunnelFormField::BindPort => Some(&self.bind_port),
TunnelFormField::RemoteHost => Some(&self.remote_host),
TunnelFormField::RemotePort => Some(&self.remote_port),
}
}
pub fn focused_value_mut(&mut self) -> Option<&mut String> {
match self.focused_field {
TunnelFormField::Type => None,
TunnelFormField::BindPort => Some(&mut self.bind_port),
TunnelFormField::RemoteHost => Some(&mut self.remote_host),
TunnelFormField::RemotePort => Some(&mut self.remote_port),
}
}
pub fn insert_char(&mut self, c: char) {
let pos = self.cursor_pos;
if let Some(val) = self.focused_value_mut() {
let byte_pos = char_to_byte_pos(val, pos);
val.insert(byte_pos, c);
self.cursor_pos = pos + 1;
}
}
pub fn delete_char_before_cursor(&mut self) {
if self.cursor_pos == 0 {
return;
}
let pos = self.cursor_pos;
if let Some(val) = self.focused_value_mut() {
let byte_pos = char_to_byte_pos(val, pos);
let prev = char_to_byte_pos(val, pos - 1);
val.drain(prev..byte_pos);
self.cursor_pos = pos - 1;
}
}
pub fn sync_cursor_to_end(&mut self) {
self.cursor_pos = self.focused_value().map(|v| v.chars().count()).unwrap_or(0);
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum SnippetFormField {
Name,
Command,
Description,
}
impl SnippetFormField {
pub const ALL: &[SnippetFormField] = &[
SnippetFormField::Name,
SnippetFormField::Command,
SnippetFormField::Description,
];
pub fn next(self) -> Self {
let idx = Self::ALL.iter().position(|f| *f == self).unwrap_or(0);
Self::ALL[(idx + 1) % Self::ALL.len()]
}
pub fn prev(self) -> Self {
let idx = Self::ALL.iter().position(|f| *f == self).unwrap_or(0);
Self::ALL[(idx + Self::ALL.len() - 1) % Self::ALL.len()]
}
pub fn label(self) -> &'static str {
match self {
SnippetFormField::Name => "Name",
SnippetFormField::Command => "Command",
SnippetFormField::Description => "Description",
}
}
}
#[derive(Debug, Clone)]
pub struct SnippetForm {
pub name: String,
pub command: String,
pub description: String,
pub focused_field: SnippetFormField,
pub cursor_pos: usize,
}
impl SnippetForm {
pub fn new() -> Self {
Self {
name: String::new(),
command: String::new(),
description: String::new(),
focused_field: SnippetFormField::Name,
cursor_pos: 0,
}
}
pub fn from_snippet(snippet: &crate::snippet::Snippet) -> Self {
Self {
name: snippet.name.clone(),
command: snippet.command.clone(),
description: snippet.description.clone(),
focused_field: SnippetFormField::Name,
cursor_pos: snippet.name.chars().count(),
}
}
pub fn focused_value(&self) -> &str {
match self.focused_field {
SnippetFormField::Name => &self.name,
SnippetFormField::Command => &self.command,
SnippetFormField::Description => &self.description,
}
}
pub fn focused_value_mut(&mut self) -> &mut String {
match self.focused_field {
SnippetFormField::Name => &mut self.name,
SnippetFormField::Command => &mut self.command,
SnippetFormField::Description => &mut self.description,
}
}
pub fn insert_char(&mut self, c: char) {
let pos = self.cursor_pos;
let val = self.focused_value_mut();
let byte_pos = char_to_byte_pos(val, pos);
val.insert(byte_pos, c);
self.cursor_pos = pos + 1;
}
pub fn delete_char_before_cursor(&mut self) {
if self.cursor_pos == 0 {
return;
}
let pos = self.cursor_pos;
let val = self.focused_value_mut();
let byte_pos = char_to_byte_pos(val, pos);
let prev = char_to_byte_pos(val, pos - 1);
val.drain(prev..byte_pos);
self.cursor_pos = pos - 1;
}
pub fn sync_cursor_to_end(&mut self) {
self.cursor_pos = self.focused_value().chars().count();
}
pub fn validate(&self) -> Result<(), String> {
crate::snippet::validate_name(&self.name)?;
crate::snippet::validate_command(&self.command)?;
if self.description.contains(|c: char| c.is_control()) {
return Err("Description contains control characters.".to_string());
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct SnippetHostOutput {
pub alias: String,
pub stdout: String,
pub stderr: String,
pub exit_code: Option<i32>,
}
#[derive(Debug, Clone)]
pub struct SnippetOutputState {
pub run_id: u64,
pub results: Vec<SnippetHostOutput>,
pub scroll_offset: usize,
pub completed: usize,
pub total: usize,
pub all_done: bool,
pub cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
}
#[derive(Debug, Clone)]
pub struct SnippetParamFormState {
pub params: Vec<crate::snippet::SnippetParam>,
pub values: Vec<String>,
pub focused_index: usize,
pub cursor_pos: usize,
pub scroll_offset: usize,
pub visible_count: usize,
}
impl SnippetParamFormState {
pub fn new(params: &[crate::snippet::SnippetParam]) -> Self {
let values: Vec<String> = params
.iter()
.map(|p| p.default.clone().unwrap_or_default())
.collect();
let cursor_pos = values.first().map(|v| v.chars().count()).unwrap_or(0);
Self {
params: params.to_vec(),
values,
focused_index: 0,
cursor_pos,
scroll_offset: 0,
visible_count: params.len().min(8),
}
}
pub fn insert_char(&mut self, c: char) {
let idx = self.focused_index;
let pos = self.cursor_pos;
let val = &mut self.values[idx];
let byte_pos = char_to_byte_pos(val, pos);
val.insert(byte_pos, c);
self.cursor_pos = pos + 1;
}
pub fn delete_char_before_cursor(&mut self) {
if self.cursor_pos == 0 {
return;
}
let idx = self.focused_index;
let pos = self.cursor_pos;
let val = &mut self.values[idx];
let byte_pos = char_to_byte_pos(val, pos);
let prev = char_to_byte_pos(val, pos - 1);
val.drain(prev..byte_pos);
self.cursor_pos = pos - 1;
}
pub fn values_map(&self) -> HashMap<String, String> {
self.params
.iter()
.enumerate()
.map(|(i, p)| (p.name.clone(), self.values[i].clone()))
.collect()
}
pub fn is_dirty(&self) -> bool {
self.params.iter().enumerate().any(|(i, p)| {
let default = p.default.as_deref().unwrap_or("");
self.values[i] != default
})
}
}