diff --git a/Cargo.lock b/Cargo.lock index 82fd957..ecc3290 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -404,6 +404,15 @@ dependencies = [ "syn", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + [[package]] name = "dirs-next" version = "2.0.0" @@ -414,6 +423,18 @@ dependencies = [ "dirs-sys-next", ] +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.0", + "windows-sys 0.59.0", +] + [[package]] name = "dirs-sys-next" version = "0.1.2" @@ -421,7 +442,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" dependencies = [ "libc", - "redox_users", + "redox_users 0.4.6", "winapi", ] @@ -589,6 +610,12 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + [[package]] name = "jiff" version = "0.2.15" @@ -720,6 +747,9 @@ version = "0.1.0" dependencies = [ "anyhow", "clap", + "dirs", + "serde", + "serde_json", "skim", "tokio", "tracing", @@ -772,6 +802,12 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "overload" version = "0.1.1" @@ -938,7 +974,18 @@ checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ "getrandom 0.2.16", "libredox", - "thiserror", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 2.0.12", ] [[package]] @@ -1010,6 +1057,12 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + [[package]] name = "scopeguard" version = "1.2.0" @@ -1036,6 +1089,19 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.140" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "ryu", + "serde", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1179,7 +1245,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +dependencies = [ + "thiserror-impl 2.0.12", ] [[package]] @@ -1193,6 +1268,17 @@ dependencies = [ "syn", ] +[[package]] +name = "thiserror-impl" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "thread_local" version = "1.1.9" diff --git a/Cargo.toml b/Cargo.toml index 4247699..3665abb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,9 @@ edition = "2024" [dependencies] anyhow = "1.0.98" clap = { version = "4.5.40", features = ["derive", "env"] } +dirs = "6.0.0" +serde = { version = "1.0.219", features = ["derive"] } +serde_json = { version = "1.0.140", features = ["preserve_order"] } skim = "0.20.2" tokio = { version = "1.46.1", features = ["full"] } tracing = { version = "0.1.41", features = ["log"] } diff --git a/src/commands.rs b/src/commands.rs index 0026ff2..b91716e 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1,44 +1 @@ -pub mod interactive { - use std::path::Path; - - use anyhow::Context; - - use crate::{ - ssh_command::SshCommandState, ssh_config::SshConfigServiceState, state::State, - user_fuzzy_find::UserFuzzyFindState, - }; - - #[derive(clap::Parser, Default)] - pub struct InteractiveCommand {} - - impl InteractiveCommand { - pub fn new() -> Self { - Self::default() - } - - pub async fn execute(&self, state: &State, ssh_config_path: &Path) -> anyhow::Result<()> { - // 1. Get a list of items in ~/.ssh/config - let items = state - .ssh_config_service() - .get_ssh_items(ssh_config_path) - .await - .context("failed to get ssh items")?; - tracing::trace!("found ssh items: {:#?}", items); - - // 2. Present the list, and allow the user to choose an item - let item = state - .user_fuzzy_find() - .get_ssh_item_from_user(&items) - .await?; - - tracing::debug!("found ssh item: '{}'", item); - - // 3. Perform ssh - // call the cmdline parse in all pipes, with the hostname as the destination - // ssh something - state.ssh_command().start_ssh_session(item).await?; - - Ok(()) - } - } -} +pub mod interactive; diff --git a/src/commands/interactive.rs b/src/commands/interactive.rs new file mode 100644 index 0000000..6da88dd --- /dev/null +++ b/src/commands/interactive.rs @@ -0,0 +1,46 @@ +use std::path::Path; + +use anyhow::Context; + +use crate::{ + ssh_command::SshCommandState, ssh_command_database::SshCommandDatabaseState, + ssh_config::SshConfigServiceState, state::State, user_fuzzy_find::UserFuzzyFindState, +}; + +#[derive(clap::Parser, Default)] +pub struct InteractiveCommand {} + +impl InteractiveCommand { + pub fn new() -> Self { + Self::default() + } + + pub async fn execute(&self, state: &State, ssh_config_path: &Path) -> anyhow::Result<()> { + // 1. Get a list of items in ~/.ssh/config + let mut items = state + .ssh_config_service() + .get_ssh_items(ssh_config_path) + .await + .context("failed to get ssh items")?; + + let mut database_items = state.ssh_command_database().get_items().await?; + items.append(&mut database_items); + + tracing::trace!("found ssh items: {:#?}", items); + + // 2. Present the list, and allow the user to choose an item + let item = state + .user_fuzzy_find() + .get_ssh_item_from_user(&items) + .await?; + + tracing::debug!("found ssh item: '{}'", item); + + // 3. Perform ssh + // call the cmdline parse in all pipes, with the hostname as the destination + // ssh something + state.ssh_command().start_ssh_session(item).await?; + + Ok(()) + } +} diff --git a/src/main.rs b/src/main.rs index 255fc47..6cab0b4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,12 +5,14 @@ use clap::Parser; use tracing_subscriber::EnvFilter; use crate::{ - commands::interactive::InteractiveCommand, ssh_command::SshCommandState, state::State, + commands::interactive::InteractiveCommand, ssh_command::SshCommandState, + ssh_command_database::SshCommandDatabaseState, state::State, }; mod commands; mod ssh_command; +mod ssh_command_database; mod ssh_config; mod state; mod user_fuzzy_find; @@ -72,13 +74,16 @@ async fn main() -> anyhow::Result<()> { cmd.execute(&state, &ssh_config_path).await?; } Subcommands::External(items) => { + let items_ref: Vec<&str> = items.iter().map(|i| i.as_str()).collect(); + // Send to ssh state .ssh_command() - .start_ssh_session_from_raw(items.iter().map(|i| i.as_str()).collect()) + .start_ssh_session_from_raw(&items_ref) .await?; // Remember result + state.ssh_command_database().add_item(&items_ref).await?; } } diff --git a/src/ssh_command.rs b/src/ssh_command.rs index b1828fb..2e41121 100644 --- a/src/ssh_command.rs +++ b/src/ssh_command.rs @@ -12,7 +12,7 @@ impl SshCommand { // ssh something let mut cmd = tokio::process::Command::new("ssh"); - cmd.arg(host); + cmd.args(host); let mut process = cmd.spawn()?; let res = process.wait().await.context("ssh call failed"); @@ -23,7 +23,7 @@ impl SshCommand { Ok(()) } - pub async fn start_ssh_session_from_raw(&self, raw: Vec<&str>) -> anyhow::Result<()> { + pub async fn start_ssh_session_from_raw(&self, raw: &[&str]) -> anyhow::Result<()> { let pretty_raw = raw.join(" "); tracing::info!("starting ssh session at: {}", pretty_raw); diff --git a/src/ssh_command_database.rs b/src/ssh_command_database.rs new file mode 100644 index 0000000..58d6d6d --- /dev/null +++ b/src/ssh_command_database.rs @@ -0,0 +1,146 @@ +use std::{collections::BTreeMap, path::PathBuf}; + +use anyhow::Context; +use serde::{Deserialize, Serialize}; +use tokio::io::AsyncWriteExt; + +use crate::{ + ssh_config::{SshItem, SshItems}, + state::State, +}; + +pub struct SshCommandDatabase {} + +impl SshCommandDatabase { + pub async fn get_items(&self) -> anyhow::Result { + let database_path = self.ensure_get_database_file_path().await?; + let database = if database_path.exists() { + let content = tokio::fs::read(&database_path) + .await + .context("failed to read nossh database file")?; + + let database: Database = serde_json::from_slice(&content)?; + + database + } else { + Database::default() + }; + + let entries = database.get_entries(); + + let ssh_items = entries + .into_iter() + .map(|e| { + ( + e.join(" "), + SshItem::Raw { + contents: e.clone(), + }, + ) + }) + .collect(); + + Ok(SshItems { items: ssh_items }) + } + + #[tracing::instrument(skip(self), level = "trace")] + pub async fn add_item(&self, raw_session: &[&str]) -> anyhow::Result<()> { + tracing::debug!("adding item to database"); + let database_path = self.ensure_get_database_file_path().await?; + + let mut database = if database_path.exists() { + let content = tokio::fs::read(&database_path) + .await + .context("failed to read nossh database file")?; + + let database: Database = serde_json::from_slice(&content)?; + + database + } else { + Database::default() + }; + + database.add_raw(raw_session); + + let mut database_file = tokio::fs::File::create(database_path) + .await + .context("failed to create nossh database file")?; + + let database_file_content = serde_json::to_vec_pretty(&database)?; + + database_file + .write_all(&database_file_content) + .await + .context("failed to write data to database file")?; + database_file + .flush() + .await + .context("failed to flush nossh database file")?; + + Ok(()) + } + + fn get_database_file_path(&self) -> PathBuf { + dirs::data_local_dir() + .expect("requires having a data dir, if using nossh") + .join("nossh") + .join("nossh.database.json") + } + + async fn ensure_get_database_file_path(&self) -> anyhow::Result { + let file_dir = self.get_database_file_path(); + + if let Some(parent) = file_dir.parent() { + tokio::fs::create_dir_all(parent) + .await + .context("failed to create data dir for nossh")?; + } + + Ok(file_dir) + } +} + +#[derive(Default, Clone, Serialize, Deserialize)] +struct Database { + entries: BTreeMap, +} +impl Database { + fn add_raw(&mut self, raw_session: &[&str]) { + self.entries.insert( + raw_session.join(" "), + DatabaseEntry::Raw { + contents: raw_session.iter().map(|r| r.to_string()).collect(), + }, + ); + } + + fn get_entries(&self) -> Vec<&Vec> { + let mut items = Vec::new(); + + for v in self.entries.values() { + match v { + DatabaseEntry::Raw { contents } => { + items.push(contents); + } + } + } + + items + } +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(tag = "entry_type")] +enum DatabaseEntry { + Raw { contents: Vec }, +} + +pub trait SshCommandDatabaseState { + fn ssh_command_database(&self) -> SshCommandDatabase; +} + +impl SshCommandDatabaseState for State { + fn ssh_command_database(&self) -> SshCommandDatabase { + SshCommandDatabase {} + } +} diff --git a/src/ssh_config.rs b/src/ssh_config.rs index 3a2164e..b94f26d 100644 --- a/src/ssh_config.rs +++ b/src/ssh_config.rs @@ -44,25 +44,34 @@ impl SshConfigService { } #[derive(Debug, Clone)] -pub struct SshItem { - host: String, +pub enum SshItem { + Own(String), + Raw { contents: Vec }, } impl SshItem { - pub fn to_host(&self) -> &str { - &self.host + pub fn to_host(&self) -> Vec<&str> { + match self { + SshItem::Own(own) => vec![own], + SshItem::Raw { contents } => contents.iter().map(|c| c.as_str()).collect(), + } } } impl Display for SshItem { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.host) + let host = match self { + SshItem::Own(o) => o.to_string(), + SshItem::Raw { contents } => contents.join(" "), + }; + + f.write_str(&host) } } #[derive(Debug)] pub struct SshItems { - items: BTreeMap, + pub items: BTreeMap, } impl SshItems { @@ -73,13 +82,15 @@ impl SshItems { pub fn get_choice(&self, choice: &str) -> Option<&SshItem> { self.items.get(choice) } + + pub fn append(&mut self, other: &mut SshItems) { + self.items.append(&mut other.items); + } } impl From<&str> for SshItem { fn from(value: &str) -> Self { - Self { - host: value.to_string(), - } + Self::Own(value.to_string()) } } diff --git a/src/user_fuzzy_find.rs b/src/user_fuzzy_find.rs index bd5a991..1043bd1 100644 --- a/src/user_fuzzy_find.rs +++ b/src/user_fuzzy_find.rs @@ -55,7 +55,7 @@ impl UserFuzzyFindState for State { } impl SkimItem for SshItem { - fn text(&self) -> std::borrow::Cow { + fn text(&'_ self) -> std::borrow::Cow<'_, str> { format!("{self}").into() } }