From 97308f9d1550c21d04a622b80ee97374eee8d361 Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Thu, 21 Aug 2025 12:39:21 -0700 Subject: [PATCH 01/10] feat(client): connection profiles (--profile/-P, --save) with JSON persistence --- Cargo.lock | 43 ++++++++++++ Cargo.toml | 1 + socktop/Cargo.toml | 1 + socktop/src/main.rs | 133 ++++++++++++++++++++++++++++++++++---- socktop/src/profiles.rs | 96 +++++++++++++++++++++++++++ socktop/tests/cli_args.rs | 17 ++++- 6 files changed, 278 insertions(+), 13 deletions(-) create mode 100644 socktop/src/profiles.rs diff --git a/Cargo.lock b/Cargo.lock index 934e637..e5aa16f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -540,6 +540,27 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1204,6 +1225,16 @@ dependencies = [ "windows-targets 0.53.3", ] +[[package]] +name = "libredox" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3" +dependencies = [ + "bitflags", + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -1850,6 +1881,17 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "regex" version = "1.11.1" @@ -2183,6 +2225,7 @@ dependencies = [ "bytes", "chrono", "crossterm 0.27.0", + "dirs-next", "flate2", "futures", "futures-util", diff --git a/Cargo.toml b/Cargo.toml index fa97049..c3aad7a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ axum = { version = "0.7", features = ["ws"] } prost = "0.13" prost-types = "0.13" bytes = "1" +dirs-next = "2" [profile.release] # Favor smaller, simpler binaries with good runtime perf diff --git a/socktop/Cargo.toml b/socktop/Cargo.toml index 526001a..bdbcb92 100644 --- a/socktop/Cargo.toml +++ b/socktop/Cargo.toml @@ -19,6 +19,7 @@ crossterm = { workspace = true } chrono = { workspace = true } anyhow = { workspace = true } flate2 = { version = "1", default-features = false, features = ["rust_backend"] } +dirs-next = { workspace = true } rustls = "0.23" rustls-pemfile = "2.1" prost = { workspace = true } diff --git a/socktop/src/main.rs b/socktop/src/main.rs index e1caaa7..e2a6f90 100644 --- a/socktop/src/main.rs +++ b/socktop/src/main.rs @@ -2,29 +2,47 @@ mod app; mod history; +mod profiles; mod types; mod ui; mod ws; use app::App; +use profiles::{load_profiles, save_profiles, ProfileEntry, ProfileRequest, ResolveProfile}; use std::env; +use std::io::{self, Write}; -fn parse_args>(args: I) -> Result<(String, Option), String> { +struct ParsedArgs { + url: Option, + tls_ca: Option, + profile: Option, + save: bool, +} + +fn parse_args>(args: I) -> Result { let mut it = args.into_iter(); let prog = it.next().unwrap_or_else(|| "socktop".into()); let mut url: Option = None; let mut tls_ca: Option = None; + let mut profile: Option = None; + let mut save = false; // --save-profile while let Some(arg) = it.next() { match arg.as_str() { "-h" | "--help" => { return Err(format!( - "Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] ws://HOST:PORT/ws" + "Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--profile NAME|-P NAME] [--save] [ws://HOST:PORT/ws]" )); } "--tls-ca" | "-t" => { tls_ca = it.next(); } + "--profile" | "-P" => { + profile = it.next(); + } + "--save" => { + save = true; + } _ if arg.starts_with("--tls-ca=") => { if let Some((_, v)) = arg.split_once('=') { if !v.is_empty() { @@ -32,30 +50,36 @@ fn parse_args>(args: I) -> Result<(String, Option } } } + _ if arg.starts_with("--profile=") => { + if let Some((_, v)) = arg.split_once('=') { + if !v.is_empty() { + profile = Some(v.to_string()); + } + } + } _ => { if url.is_none() { url = Some(arg); } else { return Err(format!( - "Unexpected argument. Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] ws://HOST:PORT/ws" + "Unexpected argument. Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--profile NAME|-P NAME] [--save] [ws://HOST:PORT/ws]" )); } } } } - - match url { - Some(u) => Ok((u, tls_ca)), - None => Err(format!( - "Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] ws://HOST:PORT/ws" - )), - } + Ok(ParsedArgs { + url, + tls_ca, + profile, + save, + }) } #[tokio::main] async fn main() -> Result<(), Box> { // Reuse the same parsing logic for testability - let (url, tls_ca) = match parse_args(env::args()) { + let parsed = match parse_args(env::args()) { Ok(v) => v, Err(msg) => { eprintln!("{msg}"); @@ -63,6 +87,93 @@ async fn main() -> Result<(), Box> { } }; + let profiles_file = load_profiles(); + let req = ProfileRequest { + profile_name: parsed.profile.clone(), + url: parsed.url.clone(), + tls_ca: parsed.tls_ca.clone(), + }; + let resolved = req.resolve(&profiles_file); + + // Determine final connection parameters (and maybe mutated profiles to persist) + let mut profiles_mut = profiles_file.clone(); + let (url, tls_ca): (String, Option) = match resolved { + ResolveProfile::Direct(u, t) => { + // Possibly save if profile specified and --save or new entry + if let Some(name) = parsed.profile.as_ref() { + let existing = profiles_mut.profiles.get(name); + if existing.is_none() || parsed.save { + // If existing and not forced save, prompt + if existing.is_some() && !parsed.save { + if prompt_yes_no(&format!("Overwrite existing profile '{name}'? [y/N]: ")) { + profiles_mut.profiles.insert( + name.clone(), + ProfileEntry { + url: u.clone(), + tls_ca: t.clone(), + }, + ); + let _ = save_profiles(&profiles_mut); + } + } else { + profiles_mut.profiles.insert( + name.clone(), + ProfileEntry { + url: u.clone(), + tls_ca: t.clone(), + }, + ); + let _ = save_profiles(&profiles_mut); + } + } + } + (u, t) + } + ResolveProfile::Loaded(u, t) => (u, t), + ResolveProfile::PromptSelect(names) => { + eprintln!("Select profile:"); + for (i, n) in names.iter().enumerate() { + eprintln!(" {}. {}", i + 1, n); + } + eprint!("Enter number (or blank to abort): "); + let _ = io::stderr().flush(); + let mut line = String::new(); + if io::stdin().read_line(&mut line).is_ok() { + if let Ok(idx) = line.trim().parse::() { + if idx >= 1 && idx <= names.len() { + let name = &names[idx - 1]; + if let Some(entry) = profiles_mut.profiles.get(name) { + (entry.url.clone(), entry.tls_ca.clone()) + } else { + return Ok(()); + } + } else { + return Ok(()); + } + } else { + return Ok(()); + } + } else { + return Ok(()); + } + } + ResolveProfile::None => { + eprintln!("No URL provided and no profiles to select."); + return Ok(()); + } + }; + let mut app = App::new(); app.run(&url, tls_ca.as_deref()).await } + +fn prompt_yes_no(prompt: &str) -> bool { + eprint!("{prompt}"); + let _ = io::stderr().flush(); + let mut line = String::new(); + if io::stdin().read_line(&mut line).is_ok() { + matches!(line.trim().to_ascii_lowercase().as_str(), "y" | "yes") + } else { + false + } +} diff --git a/socktop/src/profiles.rs b/socktop/src/profiles.rs new file mode 100644 index 0000000..2a75c4d --- /dev/null +++ b/socktop/src/profiles.rs @@ -0,0 +1,96 @@ +//! Connection profiles: load/save simple JSON mapping of profile name -> { url, tls_ca } +//! Stored under XDG config dir: $XDG_CONFIG_HOME/socktop/profiles.json (fallback ~/.config/socktop/profiles.json) + +use serde::{Deserialize, Serialize}; +use std::{collections::BTreeMap, fs, path::PathBuf}; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ProfileEntry { + pub url: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub tls_ca: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ProfilesFile { + #[serde(default)] + pub profiles: BTreeMap, + #[serde(default)] + pub version: u32, +} + +pub fn config_dir() -> PathBuf { + if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") { + PathBuf::from(xdg).join("socktop") + } else { + dirs_next::config_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("socktop") + } +} + +pub fn profiles_path() -> PathBuf { + config_dir().join("profiles.json") +} + +pub fn load_profiles() -> ProfilesFile { + let path = profiles_path(); + match fs::read_to_string(&path) { + Ok(s) => serde_json::from_str(&s).unwrap_or_default(), + Err(_) => ProfilesFile::default(), + } +} + +pub fn save_profiles(p: &ProfilesFile) -> std::io::Result<()> { + let path = profiles_path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let data = serde_json::to_vec_pretty(p).expect("serialize profiles"); + fs::write(path, data) +} + +pub enum ResolveProfile { + /// Use the provided runtime inputs (not persisted). (url, tls_ca) + Direct(String, Option), + /// Loaded from existing profile entry (url, tls_ca) + Loaded(String, Option), + /// Should prompt user to select among profile names + PromptSelect(Vec), + /// No profile could be resolved (e.g., missing arguments) + None, +} + +pub struct ProfileRequest { + pub profile_name: Option, + pub url: Option, + pub tls_ca: Option, +} + +impl ProfileRequest { + pub fn resolve(self, pf: &ProfilesFile) -> ResolveProfile { + // Case: only profile name given -> try load + if self.url.is_none() && self.profile_name.is_some() { + let name = self.profile_name.unwrap(); + if let Some(entry) = pf.profiles.get(&name) { + return ResolveProfile::Loaded(entry.url.clone(), entry.tls_ca.clone()); + } else { + return ResolveProfile::None; + } + } + // Both provided -> direct (maybe later saved by caller) + if let Some(u) = self.url { + return ResolveProfile::Direct(u, self.tls_ca); + } + // Nothing provided -> maybe prompt select if profiles exist + if self.url.is_none() && self.profile_name.is_none() { + if pf.profiles.is_empty() { + ResolveProfile::None + } else { + ResolveProfile::PromptSelect(pf.profiles.keys().cloned().collect()) + } + } else { + ResolveProfile::None + } + } +} diff --git a/socktop/tests/cli_args.rs b/socktop/tests/cli_args.rs index 53433e1..6feacb4 100644 --- a/socktop/tests/cli_args.rs +++ b/socktop/tests/cli_args.rs @@ -17,8 +17,8 @@ fn test_help_mentions_short_and_long_flags() { String::from_utf8_lossy(&output.stderr) ); assert!( - text.contains("--tls-ca") && text.contains("-t"), - "help text missing --tls-ca/-t\n{text}" + text.contains("--tls-ca") && text.contains("-t") && text.contains("--profile") && text.contains("-P"), + "help text missing expected flags (--tls-ca/-t, --profile/-P)\n{text}" ); } @@ -53,4 +53,17 @@ fn test_tlc_ca_arg_long_and_short_parsed() { String::from_utf8_lossy(&out2.stderr) ); assert!(text2.contains("Usage:")); + + // Profile flags with help (should not error) + let out3 = Command::new(exe) + .args(["--profile", "dev", "--help"]) + .output() + .expect("run socktop"); + assert!(out3.status.success(), "socktop --profile dev --help did not succeed"); + let text3 = format!( + "{}{}", + String::from_utf8_lossy(&out3.stdout), + String::from_utf8_lossy(&out3.stderr) + ); + assert!(text3.contains("Usage:")); } From d0498465647553c8f802f5a33df878545aaa4d35 Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Thu, 21 Aug 2025 12:41:46 -0700 Subject: [PATCH 02/10] docs: add connection profiles section to README --- README.md | 78 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/README.md b/README.md index e65ff55..460915e 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,84 @@ The agent stays idle unless queried. When queried, it collects just what’s nee --- +## Connection Profiles (Named) + +You can save frequently used connection settings (URL + optional TLS CA path) under a short name and reuse them later. + +Config file location: + +- Linux (XDG): `$XDG_CONFIG_HOME/socktop/profiles.json` +- Fallback (when XDG not set): `~/.config/socktop/profiles.json` + +### Creating a profile + +First time you specify a new `--profile/-P` name together with a URL (and optional `--tls-ca`), it is saved automatically: + +```bash +socktop --profile prod ws://prod-host:3000/ws +# With TLS pinning: +socktop --profile prod-tls --tls-ca /path/to/cert.pem wss://prod-host:8443/ws +``` + +If a profile already exists you will be prompted before overwriting: + +``` +$ socktop --profile prod ws://new-host:3000/ws +Overwrite existing profile 'prod'? [y/N]: y +``` + +To overwrite without an interactive prompt pass `--save`: + +```bash +socktop --profile prod --save ws://new-host:3000/ws +``` + +### Using a saved profile + +Just pass the profile name (no URL needed): + +```bash +socktop --profile prod +socktop -P prod-tls # short flag +``` + +The stored URL (and TLS CA path, if any) will be used. TLS auto-upgrade still applies if a CA path is stored alongside a ws:// URL. + +### Interactive selection (no args) + +If you run `socktop` with no arguments and at least one profile exists, you will be shown a numbered list to pick from: + +``` +$ socktop +Select profile: + 1. prod + 2. prod-tls +Enter number (or blank to abort): 2 +``` + +Choosing a number starts the TUI with that profile. Pressing Enter on blank aborts without connecting. + +### JSON format + +An example `profiles.json` (pretty‑printed): + +```json +{ + "profiles": { + "prod": { "url": "ws://prod-host:3000/ws" }, + "prod-tls": { "url": "wss://prod-host:8443/ws", "tls_ca": "/home/user/certs/prod-cert.pem" } + }, + "version": 0 +} +``` + +Notes: +- The `tls_ca` path is stored as given; if you move or rotate the certificate update the profile by re-running with `--profile NAME --save`. +- Deleting a profile: edit the JSON file and remove the entry (TUI does not yet have an in-app delete command). +- Profiles are client-side convenience only; they do not affect the agent. + +--- + ## Updating Update the agent (systemd): From 2af08c455ac1daf230ba49de5cafa562b42d33c2 Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Thu, 21 Aug 2025 12:48:53 -0700 Subject: [PATCH 03/10] fix(client): correct profile overwrite prompt logic (only save on confirm or --save) --- socktop/src/main.rs | 42 ++++++++++++++++++++++++------------------ 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/socktop/src/main.rs b/socktop/src/main.rs index e2a6f90..98ab11d 100644 --- a/socktop/src/main.rs +++ b/socktop/src/main.rs @@ -102,29 +102,35 @@ async fn main() -> Result<(), Box> { // Possibly save if profile specified and --save or new entry if let Some(name) = parsed.profile.as_ref() { let existing = profiles_mut.profiles.get(name); - if existing.is_none() || parsed.save { - // If existing and not forced save, prompt - if existing.is_some() && !parsed.save { - if prompt_yes_no(&format!("Overwrite existing profile '{name}'? [y/N]: ")) { - profiles_mut.profiles.insert( - name.clone(), - ProfileEntry { - url: u.clone(), - tls_ca: t.clone(), - }, - ); - let _ = save_profiles(&profiles_mut); - } - } else { + match existing { + None => { + // New profile: auto-save immediately profiles_mut.profiles.insert( name.clone(), - ProfileEntry { - url: u.clone(), - tls_ca: t.clone(), - }, + ProfileEntry { url: u.clone(), tls_ca: t.clone() }, ); let _ = save_profiles(&profiles_mut); } + Some(entry) => { + let changed = entry.url != u || entry.tls_ca != t; + if changed { + if parsed.save { + profiles_mut.profiles.insert( + name.clone(), + ProfileEntry { url: u.clone(), tls_ca: t.clone() }, + ); + let _ = save_profiles(&profiles_mut); + } else if prompt_yes_no(&format!( + "Overwrite existing profile '{name}'? [y/N]: " + )) { + profiles_mut.profiles.insert( + name.clone(), + ProfileEntry { url: u.clone(), tls_ca: t.clone() }, + ); + let _ = save_profiles(&profiles_mut); + } // else: do not overwrite, just connect with provided details + } + } } } (u, t) From b727e545894ae40b458d3b7b775484a5a9319171 Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Thu, 21 Aug 2025 12:56:11 -0700 Subject: [PATCH 04/10] feat(client): prompt for URL/CA when specifying a new profile name --- socktop/src/main.rs | 35 +++++++++++++++++++++++++++++++---- socktop/src/profiles.rs | 4 +++- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/socktop/src/main.rs b/socktop/src/main.rs index 98ab11d..5018d6c 100644 --- a/socktop/src/main.rs +++ b/socktop/src/main.rs @@ -98,7 +98,7 @@ async fn main() -> Result<(), Box> { // Determine final connection parameters (and maybe mutated profiles to persist) let mut profiles_mut = profiles_file.clone(); let (url, tls_ca): (String, Option) = match resolved { - ResolveProfile::Direct(u, t) => { + ResolveProfile::Direct(u, t) => { // Possibly save if profile specified and --save or new entry if let Some(name) = parsed.profile.as_ref() { let existing = profiles_mut.profiles.get(name); @@ -107,7 +107,10 @@ async fn main() -> Result<(), Box> { // New profile: auto-save immediately profiles_mut.profiles.insert( name.clone(), - ProfileEntry { url: u.clone(), tls_ca: t.clone() }, + ProfileEntry { + url: u.clone(), + tls_ca: t.clone(), + }, ); let _ = save_profiles(&profiles_mut); } @@ -117,7 +120,10 @@ async fn main() -> Result<(), Box> { if parsed.save { profiles_mut.profiles.insert( name.clone(), - ProfileEntry { url: u.clone(), tls_ca: t.clone() }, + ProfileEntry { + url: u.clone(), + tls_ca: t.clone(), + }, ); let _ = save_profiles(&profiles_mut); } else if prompt_yes_no(&format!( @@ -125,7 +131,10 @@ async fn main() -> Result<(), Box> { )) { profiles_mut.profiles.insert( name.clone(), - ProfileEntry { url: u.clone(), tls_ca: t.clone() }, + ProfileEntry { + url: u.clone(), + tls_ca: t.clone(), + }, ); let _ = save_profiles(&profiles_mut); } // else: do not overwrite, just connect with provided details @@ -163,6 +172,16 @@ async fn main() -> Result<(), Box> { return Ok(()); } } + ResolveProfile::PromptCreate(name) => { + eprintln!("Profile '{name}' does not exist yet."); + let url = prompt_string("Enter URL (ws://HOST:PORT/ws or wss://...): ")?; + if url.trim().is_empty() { return Ok(()); } + let ca = prompt_string("Enter TLS CA path (or leave blank): ")?; + let ca_opt = if ca.trim().is_empty() { None } else { Some(ca.trim().to_string()) }; + profiles_mut.profiles.insert(name.clone(), ProfileEntry { url: url.trim().to_string(), tls_ca: ca_opt.clone() }); + let _ = save_profiles(&profiles_mut); + (url.trim().to_string(), ca_opt) + } ResolveProfile::None => { eprintln!("No URL provided and no profiles to select."); return Ok(()); @@ -183,3 +202,11 @@ fn prompt_yes_no(prompt: &str) -> bool { false } } + +fn prompt_string(prompt: &str) -> io::Result { + eprint!("{prompt}"); + let _ = io::stderr().flush(); + let mut line = String::new(); + io::stdin().read_line(&mut line)?; + Ok(line) +} diff --git a/socktop/src/profiles.rs b/socktop/src/profiles.rs index 2a75c4d..8dcc682 100644 --- a/socktop/src/profiles.rs +++ b/socktop/src/profiles.rs @@ -57,6 +57,8 @@ pub enum ResolveProfile { Loaded(String, Option), /// Should prompt user to select among profile names PromptSelect(Vec), + /// Should prompt user to create a new profile (name) + PromptCreate(String), /// No profile could be resolved (e.g., missing arguments) None, } @@ -75,7 +77,7 @@ impl ProfileRequest { if let Some(entry) = pf.profiles.get(&name) { return ResolveProfile::Loaded(entry.url.clone(), entry.tls_ca.clone()); } else { - return ResolveProfile::None; + return ResolveProfile::PromptCreate(name); } } // Both provided -> direct (maybe later saved by caller) From a596acfb72551b26fda61ee2b23f07efec24faa5 Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Thu, 21 Aug 2025 13:17:53 -0700 Subject: [PATCH 05/10] chore(client): refactor profile overwrite logic to satisfy clippy --- socktop/src/main.rs | 48 +++++++++++++++++++++++---------------------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/socktop/src/main.rs b/socktop/src/main.rs index 5018d6c..f68bbac 100644 --- a/socktop/src/main.rs +++ b/socktop/src/main.rs @@ -117,27 +117,17 @@ async fn main() -> Result<(), Box> { Some(entry) => { let changed = entry.url != u || entry.tls_ca != t; if changed { - if parsed.save { - profiles_mut.profiles.insert( - name.clone(), - ProfileEntry { - url: u.clone(), - tls_ca: t.clone(), - }, - ); + let overwrite = if parsed.save { + true + } else { + prompt_yes_no(&format!( + "Overwrite existing profile '{name}'? [y/N]: " + )) + }; + if overwrite { + profiles_mut.profiles.insert(name.clone(), ProfileEntry { url: u.clone(), tls_ca: t.clone() }); let _ = save_profiles(&profiles_mut); - } else if prompt_yes_no(&format!( - "Overwrite existing profile '{name}'? [y/N]: " - )) { - profiles_mut.profiles.insert( - name.clone(), - ProfileEntry { - url: u.clone(), - tls_ca: t.clone(), - }, - ); - let _ = save_profiles(&profiles_mut); - } // else: do not overwrite, just connect with provided details + } } } } @@ -175,10 +165,22 @@ async fn main() -> Result<(), Box> { ResolveProfile::PromptCreate(name) => { eprintln!("Profile '{name}' does not exist yet."); let url = prompt_string("Enter URL (ws://HOST:PORT/ws or wss://...): ")?; - if url.trim().is_empty() { return Ok(()); } + if url.trim().is_empty() { + return Ok(()); + } let ca = prompt_string("Enter TLS CA path (or leave blank): ")?; - let ca_opt = if ca.trim().is_empty() { None } else { Some(ca.trim().to_string()) }; - profiles_mut.profiles.insert(name.clone(), ProfileEntry { url: url.trim().to_string(), tls_ca: ca_opt.clone() }); + let ca_opt = if ca.trim().is_empty() { + None + } else { + Some(ca.trim().to_string()) + }; + profiles_mut.profiles.insert( + name.clone(), + ProfileEntry { + url: url.trim().to_string(), + tls_ca: ca_opt.clone(), + }, + ); let _ = save_profiles(&profiles_mut); (url.trim().to_string(), ca_opt) } From e7eb3e6557e74f8fd785d33aa039bb5a2ecd4148 Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Thu, 21 Aug 2025 13:18:36 -0700 Subject: [PATCH 06/10] cargo fmt --- socktop/src/main.rs | 8 +++++++- socktop/tests/cli_args.rs | 12 +++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/socktop/src/main.rs b/socktop/src/main.rs index f68bbac..ae92554 100644 --- a/socktop/src/main.rs +++ b/socktop/src/main.rs @@ -125,7 +125,13 @@ async fn main() -> Result<(), Box> { )) }; if overwrite { - profiles_mut.profiles.insert(name.clone(), ProfileEntry { url: u.clone(), tls_ca: t.clone() }); + profiles_mut.profiles.insert( + name.clone(), + ProfileEntry { + url: u.clone(), + tls_ca: t.clone(), + }, + ); let _ = save_profiles(&profiles_mut); } } diff --git a/socktop/tests/cli_args.rs b/socktop/tests/cli_args.rs index 6feacb4..debaa57 100644 --- a/socktop/tests/cli_args.rs +++ b/socktop/tests/cli_args.rs @@ -17,8 +17,11 @@ fn test_help_mentions_short_and_long_flags() { String::from_utf8_lossy(&output.stderr) ); assert!( - text.contains("--tls-ca") && text.contains("-t") && text.contains("--profile") && text.contains("-P"), - "help text missing expected flags (--tls-ca/-t, --profile/-P)\n{text}" + text.contains("--tls-ca") + && text.contains("-t") + && text.contains("--profile") + && text.contains("-P"), + "help text missing expected flags (--tls-ca/-t, --profile/-P)\n{text}" ); } @@ -59,7 +62,10 @@ fn test_tlc_ca_arg_long_and_short_parsed() { .args(["--profile", "dev", "--help"]) .output() .expect("run socktop"); - assert!(out3.status.success(), "socktop --profile dev --help did not succeed"); + assert!( + out3.status.success(), + "socktop --profile dev --help did not succeed" + ); let text3 = format!( "{}{}", String::from_utf8_lossy(&out3.stdout), From 9491dc50a80d949bb3501a46eac7c86815c3871e Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Thu, 21 Aug 2025 13:47:28 -0700 Subject: [PATCH 07/10] feat(client): demo mode (--demo or select demo) auto-spawns local agent on 3231 --- README.md | 2 +- socktop/src/main.rs | 72 ++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 460915e..be2004a 100644 --- a/README.md +++ b/README.md @@ -241,7 +241,7 @@ Select profile: Enter number (or blank to abort): 2 ``` -Choosing a number starts the TUI with that profile. Pressing Enter on blank aborts without connecting. +Choosing a number starts the TUI with that profile. A built‑in `demo` option is always appended; selecting it launches a local agent on port 3231 (no TLS) and connects to `ws://127.0.0.1:3231/ws`. Pressing Enter on blank aborts without connecting. ### JSON format diff --git a/socktop/src/main.rs b/socktop/src/main.rs index ae92554..aeb308a 100644 --- a/socktop/src/main.rs +++ b/socktop/src/main.rs @@ -17,6 +17,7 @@ struct ParsedArgs { tls_ca: Option, profile: Option, save: bool, + demo: bool, } fn parse_args>(args: I) -> Result { @@ -25,13 +26,14 @@ fn parse_args>(args: I) -> Result = None; let mut tls_ca: Option = None; let mut profile: Option = None; - let mut save = false; // --save-profile + let mut save = false; // --save + let mut demo = false; // --demo while let Some(arg) = it.next() { match arg.as_str() { "-h" | "--help" => { return Err(format!( - "Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--profile NAME|-P NAME] [--save] [ws://HOST:PORT/ws]" + "Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--profile NAME|-P NAME] [--save] [--demo] [ws://HOST:PORT/ws]" )); } "--tls-ca" | "-t" => { @@ -43,6 +45,9 @@ fn parse_args>(args: I) -> Result { save = true; } + "--demo" => { + demo = true; + } _ if arg.starts_with("--tls-ca=") => { if let Some((_, v)) = arg.split_once('=') { if !v.is_empty() { @@ -62,7 +67,7 @@ fn parse_args>(args: I) -> Result>(args: I) -> Result Result<(), Box> { } }; + // Demo mode short-circuit (ignore other args except conflicting ones) + if parsed.demo || matches!(parsed.profile.as_deref(), Some("demo")) { + return run_demo_mode(parsed.tls_ca.as_deref()).await; + } + let profiles_file = load_profiles(); let req = ProfileRequest { profile_name: parsed.profile.clone(), @@ -141,7 +152,9 @@ async fn main() -> Result<(), Box> { (u, t) } ResolveProfile::Loaded(u, t) => (u, t), - ResolveProfile::PromptSelect(names) => { + ResolveProfile::PromptSelect(mut names) => { + // Always add demo option to list + if !names.iter().any(|n| n == "demo") { names.push("demo".into()); } eprintln!("Select profile:"); for (i, n) in names.iter().enumerate() { eprintln!(" {}. {}", i + 1, n); @@ -153,6 +166,7 @@ async fn main() -> Result<(), Box> { if let Ok(idx) = line.trim().parse::() { if idx >= 1 && idx <= names.len() { let name = &names[idx - 1]; + if name == "demo" { return run_demo_mode(parsed.tls_ca.as_deref()).await; } if let Some(entry) = profiles_mut.profiles.get(name) { (entry.url.clone(), entry.tls_ca.clone()) } else { @@ -218,3 +232,53 @@ fn prompt_string(prompt: &str) -> io::Result { io::stdin().read_line(&mut line)?; Ok(line) } + +// --- Demo Mode --- + +async fn run_demo_mode(_tls_ca: Option<&str>) -> Result<(), Box> { + let port = 3231; + let url = format!("ws://127.0.0.1:{port}/ws"); + let child = spawn_demo_agent(port)?; + // Use select to handle Ctrl-C and normal quit + let mut app = App::new(); + tokio::select! { + res = app.run(&url, None) => { drop(child); res } + _ = tokio::signal::ctrl_c() => { + // Drop child (kills agent) then return + drop(child); + Ok(()) + } + } +} + +struct DemoGuard(std::sync::Arc>>); +impl Drop for DemoGuard { fn drop(&mut self) { if let Some(mut ch) = self.0.lock().unwrap().take() { let _ = ch.kill(); } } } + +fn spawn_demo_agent(port: u16) -> Result> { + let candidate = find_agent_executable(); + let mut cmd = std::process::Command::new(candidate); + cmd.arg("--port").arg(port.to_string()); + cmd.env("SOCKTOP_ENABLE_SSL", "0"); + cmd.env("SOCKTOP_AGENT_GPU", "0"); + cmd.env("SOCKTOP_AGENT_TEMP", "0"); + let child = cmd.spawn()?; + // Give the agent a brief moment to start + std::thread::sleep(std::time::Duration::from_millis(300)); + Ok(DemoGuard(std::sync::Arc::new(std::sync::Mutex::new(Some(child))))) +} + +fn find_agent_executable() -> std::path::PathBuf { + let self_exe = std::env::current_exe().ok(); + if let Some(exe) = self_exe { + if let Some(parent) = exe.parent() { + #[cfg(windows)] + let name = "socktop_agent.exe"; + #[cfg(not(windows))] + let name = "socktop_agent"; + let candidate = parent.join(name); + if candidate.exists() { return candidate; } + } + } + // Fallback to relying on PATH + std::path::PathBuf::from("socktop_agent") +} From 0275b1871d1f46d14915e2a71f2165686ffbc56a Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Thu, 21 Aug 2025 13:49:36 -0700 Subject: [PATCH 08/10] cargo fmt --- socktop/src/main.rs | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/socktop/src/main.rs b/socktop/src/main.rs index aeb308a..94c517f 100644 --- a/socktop/src/main.rs +++ b/socktop/src/main.rs @@ -154,7 +154,9 @@ async fn main() -> Result<(), Box> { ResolveProfile::Loaded(u, t) => (u, t), ResolveProfile::PromptSelect(mut names) => { // Always add demo option to list - if !names.iter().any(|n| n == "demo") { names.push("demo".into()); } + if !names.iter().any(|n| n == "demo") { + names.push("demo".into()); + } eprintln!("Select profile:"); for (i, n) in names.iter().enumerate() { eprintln!(" {}. {}", i + 1, n); @@ -166,7 +168,9 @@ async fn main() -> Result<(), Box> { if let Ok(idx) = line.trim().parse::() { if idx >= 1 && idx <= names.len() { let name = &names[idx - 1]; - if name == "demo" { return run_demo_mode(parsed.tls_ca.as_deref()).await; } + if name == "demo" { + return run_demo_mode(parsed.tls_ca.as_deref()).await; + } if let Some(entry) = profiles_mut.profiles.get(name) { (entry.url.clone(), entry.tls_ca.clone()) } else { @@ -252,7 +256,13 @@ async fn run_demo_mode(_tls_ca: Option<&str>) -> Result<(), Box>>); -impl Drop for DemoGuard { fn drop(&mut self) { if let Some(mut ch) = self.0.lock().unwrap().take() { let _ = ch.kill(); } } } +impl Drop for DemoGuard { + fn drop(&mut self) { + if let Some(mut ch) = self.0.lock().unwrap().take() { + let _ = ch.kill(); + } + } +} fn spawn_demo_agent(port: u16) -> Result> { let candidate = find_agent_executable(); @@ -264,7 +274,9 @@ fn spawn_demo_agent(port: u16) -> Result> let child = cmd.spawn()?; // Give the agent a brief moment to start std::thread::sleep(std::time::Duration::from_millis(300)); - Ok(DemoGuard(std::sync::Arc::new(std::sync::Mutex::new(Some(child))))) + Ok(DemoGuard(std::sync::Arc::new(std::sync::Mutex::new(Some( + child, + ))))) } fn find_agent_executable() -> std::path::PathBuf { @@ -276,7 +288,9 @@ fn find_agent_executable() -> std::path::PathBuf { #[cfg(not(windows))] let name = "socktop_agent"; let candidate = parent.join(name); - if candidate.exists() { return candidate; } + if candidate.exists() { + return candidate; + } } } // Fallback to relying on PATH From 8ee2a03a2c089a7309ff6fe8037c326017706952 Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Thu, 21 Aug 2025 13:55:02 -0700 Subject: [PATCH 09/10] chore(client): clean up demo mode integration and add stop log line --- socktop/src/main.rs | 60 +++++++++++++-------------------------------- 1 file changed, 17 insertions(+), 43 deletions(-) diff --git a/socktop/src/main.rs b/socktop/src/main.rs index 94c517f..0d3b71b 100644 --- a/socktop/src/main.rs +++ b/socktop/src/main.rs @@ -26,15 +26,12 @@ fn parse_args>(args: I) -> Result = None; let mut tls_ca: Option = None; let mut profile: Option = None; - let mut save = false; // --save - let mut demo = false; // --demo - + let mut save = false; + let mut demo = false; while let Some(arg) = it.next() { match arg.as_str() { "-h" | "--help" => { - return Err(format!( - "Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--profile NAME|-P NAME] [--save] [--demo] [ws://HOST:PORT/ws]" - )); + return Err(format!("Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--profile NAME|-P NAME] [--save] [--demo] [ws://HOST:PORT/ws]")); } "--tls-ca" | "-t" => { tls_ca = it.next(); @@ -66,9 +63,7 @@ fn parse_args>(args: I) -> Result>(args: I) -> Result Result<(), Box> { - // Reuse the same parsing logic for testability let parsed = match parse_args(env::args()) { Ok(v) => v, Err(msg) => { @@ -92,12 +86,9 @@ async fn main() -> Result<(), Box> { return Ok(()); } }; - - // Demo mode short-circuit (ignore other args except conflicting ones) if parsed.demo || matches!(parsed.profile.as_deref(), Some("demo")) { return run_demo_mode(parsed.tls_ca.as_deref()).await; } - let profiles_file = load_profiles(); let req = ProfileRequest { profile_name: parsed.profile.clone(), @@ -105,17 +96,13 @@ async fn main() -> Result<(), Box> { tls_ca: parsed.tls_ca.clone(), }; let resolved = req.resolve(&profiles_file); - - // Determine final connection parameters (and maybe mutated profiles to persist) let mut profiles_mut = profiles_file.clone(); let (url, tls_ca): (String, Option) = match resolved { ResolveProfile::Direct(u, t) => { - // Possibly save if profile specified and --save or new entry if let Some(name) = parsed.profile.as_ref() { let existing = profiles_mut.profiles.get(name); match existing { None => { - // New profile: auto-save immediately profiles_mut.profiles.insert( name.clone(), ProfileEntry { @@ -153,7 +140,6 @@ async fn main() -> Result<(), Box> { } ResolveProfile::Loaded(u, t) => (u, t), ResolveProfile::PromptSelect(mut names) => { - // Always add demo option to list if !names.iter().any(|n| n == "demo") { names.push("demo".into()); } @@ -213,7 +199,6 @@ async fn main() -> Result<(), Box> { return Ok(()); } }; - let mut app = App::new(); app.run(&url, tls_ca.as_deref()).await } @@ -228,7 +213,6 @@ fn prompt_yes_no(prompt: &str) -> bool { false } } - fn prompt_string(prompt: &str) -> io::Result { eprint!("{prompt}"); let _ = io::stderr().flush(); @@ -237,33 +221,26 @@ fn prompt_string(prompt: &str) -> io::Result { Ok(line) } -// --- Demo Mode --- - +// Demo mode implementation async fn run_demo_mode(_tls_ca: Option<&str>) -> Result<(), Box> { let port = 3231; let url = format!("ws://127.0.0.1:{port}/ws"); let child = spawn_demo_agent(port)?; - // Use select to handle Ctrl-C and normal quit let mut app = App::new(); - tokio::select! { - res = app.run(&url, None) => { drop(child); res } - _ = tokio::signal::ctrl_c() => { - // Drop child (kills agent) then return - drop(child); - Ok(()) - } - } + tokio::select! { res=app.run(&url,None)=>{ drop(child); res } _=tokio::signal::ctrl_c()=>{ drop(child); Ok(()) } } +} +struct DemoGuard { + port: u16, + child: std::sync::Arc>>, } - -struct DemoGuard(std::sync::Arc>>); impl Drop for DemoGuard { fn drop(&mut self) { - if let Some(mut ch) = self.0.lock().unwrap().take() { + if let Some(mut ch) = self.child.lock().unwrap().take() { let _ = ch.kill(); } + eprintln!("Stopped demo agent on port {}", self.port); } } - fn spawn_demo_agent(port: u16) -> Result> { let candidate = find_agent_executable(); let mut cmd = std::process::Command::new(candidate); @@ -272,16 +249,14 @@ fn spawn_demo_agent(port: u16) -> Result> cmd.env("SOCKTOP_AGENT_GPU", "0"); cmd.env("SOCKTOP_AGENT_TEMP", "0"); let child = cmd.spawn()?; - // Give the agent a brief moment to start std::thread::sleep(std::time::Duration::from_millis(300)); - Ok(DemoGuard(std::sync::Arc::new(std::sync::Mutex::new(Some( - child, - ))))) + Ok(DemoGuard { + port, + child: std::sync::Arc::new(std::sync::Mutex::new(Some(child))), + }) } - fn find_agent_executable() -> std::path::PathBuf { - let self_exe = std::env::current_exe().ok(); - if let Some(exe) = self_exe { + if let Ok(exe) = std::env::current_exe() { if let Some(parent) = exe.parent() { #[cfg(windows)] let name = "socktop_agent.exe"; @@ -293,6 +268,5 @@ fn find_agent_executable() -> std::path::PathBuf { } } } - // Fallback to relying on PATH std::path::PathBuf::from("socktop_agent") } From f9114426cc9af57978628dffd8fa4b377ddb4c9d Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Thu, 21 Aug 2025 14:42:15 -0700 Subject: [PATCH 10/10] add unit tests for profile creation and update readme --- Cargo.lock | 1 + README.md | 25 ++------ socktop/Cargo.toml | 1 + socktop/src/main.rs | 23 ++++++-- socktop/tests/profiles.rs | 118 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 144 insertions(+), 24 deletions(-) create mode 100644 socktop/tests/profiles.rs diff --git a/Cargo.lock b/Cargo.lock index e5aa16f..79207ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2237,6 +2237,7 @@ dependencies = [ "rustls-pemfile", "serde", "serde_json", + "tempfile", "tokio", "tokio-tungstenite", "url", diff --git a/README.md b/README.md index be2004a..e34a2e4 100644 --- a/README.md +++ b/README.md @@ -94,31 +94,18 @@ cargo build --release ./target/release/socktop ws://REMOTE_HOST:3000/ws ``` -Tip: Add ?token=... if you enable auth (see Security). +### Quick demo (no agent setup) -TLS quick start (optional, recommended on untrusted networks): - -- Start the agent with TLS enabled (default TLS port 8443). On first run it will generate a self‑signed certificate and key under your config directory. +Spin up a temporary local agent on port 3231 and connect automatically: ```bash -./target/release/socktop_agent --enableSSL --port 8443 # or: -p 8443 -# First run prints the cert and key paths, e.g.: -# socktop_agent: generated self-signed TLS certificate at /home/you/.config/socktop_agent/tls/cert.pem -# socktop_agent: private key at /home/you/.config/socktop_agent/tls/key.pem +socktop --demo ``` -- Copy the certificate file to the client machine (keep the key private on the server): +Or just run `socktop` with no arguments and pick the built‑in `demo` entry from the interactive profile list (if you have saved profiles, `demo` is appended). The demo agent: -```bash -scp /home/you/.config/socktop_agent/tls/cert.pem you@client:/tmp/socktop-agent-ca.pem -``` - -- Connect with the TUI, pinning the server cert: - -```bash -./target/release/socktop --tls-ca /tmp/socktop-agent-ca.pem wss://REMOTE_HOST:8443/ws -# Note: if you pass --tls-ca but use ws://, the client auto-upgrades to wss:// -``` +- Runs locally (`ws://127.0.0.1:3231/ws`) +- Stops automatically (you'll see "Stopped demo agent on port 3231") when you quit the TUI or press Ctrl-C --- diff --git a/socktop/Cargo.toml b/socktop/Cargo.toml index bdbcb92..1fe44fb 100644 --- a/socktop/Cargo.toml +++ b/socktop/Cargo.toml @@ -27,6 +27,7 @@ bytes = { workspace = true } [dev-dependencies] assert_cmd = "2.0" +tempfile = "3" [build-dependencies] prost-build = "0.13" diff --git a/socktop/src/main.rs b/socktop/src/main.rs index 0d3b71b..c9204b2 100644 --- a/socktop/src/main.rs +++ b/socktop/src/main.rs @@ -12,15 +12,16 @@ use profiles::{load_profiles, save_profiles, ProfileEntry, ProfileRequest, Resol use std::env; use std::io::{self, Write}; -struct ParsedArgs { +pub(crate) struct ParsedArgs { url: Option, tls_ca: Option, profile: Option, save: bool, demo: bool, + dry_run: bool, // hidden test helper: skip connecting } -fn parse_args>(args: I) -> Result { +pub(crate) fn parse_args>(args: I) -> Result { let mut it = args.into_iter(); let prog = it.next().unwrap_or_else(|| "socktop".into()); let mut url: Option = None; @@ -28,10 +29,11 @@ fn parse_args>(args: I) -> Result = None; let mut save = false; let mut demo = false; + let mut dry_run = false; while let Some(arg) = it.next() { match arg.as_str() { "-h" | "--help" => { - return Err(format!("Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--profile NAME|-P NAME] [--save] [--demo] [ws://HOST:PORT/ws]")); + return Err(format!("Usage: {prog} [--tls-ca CERT_PEM|-t CERT_PEM] [--profile NAME|-P NAME] [--save] [--demo] [ws://HOST:PORT/ws]\n")); } "--tls-ca" | "-t" => { tls_ca = it.next(); @@ -45,6 +47,10 @@ fn parse_args>(args: I) -> Result { demo = true; } + "--dry-run" => { + // intentionally undocumented + dry_run = true; + } _ if arg.starts_with("--tls-ca=") => { if let Some((_, v)) = arg.split_once('=') { if !v.is_empty() { @@ -74,6 +80,7 @@ fn parse_args>(args: I) -> Result Result<(), Box> { } }; let mut app = App::new(); + if parsed.dry_run { + return Ok(()); + } app.run(&url, tls_ca.as_deref()).await } @@ -246,8 +256,11 @@ fn spawn_demo_agent(port: u16) -> Result> let mut cmd = std::process::Command::new(candidate); cmd.arg("--port").arg(port.to_string()); cmd.env("SOCKTOP_ENABLE_SSL", "0"); - cmd.env("SOCKTOP_AGENT_GPU", "0"); - cmd.env("SOCKTOP_AGENT_TEMP", "0"); + + //JW: do not disable GPU and TEMP in demo mode + //cmd.env("SOCKTOP_AGENT_GPU", "0"); + //cmd.env("SOCKTOP_AGENT_TEMP", "0"); + let child = cmd.spawn()?; std::thread::sleep(std::time::Duration::from_millis(300)); Ok(DemoGuard { diff --git a/socktop/tests/profiles.rs b/socktop/tests/profiles.rs new file mode 100644 index 0000000..c1aa446 --- /dev/null +++ b/socktop/tests/profiles.rs @@ -0,0 +1,118 @@ +//! Tests for profile load/save and resolution logic (non-interactive paths only) +use std::fs; +use std::sync::Mutex; + +// Global lock to serialize tests that mutate process-wide environment variables. +static ENV_LOCK: Mutex<()> = Mutex::new(()); + +#[allow(dead_code)] // touch crate +fn touch() { + let _ = socktop::types::Metrics { + cpu_total: 0.0, + cpu_per_core: vec![], + mem_total: 0, + mem_used: 0, + swap_total: 0, + swap_used: 0, + process_count: None, + hostname: String::new(), + cpu_temp_c: None, + disks: vec![], + networks: vec![], + top_processes: vec![], + gpus: None, + }; +} + +// We re-import internal modules by copying minimal logic here because profiles.rs isn't public. +// Instead of exposing internals, we simulate profile saving through CLI invocations. + +use std::process::Command; + +fn run_socktop(args: &[&str]) -> (bool, String) { + let exe = env!("CARGO_BIN_EXE_socktop"); + let output = Command::new(exe).args(args).output().expect("run socktop"); + let ok = output.status.success(); + let text = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + (ok, text) +} + +fn config_dir() -> std::path::PathBuf { + if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") { + std::path::PathBuf::from(xdg).join("socktop") + } else { + dirs_next::config_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join("socktop") + } +} + +fn profiles_path() -> std::path::PathBuf { + config_dir().join("profiles.json") +} + +#[test] +fn test_profile_created_on_first_use() { + let _guard = ENV_LOCK.lock().unwrap(); + // Isolate config in a temp dir + let td = tempfile::tempdir().unwrap(); + std::env::set_var("XDG_CONFIG_HOME", td.path()); + // Ensure directory exists fresh + std::fs::create_dir_all(td.path().join("socktop")).unwrap(); + let _ = fs::remove_file(profiles_path()); + // Provide profile + url => should create profiles.json + let (_ok, _out) = run_socktop(&["--profile", "unittest", "ws://example:1/ws", "--dry-run"]); + // We pass --help to exit early after parsing (no network attempt) + let data = fs::read_to_string(profiles_path()).expect("profiles.json created"); + assert!( + data.contains("unittest"), + "profiles.json missing profile entry: {data}" + ); +} + +#[test] +fn test_profile_overwrite_only_when_changed() { + let _guard = ENV_LOCK.lock().unwrap(); + let td = tempfile::tempdir().unwrap(); + std::env::set_var("XDG_CONFIG_HOME", td.path()); + std::fs::create_dir_all(td.path().join("socktop")).unwrap(); + let _ = fs::remove_file(profiles_path()); + // Initial create + let (_ok, _out) = run_socktop(&["--profile", "prod", "ws://one/ws", "--dry-run"]); // create + let first = fs::read_to_string(profiles_path()).unwrap(); + // Re-run identical (should not duplicate or corrupt) + let (_ok2, _out2) = run_socktop(&["--profile", "prod", "ws://one/ws", "--dry-run"]); // identical + let second = fs::read_to_string(profiles_path()).unwrap(); + assert_eq!( + first, second, + "Profile file changed despite identical input" + ); + // Overwrite with different URL using --save (no prompt path) + let (_ok3, _out3) = run_socktop(&["--profile", "prod", "--save", "ws://two/ws", "--dry-run"]); + let third = fs::read_to_string(profiles_path()).unwrap(); + assert!(third.contains("two"), "Updated URL not written: {third}"); +} + +#[test] +fn test_profile_tls_ca_persisted() { + let _guard = ENV_LOCK.lock().unwrap(); + let td = tempfile::tempdir().unwrap(); + std::env::set_var("XDG_CONFIG_HOME", td.path()); + std::fs::create_dir_all(td.path().join("socktop")).unwrap(); + let _ = fs::remove_file(profiles_path()); + let (_ok, _out) = run_socktop(&[ + "--profile", + "secureX", + "--tls-ca", + "/tmp/cert.pem", + "wss://host/ws", + "--dry-run", + ]); + let data = fs::read_to_string(profiles_path()).unwrap(); + assert!(data.contains("secureX")); + assert!(data.contains("cert.pem")); +}