diff --git a/src/cli/commands/client.rs b/src/cli/commands/client.rs index b6a9f28..834a34e 100644 --- a/src/cli/commands/client.rs +++ b/src/cli/commands/client.rs @@ -1,14 +1,19 @@ +use std::collections::HashMap; use std::io; use std::io::Write; use std::path::Path; +use std::path::PathBuf; use std::time::Duration; use anyhow::Context; use distant_core::net::common::{ConnectionId, Host, Map, Request, Response}; use distant_core::net::manager::ManagerClient; +use distant_core::protocol::SearchQueryContentsMatch; +use distant_core::protocol::SearchQueryMatch; +use distant_core::protocol::SearchQueryPathMatch; use distant_core::protocol::{ - self, Capabilities, ChangeKindSet, FileType, Permissions, SearchQuery, SetPermissionsOptions, - SystemInfo, + self, Capabilities, ChangeKind, ChangeKindSet, FileType, Permissions, SearchQuery, + SetPermissionsOptions, SystemInfo, }; use distant_core::{DistantChannel, DistantChannelExt, RemoteCommand, Searcher, Watcher}; use log::*; @@ -32,7 +37,7 @@ mod shell; use lsp::Lsp; use shell::Shell; -use super::common::{Formatter, RemoteProcessLink}; +use super::common::RemoteProcessLink; const SLEEP_DURATION: Duration = Duration::from_millis(1); @@ -1049,7 +1054,6 @@ async fn async_run(cmd: ClientSubcommand) -> CliResult { .await .with_context(|| format!("Failed to open channel to connection {connection_id}"))?; - let mut formatter = Formatter::shell(); let query = SearchQuery { target: target.into(), condition, @@ -1062,17 +1066,60 @@ async fn async_run(cmd: ClientSubcommand) -> CliResult { .context("Failed to start search")?; // Continue to receive and process matches + let mut last_searched_path: Option = None; while let Some(m) = searcher.next().await { - // TODO: Provide a cleaner way to print just a match - let res = Response::new( - "".to_string(), - protocol::Msg::Single(protocol::Response::SearchResults { - id: 0, - matches: vec![m], - }), - ); + let mut files: HashMap<_, Vec> = HashMap::new(); + let mut is_targeting_paths = false; + + match m { + SearchQueryMatch::Path(SearchQueryPathMatch { path, .. }) => { + // Create the entry with no lines called out + files.entry(path).or_default(); + is_targeting_paths = true; + } + + SearchQueryMatch::Contents(SearchQueryContentsMatch { + path, + lines, + line_number, + .. + }) => { + let file_matches = files.entry(path).or_default(); + + file_matches.push(format!( + "{line_number}:{}", + lines.to_string_lossy().trim_end() + )); + } + } + + let mut output = String::new(); + for (path, lines) in files { + use std::fmt::Write; + + // If we are seeing a new path, print it out + if last_searched_path.as_deref() != Some(path.as_path()) { + // If we have already seen some path before, we would have printed it, and + // we want to add a space between it and the current path, but only if we are + // printing out file content matches and not paths + if last_searched_path.is_some() && !is_targeting_paths { + writeln!(&mut output).unwrap(); + } + + writeln!(&mut output, "{}", path.to_string_lossy()).unwrap(); + } + + for line in lines { + writeln!(&mut output, "{line}").unwrap(); + } + + // Update our last seen path + last_searched_path = Some(path); + } - formatter.print(res).context("Failed to print match")?; + if !output.is_empty() { + print!("{}", output); + } } } ClientSubcommand::FileSystem(ClientFileSystemSubcommand::SetPermissions { @@ -1179,15 +1226,19 @@ async fn async_run(cmd: ClientSubcommand) -> CliResult { .with_context(|| format!("Failed to watch {path:?}"))?; // Continue to receive and process changes - let mut formatter = Formatter::shell(); while let Some(change) = watcher.next().await { - // TODO: Provide a cleaner way to print just a change - let res = Response::new( - "".to_string(), - protocol::Msg::Single(protocol::Response::Changed(change)), + println!( + "{} {}", + match change.kind { + ChangeKind::Create => "(Created)", + ChangeKind::Delete => "(Removed)", + x if x.is_access() => "(Accessed)", + x if x.is_modify() => "(Modified)", + x if x.is_rename() => "(Renamed)", + _ => "(Affected)", + }, + change.path.to_string_lossy() ); - - formatter.print(res).context("Failed to print change")?; } } ClientSubcommand::FileSystem(ClientFileSystemSubcommand::Write { diff --git a/src/cli/commands/common.rs b/src/cli/commands/common.rs index 63e76ed..052eb2e 100644 --- a/src/cli/commands/common.rs +++ b/src/cli/commands/common.rs @@ -1,8 +1,6 @@ mod buf; -mod format; mod link; pub mod stdin; pub use buf::*; -pub use format::*; pub use link::*; diff --git a/src/cli/commands/common/format.rs b/src/cli/commands/common/format.rs deleted file mode 100644 index b4b9ed8..0000000 --- a/src/cli/commands/common/format.rs +++ /dev/null @@ -1,399 +0,0 @@ -use std::collections::HashMap; -use std::io::{self, Write}; -use std::path::PathBuf; - -use distant_core::net::common::Response; -use distant_core::protocol::{ - self, ChangeKind, Error, FileType, Metadata, SearchQueryContentsMatch, SearchQueryMatch, - SearchQueryPathMatch, SystemInfo, -}; -use log::*; -use tabled::settings::object::Rows; -use tabled::settings::style::Style; -use tabled::settings::{Alignment, Disable, Modify}; -use tabled::{Table, Tabled}; - -use crate::options::Format; - -#[derive(Default)] -struct FormatterState { - /// Last seen path during search - pub last_searched_path: Option, -} - -pub struct Formatter { - format: Format, - state: FormatterState, -} - -impl Formatter { - /// Create a new output message for the given response based on the specified format - pub fn new(format: Format) -> Self { - Self { - format, - state: Default::default(), - } - } - - /// Creates a new [`Formatter`] using [`Format`] of `Format::Shell` - pub fn shell() -> Self { - Self::new(Format::Shell) - } - - /// Consumes the output message, printing it based on its configuration - pub fn print(&mut self, res: Response>) -> io::Result<()> { - let output = match self.format { - Format::Json => Output::StdoutLine( - serde_json::to_vec(&res) - .map_err(|x| io::Error::new(io::ErrorKind::InvalidData, x))?, - ), - - // NOTE: For shell, we assume a singular entry in the response's payload - Format::Shell if res.payload.is_batch() => { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "Shell does not support batch responses", - )) - } - Format::Shell => format_shell(&mut self.state, res.payload.into_single().unwrap()), - }; - - match output { - Output::Stdout(x) => { - // NOTE: Because we are not including a newline in the output, - // it is not guaranteed to be written out. In the case of - // LSP protocol, the JSON content is not followed by a - // newline and was not picked up when the response was - // sent back to the client; so, we need to manually flush - if let Err(x) = io::stdout().lock().write_all(&x) { - error!("Failed to write stdout: {}", x); - } - - if let Err(x) = io::stdout().lock().flush() { - error!("Failed to flush stdout: {}", x); - } - } - Output::StdoutLine(x) => { - if let Err(x) = io::stdout().lock().write_all(&x) { - error!("Failed to write stdout: {}", x); - } - - if let Err(x) = io::stdout().lock().write(b"\n") { - error!("Failed to write stdout newline: {}", x); - } - } - Output::Stderr(x) => { - // NOTE: Because we are not including a newline in the output, - // it is not guaranteed to be written out. In the case of - // LSP protocol, the JSON content is not followed by a - // newline and was not picked up when the response was - // sent back to the client; so, we need to manually flush - if let Err(x) = io::stderr().lock().write_all(&x) { - error!("Failed to write stderr: {}", x); - } - - if let Err(x) = io::stderr().lock().flush() { - error!("Failed to flush stderr: {}", x); - } - } - Output::StderrLine(x) => { - if let Err(x) = io::stderr().lock().write_all(&x) { - error!("Failed to write stderr: {}", x); - } - - if let Err(x) = io::stderr().lock().write(b"\n") { - error!("Failed to write stderr newline: {}", x); - } - } - Output::None => {} - } - - Ok(()) - } -} - -/// Represents the output content and destination -enum Output { - Stdout(Vec), - StdoutLine(Vec), - Stderr(Vec), - StderrLine(Vec), - None, -} - -fn format_shell(state: &mut FormatterState, data: protocol::Response) -> Output { - match data { - protocol::Response::Ok => Output::None, - protocol::Response::Error(Error { description, .. }) => { - Output::StderrLine(description.into_bytes()) - } - protocol::Response::Blob { data } => Output::StdoutLine(data), - protocol::Response::Text { data } => Output::StdoutLine(data.into_bytes()), - protocol::Response::DirEntries { entries, .. } => { - #[derive(Tabled)] - struct EntryRow { - ty: String, - path: String, - } - - let table = Table::new(entries.into_iter().map(|entry| EntryRow { - ty: String::from(match entry.file_type { - FileType::Dir => "", - FileType::File => "", - FileType::Symlink => "", - }), - path: entry.path.to_string_lossy().to_string(), - })) - .with(Style::blank()) - .with(Disable::row(Rows::new(..1))) - .with(Modify::new(Rows::new(..)).with(Alignment::left())) - .to_string() - .into_bytes(); - - Output::Stdout(table) - } - protocol::Response::Changed(change) => Output::StdoutLine( - format!( - "{} {}", - match change.kind { - ChangeKind::Create => "(Created)", - ChangeKind::Delete => "(Removed)", - x if x.is_access() => "(Accessed)", - x if x.is_modify() => "(Modified)", - x if x.is_rename() => "(Renamed)", - _ => "(Affected)", - }, - change.path.to_string_lossy() - ) - .into_bytes(), - ), - protocol::Response::Exists { value: exists } => { - if exists { - Output::StdoutLine(b"true".to_vec()) - } else { - Output::StdoutLine(b"false".to_vec()) - } - } - protocol::Response::Metadata(Metadata { - canonicalized_path, - file_type, - len, - readonly, - accessed, - created, - modified, - unix, - windows, - }) => Output::StdoutLine( - format!( - concat!( - "{}", - "Type: {}\n", - "Len: {}\n", - "Readonly: {}\n", - "Created: {}\n", - "Last Accessed: {}\n", - "Last Modified: {}\n", - "{}", - "{}", - "{}", - ), - canonicalized_path - .map(|p| format!("Canonicalized Path: {p:?}\n")) - .unwrap_or_default(), - file_type.as_ref(), - len, - readonly, - created.unwrap_or_default(), - accessed.unwrap_or_default(), - modified.unwrap_or_default(), - unix.map(|u| format!( - concat!( - "Owner Read: {}\n", - "Owner Write: {}\n", - "Owner Exec: {}\n", - "Group Read: {}\n", - "Group Write: {}\n", - "Group Exec: {}\n", - "Other Read: {}\n", - "Other Write: {}\n", - "Other Exec: {}", - ), - u.owner_read, - u.owner_write, - u.owner_exec, - u.group_read, - u.group_write, - u.group_exec, - u.other_read, - u.other_write, - u.other_exec - )) - .unwrap_or_default(), - windows - .map(|w| format!( - concat!( - "Archive: {}\n", - "Compressed: {}\n", - "Encrypted: {}\n", - "Hidden: {}\n", - "Integrity Stream: {}\n", - "Normal: {}\n", - "Not Content Indexed: {}\n", - "No Scrub Data: {}\n", - "Offline: {}\n", - "Recall on Data Access: {}\n", - "Recall on Open: {}\n", - "Reparse Point: {}\n", - "Sparse File: {}\n", - "System: {}\n", - "Temporary: {}", - ), - w.archive, - w.compressed, - w.encrypted, - w.hidden, - w.integrity_stream, - w.normal, - w.not_content_indexed, - w.no_scrub_data, - w.offline, - w.recall_on_data_access, - w.recall_on_open, - w.reparse_point, - w.sparse_file, - w.system, - w.temporary, - )) - .unwrap_or_default(), - if unix.is_none() && windows.is_none() { - String::from("\n") - } else { - String::new() - } - ) - .into_bytes(), - ), - protocol::Response::SearchStarted { id } => { - Output::StdoutLine(format!("Query {id} started").into_bytes()) - } - protocol::Response::SearchDone { .. } => Output::None, - protocol::Response::SearchResults { matches, .. } => { - let mut files: HashMap<_, Vec> = HashMap::new(); - let mut is_targeting_paths = false; - - for m in matches { - match m { - SearchQueryMatch::Path(SearchQueryPathMatch { path, .. }) => { - // Create the entry with no lines called out - files.entry(path).or_default(); - is_targeting_paths = true; - } - - SearchQueryMatch::Contents(SearchQueryContentsMatch { - path, - lines, - line_number, - .. - }) => { - let file_matches = files.entry(path).or_default(); - - file_matches.push(format!( - "{line_number}:{}", - lines.to_string_lossy().trim_end() - )); - } - } - } - - let mut output = String::new(); - for (path, lines) in files { - use std::fmt::Write; - - // If we are seening a new path, print it out - if state.last_searched_path.as_deref() != Some(path.as_path()) { - // If we have already seen some path before, we would have printed it, and - // we want to add a space between it and the current path, but only if we are - // printing out file content matches and not paths - if state.last_searched_path.is_some() && !is_targeting_paths { - writeln!(&mut output).unwrap(); - } - - writeln!(&mut output, "{}", path.to_string_lossy()).unwrap(); - } - - for line in lines { - writeln!(&mut output, "{line}").unwrap(); - } - - // Update our last seen path - state.last_searched_path = Some(path); - } - - if !output.is_empty() { - Output::Stdout(output.into_bytes()) - } else { - Output::None - } - } - protocol::Response::ProcSpawned { .. } => Output::None, - protocol::Response::ProcStdout { data, .. } => Output::Stdout(data), - protocol::Response::ProcStderr { data, .. } => Output::Stderr(data), - protocol::Response::ProcDone { id, success, code } => { - if success { - Output::None - } else if let Some(code) = code { - Output::StderrLine(format!("Proc {id} failed with code {code}").into_bytes()) - } else { - Output::StderrLine(format!("Proc {id} failed").into_bytes()) - } - } - protocol::Response::SystemInfo(SystemInfo { - family, - os, - arch, - current_dir, - main_separator, - username, - shell, - }) => Output::StdoutLine( - format!( - concat!( - "Family: {:?}\n", - "Operating System: {:?}\n", - "Arch: {:?}\n", - "Cwd: {:?}\n", - "Path Sep: {:?}\n", - "Username: {:?}\n", - "Shell: {:?}" - ), - family, os, arch, current_dir, main_separator, username, shell - ) - .into_bytes(), - ), - protocol::Response::Version(version) => { - #[derive(Tabled)] - struct EntryRow { - kind: String, - description: String, - } - - let table = Table::new( - version - .capabilities - .into_sorted_vec() - .into_iter() - .map(|cap| EntryRow { - kind: cap.kind, - description: cap.description, - }), - ) - .with(Style::ascii()) - .with(Modify::new(Rows::new(..)).with(Alignment::left())) - .to_string() - .into_bytes(); - - Output::StdoutLine(table) - } - } -}