use std::fs::{File, OpenOptions};
use std::io::{BufRead, BufReader, Write as _};
use std::path::PathBuf;
use tracing::{debug, warn};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
pub enum LogLevel {
Error,
Warn,
#[default]
Info,
Debug,
Trace,
}
impl LogLevel {
pub fn parse(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"error" => Some(LogLevel::Error),
"warn" => Some(LogLevel::Warn),
"info" | "transactional" => Some(LogLevel::Info),
"debug" => Some(LogLevel::Debug),
"trace" | "verbose" => Some(LogLevel::Trace),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
LogLevel::Error => "ERROR",
LogLevel::Warn => "WARN",
LogLevel::Info => "INFO",
LogLevel::Debug => "DEBUG",
LogLevel::Trace => "TRACE",
}
}
pub fn cycle(&self) -> Self {
match self {
LogLevel::Error => LogLevel::Info,
LogLevel::Info => LogLevel::Trace,
LogLevel::Trace => LogLevel::Error,
LogLevel::Warn => LogLevel::Info,
LogLevel::Debug => LogLevel::Trace,
}
}
pub fn color(&self) -> crossterm::style::Color {
match self {
LogLevel::Error => crossterm::style::Color::Red,
LogLevel::Warn => crossterm::style::Color::Yellow,
LogLevel::Info => crossterm::style::Color::Green,
LogLevel::Debug => crossterm::style::Color::Cyan,
LogLevel::Trace => crossterm::style::Color::Blue,
}
}
}
#[derive(Debug, Clone)]
pub struct ServerDisplayInfo {
pub id: String,
pub protocol: String,
pub port: u16,
pub status: String,
pub connections: usize,
}
#[derive(Debug, Clone)]
pub struct ClientDisplayInfo {
pub id: String,
pub protocol: String,
pub remote_addr: String,
pub status: String,
}
#[derive(Debug, Clone)]
pub struct ConnectionDisplayInfo {
pub id: u32,
pub server_id: String,
pub address: String,
pub state: String,
}
#[derive(Debug, Clone)]
pub struct TaskDisplayInfo {
pub id: String,
pub name: String,
pub scope: String,
pub status: String,
pub task_type: String,
}
#[derive(Default, Clone)]
pub struct ConnectionInfo {
pub mode: String,
pub protocol: String,
pub model: String,
pub local_addr: Option<String>,
pub remote_addr: Option<String>,
pub state: String,
}
#[derive(Default, Clone)]
pub struct PacketStats {
pub packets_received: u64,
pub packets_sent: u64,
}
pub struct App {
pub command_history: Vec<String>,
pub history_position: Option<usize>,
pub history_temp_input: Option<String>,
pub connection_info: ConnectionInfo,
pub packet_stats: PacketStats,
pub log_level: LogLevel,
pub slash_suggestions: Vec<String>,
pub servers: Vec<ServerDisplayInfo>,
pub clients: Vec<ClientDisplayInfo>,
pub connections: Vec<ConnectionDisplayInfo>,
pub tasks: Vec<TaskDisplayInfo>,
pub expand_all_connections: bool,
pub next_global_connection_id: u32,
pub connection_id_map: std::collections::HashMap<String, u32>,
pub system_capabilities: crate::privilege::SystemCapabilities,
pub conversations: Vec<crate::state::app_state::ConversationInfo>,
pub show_usage_stats: bool,
pub system_stats: crate::system_stats::SystemStats,
pub active_form: Option<crate::cli::management::InteractiveForm>,
}
impl Default for App {
fn default() -> Self {
Self {
command_history: Vec::new(),
history_position: None,
history_temp_input: None,
connection_info: ConnectionInfo::default(),
packet_stats: PacketStats::default(),
log_level: LogLevel::default(), slash_suggestions: Vec::new(),
servers: Vec::new(),
clients: Vec::new(),
connections: Vec::new(),
tasks: Vec::new(),
expand_all_connections: false,
next_global_connection_id: 1,
connection_id_map: std::collections::HashMap::new(),
system_capabilities: crate::privilege::SystemCapabilities::detect(),
conversations: Vec::new(),
show_usage_stats: false,
system_stats: crate::system_stats::SystemStats::default(),
active_form: None,
}
}
}
impl App {
pub fn new(system_capabilities: crate::privilege::SystemCapabilities) -> Self {
let mut app = Self {
system_capabilities,
..Default::default()
};
app.command_history = Self::load_history();
app
}
fn history_file_path() -> Option<PathBuf> {
dirs::home_dir().map(|mut path| {
path.push(".netget_history");
path
})
}
fn escape_for_storage(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('\n', "\\n")
.replace('\r', "\\r")
.replace('\t', "\\t")
}
fn unescape_from_storage(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(ch) = chars.next() {
if ch == '\\' {
match chars.next() {
Some('n') => result.push('\n'),
Some('r') => result.push('\r'),
Some('t') => result.push('\t'),
Some('\\') => result.push('\\'),
Some(c) => {
result.push('\\');
result.push(c);
}
None => result.push('\\'),
}
} else {
result.push(ch);
}
}
result
}
fn load_history() -> Vec<String> {
let Some(path) = Self::history_file_path() else {
warn!("Could not determine home directory for history file");
return Vec::new();
};
if !path.exists() {
debug!("History file does not exist yet: {:?}", path);
return Vec::new();
}
match File::open(&path) {
Ok(file) => {
let reader = BufReader::new(file);
let history: Vec<String> = reader
.lines()
.map_while(Result::ok)
.filter(|line| !line.trim().is_empty())
.map(|line| Self::unescape_from_storage(&line))
.collect();
debug!("Loaded {} commands from history", history.len());
history
}
Err(e) => {
warn!("Failed to open history file: {}", e);
Vec::new()
}
}
}
pub fn save_history(&self) -> std::io::Result<()> {
let Some(path) = Self::history_file_path() else {
return Ok(()); };
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&path)?;
for command in &self.command_history {
writeln!(file, "{}", Self::escape_for_storage(command))?;
}
debug!("Saved {} commands to history", self.command_history.len());
Ok(())
}
pub fn add_to_history(&mut self, command: String) {
if !command.trim().is_empty()
&& (self.command_history.is_empty() || self.command_history.last() != Some(&command))
{
self.command_history.push(command);
}
self.history_position = None;
self.history_temp_input = None;
}
pub fn get_or_allocate_connection_id(&mut self, network_conn_id: String) -> u32 {
if let Some(&id) = self.connection_id_map.get(&network_conn_id) {
id
} else {
let id = self.next_global_connection_id;
self.next_global_connection_id += 1;
self.connection_id_map.insert(network_conn_id, id);
id
}
}
pub fn remove_connection_id(&mut self, network_conn_id: &str) {
self.connection_id_map.remove(network_conn_id);
}
pub fn update_slash_suggestions(&mut self, input: &str) {
if !input.starts_with('/') {
self.slash_suggestions.clear();
return;
}
let all_commands = vec![
"/exit - Exit the application",
"/model [<name>] - List/select a model",
"/log [<level>] - Show/set log level (error, warn, info, debug, trace)",
"/script [<env>] - Show/set scripting environment (llm, python, javascript, go)",
"/web [on/off/ask] - Show/set web search mode",
"/test_ask - Test web search approval prompt",
"/backend [ollama|openai <url>] - Show/switch LLM backend",
"/docs [<protocol>] - Show protocol documentation",
"/stability - List protocols grouped by development state",
"/manage - List running servers/clients and show create/update shapes",
"/create <protocol> [--client] - Interactive prefilled create form",
"/edit <id> - Interactive form prefilled with a running instance's config",
"/update <id> <instruction> - Update a running server/client in place",
];
let input_lower = input.to_lowercase();
self.slash_suggestions = all_commands
.into_iter()
.filter(|cmd| cmd.to_lowercase().starts_with(&input_lower))
.map(|s| s.to_string())
.collect();
}
pub fn set_log_level(&mut self, level: LogLevel) {
self.log_level = level;
}
pub fn toggle_expand_all(&mut self) {
self.expand_all_connections = !self.expand_all_connections;
}
pub fn toggle_usage_stats(&mut self) {
self.show_usage_stats = !self.show_usage_stats;
}
pub fn add_llm_message(&mut self, _message: String) {
}
}