diff options
| author | 魏曹先生 <1992414357@qq.com> | 2026-01-27 06:02:59 +0800 |
|---|---|---|
| committer | 魏曹先生 <1992414357@qq.com> | 2026-01-27 06:02:59 +0800 |
| commit | 4eef9ce364bb660421a96052a3fb126a33b22c63 (patch) | |
| tree | a36947411d83205dc743881cd2a30d8c907d4b57 /utils/src | |
| parent | 243d521fd19af169910506529e737a797e9bc583 (diff) | |
Extract CLI utilities into a separate crate
Diffstat (limited to 'utils/src')
| -rw-r--r-- | utils/src/display.rs | 486 | ||||
| -rw-r--r-- | utils/src/env.rs | 94 | ||||
| -rw-r--r-- | utils/src/fs.rs | 40 | ||||
| -rw-r--r-- | utils/src/globber.rs | 276 | ||||
| -rw-r--r-- | utils/src/input.rs | 139 | ||||
| -rw-r--r-- | utils/src/levenshtein_distance.rs | 34 | ||||
| -rw-r--r-- | utils/src/lib.rs | 9 | ||||
| -rw-r--r-- | utils/src/logger.rs | 87 | ||||
| -rw-r--r-- | utils/src/push_version.rs | 30 | ||||
| -rw-r--r-- | utils/src/socket_addr_helper.rs | 194 |
10 files changed, 1389 insertions, 0 deletions
diff --git a/utils/src/display.rs b/utils/src/display.rs new file mode 100644 index 0000000..835313b --- /dev/null +++ b/utils/src/display.rs @@ -0,0 +1,486 @@ +use colored::*; +use just_enough_vcs::vcs::data::sheet::SheetMappingMetadata; +use std::{ + collections::{BTreeMap, HashMap, VecDeque}, + path::PathBuf, +}; + +pub struct SimpleTable { + items: Vec<String>, + line: Vec<Vec<String>>, + length: Vec<usize>, + padding: usize, +} + +impl SimpleTable { + /// Create a new Table + pub fn new(items: Vec<impl Into<String>>) -> Self { + Self::new_with_padding(items, 2) + } + + /// Create a new Table with padding + pub fn new_with_padding(items: Vec<impl Into<String>>, padding: usize) -> Self { + let items: Vec<String> = items.into_iter().map(|v| v.into()).collect(); + let mut length = Vec::with_capacity(items.len()); + + for item in &items { + length.push(display_width(item)); + } + + SimpleTable { + items, + padding, + line: Vec::new(), + length, + } + } + + /// Push a new row of items to the table + pub fn push_item(&mut self, items: Vec<impl Into<String>>) { + let items: Vec<String> = items.into_iter().map(|v| v.into()).collect(); + + let mut processed_items = Vec::with_capacity(self.items.len()); + + for i in 0..self.items.len() { + if i < items.len() { + processed_items.push(items[i].clone()); + } else { + processed_items.push(String::new()); + } + } + + for (i, d) in processed_items.iter().enumerate() { + let d_len = display_width(d); + if d_len > self.length[i] { + self.length[i] = d_len; + } + } + + self.line.push(processed_items); + } + + /// Insert a new row of items at the specified index + pub fn insert_item(&mut self, index: usize, items: Vec<impl Into<String>>) { + let items: Vec<String> = items.into_iter().map(|v| v.into()).collect(); + + let mut processed_items = Vec::with_capacity(self.items.len()); + + for i in 0..self.items.len() { + if i < items.len() { + processed_items.push(items[i].clone()); + } else { + processed_items.push(String::new()); + } + } + + for (i, d) in processed_items.iter().enumerate() { + let d_len = display_width(d); + if d_len > self.length[i] { + self.length[i] = d_len; + } + } + + self.line.insert(index, processed_items); + } + + /// Get the current maximum column widths + fn get_column_widths(&self) -> &[usize] { + &self.length + } +} + +impl std::fmt::Display for SimpleTable { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let column_widths = self.get_column_widths(); + + // Build the header row + let header: Vec<String> = self + .items + .iter() + .enumerate() + .map(|(i, item)| { + let target_width = column_widths[i] + self.padding; + let current_width = display_width(item); + let space_count = target_width - current_width; + let space = " ".repeat(space_count); + let result = format!("{}{}", item, space); + result + }) + .collect(); + writeln!(f, "{}", header.join(""))?; + + // Build each data row + for row in &self.line { + let formatted_row: Vec<String> = row + .iter() + .enumerate() + .map(|(i, cell)| { + let target_width = column_widths[i] + self.padding; + let current_width = display_width(cell); + let space_count = target_width - current_width; + let spaces = " ".repeat(space_count); + let result = format!("{}{}", cell, spaces); + result + }) + .collect(); + writeln!(f, "{}", formatted_row.join(""))?; + } + + Ok(()) + } +} + +pub fn display_width(s: &str) -> usize { + // Filter out ANSI escape sequences before calculating width + let filtered_bytes = strip_ansi_escapes::strip(s); + let filtered_str = match std::str::from_utf8(&filtered_bytes) { + Ok(s) => s, + Err(_) => s, // Fallback to original string if UTF-8 conversion fails + }; + + let mut width = 0; + for c in filtered_str.chars() { + if c.is_ascii() { + width += 1; + } else { + width += 2; + } + } + width +} + +/// Convert byte size to a human-readable string format +/// +/// Automatically selects the appropriate unit (B, KB, MB, GB, TB) based on the byte size +/// and formats it as a string with two decimal places +pub fn size_str(total_size: usize) -> String { + if total_size < 1024 { + format!("{} B", total_size) + } else if total_size < 1024 * 1024 { + format!("{:.2} KB", total_size as f64 / 1024.0) + } else if total_size < 1024 * 1024 * 1024 { + format!("{:.2} MB", total_size as f64 / (1024.0 * 1024.0)) + } else if total_size < 1024 * 1024 * 1024 * 1024 { + format!("{:.2} GB", total_size as f64 / (1024.0 * 1024.0 * 1024.0)) + } else { + format!( + "{:.2} TB", + total_size as f64 / (1024.0 * 1024.0 * 1024.0 * 1024.0) + ) + } +} + +// Convert the Markdown formatted text into a format supported by the command line +pub fn md(text: impl AsRef<str>) -> String { + let text = text.as_ref().trim(); + let mut result = String::new(); + let mut color_stack: VecDeque<String> = VecDeque::new(); + + let mut i = 0; + let chars: Vec<char> = text.chars().collect(); + + while i < chars.len() { + // Check for escape character \ + if chars[i] == '\\' && i + 1 < chars.len() { + let escaped_char = chars[i + 1]; + // Only escape specific characters + if matches!(escaped_char, '*' | '<' | '>' | '`') { + let mut escaped_text = escaped_char.to_string(); + + // Apply current color stack + for color in color_stack.iter().rev() { + escaped_text = apply_color(&escaped_text, color); + } + + result.push_str(&escaped_text); + i += 2; + continue; + } + } + + // Check for color tag start [[color]] + if i + 1 < chars.len() && chars[i] == '[' && chars[i + 1] == '[' { + let mut j = i + 2; + while j < chars.len() + && !(chars[j] == ']' && j + 1 < chars.len() && chars[j + 1] == ']') + { + j += 1; + } + + if j + 1 < chars.len() { + let tag_content: String = chars[i + 2..j].iter().collect(); + + // Check if it's a closing tag [[/]] + if tag_content == "/" { + color_stack.pop_back(); + i = j + 2; + continue; + } + + // It's a color tag + color_stack.push_back(tag_content.clone()); + i = j + 2; + continue; + } + } + + // Check for bold **text** + if i + 1 < chars.len() && chars[i] == '*' && chars[i + 1] == '*' { + let mut j = i + 2; + while j + 1 < chars.len() && !(chars[j] == '*' && chars[j + 1] == '*') { + j += 1; + } + + if j + 1 < chars.len() { + let bold_text: String = chars[i + 2..j].iter().collect(); + let mut formatted_text = bold_text.bold().to_string(); + + // Apply current color stack + for color in color_stack.iter().rev() { + formatted_text = apply_color(&formatted_text, color); + } + + result.push_str(&formatted_text); + i = j + 2; + continue; + } + } + + // Check for italic *text* + if chars[i] == '*' { + let mut j = i + 1; + while j < chars.len() && chars[j] != '*' { + j += 1; + } + + if j < chars.len() { + let italic_text: String = chars[i + 1..j].iter().collect(); + let mut formatted_text = italic_text.italic().to_string(); + + // Apply current color stack + for color in color_stack.iter().rev() { + formatted_text = apply_color(&formatted_text, color); + } + + result.push_str(&formatted_text); + i = j + 1; + continue; + } + } + + // Check for angle-bracketed content <text> + if chars[i] == '<' { + let mut j = i + 1; + while j < chars.len() && chars[j] != '>' { + j += 1; + } + + if j < chars.len() { + // Include the angle brackets in the output + let angle_text: String = chars[i..=j].iter().collect(); + let mut formatted_text = angle_text.cyan().to_string(); + + // Apply current color stack + for color in color_stack.iter().rev() { + formatted_text = apply_color(&formatted_text, color); + } + + result.push_str(&formatted_text); + i = j + 1; + continue; + } + } + + // Check for inline code `text` + if chars[i] == '`' { + let mut j = i + 1; + while j < chars.len() && chars[j] != '`' { + j += 1; + } + + if j < chars.len() { + // Include the backticks in the output + let code_text: String = chars[i..=j].iter().collect(); + let mut formatted_text = code_text.green().to_string(); + + // Apply current color stack + for color in color_stack.iter().rev() { + formatted_text = apply_color(&formatted_text, color); + } + + result.push_str(&formatted_text); + i = j + 1; + continue; + } + } + + // Regular character + let mut current_char = chars[i].to_string(); + + // Apply current color stack + for color in color_stack.iter().rev() { + current_char = apply_color(¤t_char, color); + } + + result.push_str(¤t_char); + i += 1; + } + + result +} + +// Helper function to apply color to text +fn apply_color(text: &str, color_name: &str) -> String { + match color_name { + // Normal colors + "black" => text.black().to_string(), + "red" => text.red().to_string(), + "green" => text.green().to_string(), + "yellow" => text.yellow().to_string(), + "blue" => text.blue().to_string(), + "magenta" => text.magenta().to_string(), + "cyan" => text.cyan().to_string(), + "white" => text.white().to_string(), + "bright_black" => text.bright_black().to_string(), + "bright_red" => text.bright_red().to_string(), + "bright_green" => text.bright_green().to_string(), + "bright_yellow" => text.bright_yellow().to_string(), + "bright_blue" => text.bright_blue().to_string(), + "bright_magenta" => text.bright_magenta().to_string(), + "bright_cyan" => text.bright_cyan().to_string(), + "bright_white" => text.bright_white().to_string(), + + // Short aliases for bright colors + "b_black" => text.bright_black().to_string(), + "b_red" => text.bright_red().to_string(), + "b_green" => text.bright_green().to_string(), + "b_yellow" => text.bright_yellow().to_string(), + "b_blue" => text.bright_blue().to_string(), + "b_magenta" => text.bright_magenta().to_string(), + "b_cyan" => text.bright_cyan().to_string(), + "b_white" => text.bright_white().to_string(), + + // Gray colors using truecolor + "gray" | "grey" => text.truecolor(128, 128, 128).to_string(), + "bright_gray" | "bright_grey" => text.truecolor(192, 192, 192).to_string(), + "b_gray" | "b_grey" => text.truecolor(192, 192, 192).to_string(), + + // Default to white if color not recognized + _ => text.to_string(), + } +} + +/// Render a HashMap of PathBuf to SheetMappingMetadata as a tree string. +pub fn render_share_path_tree(paths: &HashMap<PathBuf, SheetMappingMetadata>) -> String { + if paths.is_empty() { + return String::new(); + } + + // Collect all path components into a tree structure + let mut root = TreeNode::new("".to_string()); + + for (path, metadata) in paths { + let mut current = &mut root; + let components: Vec<String> = path + .components() + .filter_map(|comp| match comp { + std::path::Component::Normal(s) => s.to_str().map(|s| s.to_string()), + _ => None, + }) + .collect(); + + for (i, comp) in components.iter().enumerate() { + let is_leaf = i == components.len() - 1; + let child = current + .children + .entry(comp.clone()) + .or_insert_with(|| TreeNode::new(comp.clone())); + + // If this is the leaf node, store the metadata + if is_leaf { + child.metadata = Some((metadata.id.clone(), metadata.version.clone())); + } + + current = child; + } + } + + // Convert tree to string representation + let mut result = String::new(); + let is_root = true; + let prefix = String::new(); + let last_stack = vec![true]; // Root is always "last" + + add_tree_node_to_string(&root, &mut result, is_root, &prefix, &last_stack); + + result +} + +/// Internal tree node structure for building the path tree +#[derive(Debug)] +struct TreeNode { + name: String, + children: BTreeMap<String, TreeNode>, // Use BTreeMap for sorted output + metadata: Option<(String, String)>, // Store (id, version) for leaf nodes +} + +impl TreeNode { + fn new(name: String) -> Self { + Self { + name, + children: BTreeMap::new(), + metadata: None, + } + } +} + +/// Recursively add tree node to string representation +fn add_tree_node_to_string( + node: &TreeNode, + result: &mut String, + is_root: bool, + prefix: &str, + last_stack: &[bool], +) { + if !is_root { + // Add the tree prefix for this node + for &is_last in &last_stack[1..] { + if is_last { + result.push_str(" "); + } else { + result.push_str("│ "); + } + } + + // Add the connector for this node + if let Some(&is_last) = last_stack.last() { + if is_last { + result.push_str("└── "); + } else { + result.push_str("├── "); + } + } + + // Add node name + result.push_str(&node.name); + + // Add metadata for leaf nodes + if let Some((id, version)) = &node.metadata { + // Truncate id to first 11 characters + let truncated_id = if id.len() > 11 { &id[..11] } else { id }; + result.push_str(&format!(" [{}|{}]", truncated_id, version)); + } + + result.push('\n'); + } + + // Process children + let child_count = node.children.len(); + for (i, (_, child)) in node.children.iter().enumerate() { + let is_last_child = i == child_count - 1; + let mut new_last_stack = last_stack.to_vec(); + new_last_stack.push(is_last_child); + + add_tree_node_to_string(child, result, false, prefix, &new_last_stack); + } +} diff --git a/utils/src/env.rs b/utils/src/env.rs new file mode 100644 index 0000000..81dfbd7 --- /dev/null +++ b/utils/src/env.rs @@ -0,0 +1,94 @@ +/// Returns the current locale string based on environment variables. +/// +/// The function checks for locale settings in the following order: +/// 1. JV_LANG environment variable +/// 2. APP_LANG environment variable +/// 3. LANG environment variable (extracts base language before dot and replaces underscores with hyphens) +/// 4. Defaults to "en" if no locale environment variables are found +/// +/// # Returns +/// A String containing the detected locale code +pub fn current_locales() -> String { + if let Ok(lang) = std::env::var("JV_LANG") { + return lang; + } + + if let Ok(lang) = std::env::var("APP_LANG") { + return lang; + } + + if let Ok(lang) = std::env::var("LANG") { + if let Some(base_lang) = lang.split('.').next() { + return base_lang.replace('_', "-"); + } + return lang; + } + + "en".to_string() +} + +/// Checks if auto update is enabled based on environment variables. +/// +/// The function checks the JV_AUTO_UPDATE environment variable and compares +/// its value (after trimming and converting to lowercase) against known +/// positive and negative values. +/// +/// # Returns +/// `true` if the value matches "yes", "y", or "true" +/// `false` if the value matches "no", "n", or "false", or if the variable is not set +pub fn enable_auto_update() -> bool { + if let Ok(auto_update) = std::env::var("JV_AUTO_UPDATE") { + let normalized = auto_update.trim().to_lowercase(); + match normalized.as_str() { + "yes" | "y" | "true" => return true, + "no" | "n" | "false" => return false, + _ => {} + } + } + false +} + +/// Gets the auto update expiration time based on environment variables. +/// +/// The function checks the JV_OUTDATED_MINUTES environment variable. +/// Requires JV_AUTO_UPDATE to be enabled. +/// Next time the `jv` command is used, if the content is outdated, `jv update` will be automatically executed. +/// +/// # Returns +/// - When the set number is < 0, timeout-based update is disabled +/// - When the set number = 0, update runs every time (not recommended) +/// - When the set number > 0, update according to the specified time +/// - If not set or conversion error occurs, the default is -1 +pub fn auto_update_outdate() -> i64 { + if !enable_auto_update() { + return -1; + } + + match std::env::var("JV_OUTDATED_MINUTES") { + Ok(value) => match value.trim().parse::<i64>() { + Ok(num) => num, + Err(_) => -1, + }, + Err(_) => -1, + } +} + +/// Gets the default text editor based on environment variables. +/// +/// The function checks the JV_TEXT_EDITOR and EDITOR environment variables +/// and returns their values if they are set. If neither variable is set, +/// it returns "jvii" as the default editor. +/// +/// # Returns +/// A String containing the default text editor +pub async fn get_default_editor() -> String { + if let Ok(editor) = std::env::var("JV_TEXT_EDITOR") { + return editor; + } + + if let Ok(editor) = std::env::var("EDITOR") { + return editor; + } + + "jvii".to_string() +} diff --git a/utils/src/fs.rs b/utils/src/fs.rs new file mode 100644 index 0000000..0050cf1 --- /dev/null +++ b/utils/src/fs.rs @@ -0,0 +1,40 @@ +pub async fn move_across_partitions( + source_path: impl AsRef<std::path::Path>, + dest_path: impl AsRef<std::path::Path>, +) -> Result<(), std::io::Error> { + let source_path = source_path.as_ref(); + let dest_path = dest_path.as_ref(); + if !source_path.exists() { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "Source file does not exist", + )); + } + + if let Ok(()) = std::fs::rename(source_path, dest_path) { + return Ok(()); + } + + std::fs::copy(source_path, dest_path)?; + std::fs::remove_file(source_path)?; + + Ok(()) +} + +pub async fn copy_across_partitions( + source_path: impl AsRef<std::path::Path>, + dest_path: impl AsRef<std::path::Path>, +) -> Result<(), std::io::Error> { + let source_path = source_path.as_ref(); + let dest_path = dest_path.as_ref(); + if !source_path.exists() { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "Source file does not exist", + )); + } + + std::fs::copy(source_path, dest_path)?; + + Ok(()) +} diff --git a/utils/src/globber.rs b/utils/src/globber.rs new file mode 100644 index 0000000..e20caf9 --- /dev/null +++ b/utils/src/globber.rs @@ -0,0 +1,276 @@ +use std::{io::Error, path::PathBuf, str::FromStr}; + +use just_enough_vcs::utils::string_proc::format_path::format_path_str; + +use crate::globber::constants::{SPLIT_STR, get_base_dir_current}; + +pub struct Globber { + pattern: String, + base: PathBuf, + names: Vec<String>, +} + +#[allow(dead_code)] +impl Globber { + pub fn new(pattern: String, base: PathBuf) -> Self { + Self { + pattern, + base, + names: Vec::new(), + } + } + + pub fn names(&self) -> Vec<&String> { + self.names.iter().collect() + } + + pub fn base(&self) -> &PathBuf { + &self.base + } + + pub fn into_names(self) -> Vec<String> { + self.names + } + + pub fn paths(&self) -> Vec<PathBuf> { + self.names.iter().map(|n| self.base.join(n)).collect() + } + + pub fn glob<F>(mut self, get_names: F) -> Result<Self, std::io::Error> + where + F: Fn(PathBuf) -> Vec<GlobItem>, + { + let full_path = format!("{}{}{}", self.base.display(), SPLIT_STR, self.pattern); + + let (path, pattern) = if let Some(last_split) = full_path.rfind(SPLIT_STR) { + let (path_part, pattern_part) = full_path.split_at(last_split); + let mut path = path_part.to_string(); + if !path.ends_with(SPLIT_STR) { + path.push_str(SPLIT_STR); + } + ( + format_path_str(path)?, + pattern_part[SPLIT_STR.len()..].to_string(), + ) + } else { + (String::default(), full_path) + }; + + self.base = match PathBuf::from_str(&path) { + Ok(r) => r, + Err(_) => { + return Err(Error::new( + std::io::ErrorKind::InvalidInput, + format!("Invalid path: \"{}\"", &path), + )); + } + }; + + let pattern = if pattern.is_empty() { + "*".to_string() + } else if pattern == "." { + "*".to_string() + } else if pattern.ends_with(SPLIT_STR) { + format!("{}*", pattern) + } else { + pattern + }; + + if !pattern.contains('*') && !pattern.contains('?') { + self.names = vec![pattern]; + return Ok(self); + } + + let mut collected = Vec::new(); + + collect_files(&path.into(), "./".to_string(), &mut collected, &get_names); + fn collect_files<F>( + base: &PathBuf, + current: String, + file_names: &mut Vec<String>, + get_names: &F, + ) where + F: Fn(PathBuf) -> Vec<GlobItem>, + { + let current_path = if current.is_empty() { + base.clone() + } else { + base.join(¤t) + }; + + let items = get_names(current_path); + for item in items { + match item { + GlobItem::File(file_name) => { + let relative_path = { + format_path_str(format!("{}{}{}", current, SPLIT_STR, file_name)) + .unwrap_or_default() + }; + file_names.push(relative_path) + } + GlobItem::Directory(dir_name) => { + let new_current = { + format_path_str(format!("{}{}{}", current, SPLIT_STR, dir_name)) + .unwrap_or_default() + }; + collect_files(base, new_current, file_names, get_names); + } + } + } + } + + self.names = collected + .iter() + .filter_map(|name| match_pattern(name, &pattern)) + .collect(); + + Ok(self) + } +} + +fn match_pattern(name: &str, pattern: &str) -> Option<String> { + if pattern.is_empty() { + return None; + } + + let name_chars: Vec<char> = name.chars().collect(); + let pattern_chars: Vec<char> = pattern.chars().collect(); + + let mut name_idx = 0; + let mut pattern_idx = 0; + let mut star_idx = -1; + let mut match_idx = -1; + + while name_idx < name_chars.len() { + if pattern_idx < pattern_chars.len() + && (pattern_chars[pattern_idx] == '?' + || pattern_chars[pattern_idx] == name_chars[name_idx]) + { + name_idx += 1; + pattern_idx += 1; + } else if pattern_idx < pattern_chars.len() && pattern_chars[pattern_idx] == '*' { + star_idx = pattern_idx as i32; + match_idx = name_idx as i32; + pattern_idx += 1; + } else if star_idx != -1 { + pattern_idx = (star_idx + 1) as usize; + match_idx += 1; + name_idx = match_idx as usize; + } else { + return None; + } + } + + while pattern_idx < pattern_chars.len() && pattern_chars[pattern_idx] == '*' { + pattern_idx += 1; + } + + if pattern_idx == pattern_chars.len() { + Some(name.to_string()) + } else { + None + } +} + +impl<T: AsRef<str>> From<T> for Globber { + fn from(pattern: T) -> Self { + let (base_dir, pattern) = get_base_dir_current(pattern.as_ref().to_string()); + Self::new(pattern, base_dir) + } +} + +#[derive(Debug, Clone, Hash)] +pub enum GlobItem { + File(String), + Directory(String), +} + +impl PartialEq for GlobItem { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (GlobItem::File(a), GlobItem::File(b)) => a == b, + (GlobItem::Directory(a), GlobItem::Directory(b)) => a == b, + _ => false, + } + } +} + +impl std::fmt::Display for GlobItem { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + GlobItem::File(name) => write!(f, "{}", name), + GlobItem::Directory(name) => write!(f, "{}", name), + } + } +} + +impl Eq for GlobItem {} + +pub mod constants { + use std::{env::current_dir, path::PathBuf}; + + #[cfg(unix)] + pub(crate) const CURRENT_DIR_PREFIX: &str = "./"; + #[cfg(windows)] + pub(crate) const CURRENT_DIR_PREFIX: &str = ".\\"; + + #[cfg(unix)] + pub(crate) const USER_DIR_PREFIX: &str = "~"; + #[cfg(windows)] + pub(crate) const USER_DIR_PREFIX: &str = "~\\"; + + #[cfg(unix)] + pub(crate) const ROOT_DIR_PREFIX: &str = "/"; + #[cfg(windows)] + pub(crate) const ROOT_DIR_PREFIX: &str = "\\"; + + #[cfg(unix)] + pub(crate) const SPLIT_STR: &str = "/"; + #[cfg(windows)] + pub(crate) const SPLIT_STR: &str = "\\"; + + pub fn get_base_dir_current(input: String) -> (PathBuf, String) { + get_base_dir(input, current_dir().unwrap_or_default()) + } + + pub fn get_base_dir(input: String, current_dir: PathBuf) -> (PathBuf, String) { + if let Some(remaining) = input.strip_prefix(CURRENT_DIR_PREFIX) { + (current_dir, remaining.to_string()) + } else if let Some(remaining) = input.strip_prefix(USER_DIR_PREFIX) { + (dirs::home_dir().unwrap_or_default(), remaining.to_string()) + } else if let Some(remaining) = input.strip_prefix(ROOT_DIR_PREFIX) { + { + #[cfg(unix)] + { + (PathBuf::from(ROOT_DIR_PREFIX), remaining.to_string()) + } + #[cfg(windows)] + { + let current_drive = current_dir + .components() + .find_map(|comp| { + if let std::path::Component::Prefix(prefix_component) = comp { + Some(prefix_component) + } else { + None + } + }) + .and_then(|prefix_component| match prefix_component.kind() { + std::path::Prefix::Disk(drive_letter) + | std::path::Prefix::VerbatimDisk(drive_letter) => { + Some((drive_letter as char).to_string()) + } + _ => None, + }) + .unwrap_or_else(|| "C".to_string()); + ( + PathBuf::from(format!("{}:{}", current_drive, ROOT_DIR_PREFIX)), + remaining.to_string(), + ) + } + } + } else { + (current_dir, input) + } + } +} diff --git a/utils/src/input.rs b/utils/src/input.rs new file mode 100644 index 0000000..80ea569 --- /dev/null +++ b/utils/src/input.rs @@ -0,0 +1,139 @@ +use tokio::{fs, process::Command}; + +use crate::env::get_default_editor; + +/// Confirm the current operation +/// Waits for user input of 'y' or 'n' +pub async fn confirm_hint(text: impl Into<String>) -> bool { + use tokio::io::{self, AsyncBufReadExt, AsyncWriteExt, BufReader}; + + let prompt = text.into().trim().to_string(); + + let mut stdout = io::stdout(); + let mut stdin = BufReader::new(io::stdin()); + + stdout + .write_all(prompt.as_bytes()) + .await + .expect("Failed to write prompt"); + stdout.flush().await.expect("Failed to flush stdout"); + + let mut input = String::new(); + stdin + .read_line(&mut input) + .await + .expect("Failed to read input"); + + input.trim().eq_ignore_ascii_case("y") +} + +/// Confirm the current operation, or execute a closure if rejected +/// Waits for user input of 'y' or 'n' +/// If 'n' is entered, executes the provided closure and returns false +pub async fn confirm_hint_or<F>(text: impl Into<String>, on_reject: F) -> bool +where + F: FnOnce(), +{ + let confirmed = confirm_hint(text).await; + if !confirmed { + on_reject(); + } + confirmed +} + +/// Confirm the current operation, and execute a closure if confirmed +/// Waits for user input of 'y' or 'n' +/// If 'y' is entered, executes the provided closure and returns true +pub async fn confirm_hint_then<F>(text: impl Into<String>, on_confirm: F) -> bool +where + F: FnOnce(), +{ + let confirmed = confirm_hint(text).await; + if confirmed { + on_confirm(); + } + confirmed +} + +/// Input text using the system editor +/// Opens the system editor (from EDITOR environment variable) with default text in a cache file, +/// then reads back the modified content after the editor closes, removing comment lines +pub async fn input_with_editor( + default_text: impl AsRef<str>, + cache_file: impl AsRef<std::path::Path>, + comment_char: impl AsRef<str>, +) -> Result<String, std::io::Error> { + let cache_path = cache_file.as_ref(); + let default_content = default_text.as_ref(); + let comment_prefix = comment_char.as_ref(); + + // Write default text to cache file + fs::write(cache_path, default_content).await?; + + // Get editor from environment variable + let editor = get_default_editor().await; + + // Open editor with cache file + let status = Command::new(editor).arg(cache_path).status().await?; + + if !status.success() { + return Err(std::io::Error::new( + std::io::ErrorKind::Other, + "Editor exited with non-zero status", + )); + } + + // Read the modified content + let content = fs::read_to_string(cache_path).await?; + + // Remove comment lines and trim + let processed_content: String = content + .lines() + .filter_map(|line| { + let trimmed = line.trim(); + if trimmed.starts_with(comment_prefix) { + None + } else { + Some(line) + } + }) + .collect::<Vec<&str>>() + .join("\n"); + + // Delete the cache file + let _ = fs::remove_file(cache_path).await; + + Ok(processed_content) +} + +/// Show text using the system pager (less) +/// Opens the system pager (less) with the given text content written to the specified file +/// If less is not found, directly outputs the content to stdout +pub async fn show_in_pager( + content: impl AsRef<str>, + cache_file: impl AsRef<std::path::Path>, +) -> Result<(), std::io::Error> { + let content_str = content.as_ref(); + let cache_path = cache_file.as_ref(); + + // Write content to cache file + fs::write(cache_path, content_str).await?; + + // Try to use less first + let status = Command::new("less").arg(cache_path).status().await; + + match status { + Ok(status) if status.success() => Ok(()), + _ => { + // If less failed, output directly to stdout + use tokio::io::{self, AsyncWriteExt}; + let mut stdout = io::stdout(); + stdout + .write_all(content_str.as_bytes()) + .await + .expect("Failed to write content"); + stdout.flush().await.expect("Failed to flush stdout"); + Ok(()) + } + } +} diff --git a/utils/src/levenshtein_distance.rs b/utils/src/levenshtein_distance.rs new file mode 100644 index 0000000..6bdb7e7 --- /dev/null +++ b/utils/src/levenshtein_distance.rs @@ -0,0 +1,34 @@ +use std::cmp::min; + +pub fn levenshtein_distance(a: &str, b: &str) -> usize { + let a_chars: Vec<char> = a.chars().collect(); + let b_chars: Vec<char> = b.chars().collect(); + let a_len = a_chars.len(); + let b_len = b_chars.len(); + + if a_len == 0 { + return b_len; + } + if b_len == 0 { + return a_len; + } + + let mut dp = vec![vec![0; b_len + 1]; a_len + 1]; + + for (i, row) in dp.iter_mut().enumerate() { + row[0] = i; + } + + for (j, cell) in dp[0].iter_mut().enumerate() { + *cell = j; + } + + for (i, a_char) in a_chars.iter().enumerate() { + for (j, b_char) in b_chars.iter().enumerate() { + let cost = if a_char == b_char { 0 } else { 1 }; + dp[i + 1][j + 1] = min(dp[i][j + 1] + 1, min(dp[i + 1][j] + 1, dp[i][j] + cost)); + } + } + + dp[a_len][b_len] +} diff --git a/utils/src/lib.rs b/utils/src/lib.rs new file mode 100644 index 0000000..682c679 --- /dev/null +++ b/utils/src/lib.rs @@ -0,0 +1,9 @@ +pub mod display; +pub mod env; +pub mod fs; +pub mod globber; +pub mod input; +pub mod levenshtein_distance; +pub mod logger; +pub mod push_version; +pub mod socket_addr_helper; diff --git a/utils/src/logger.rs b/utils/src/logger.rs new file mode 100644 index 0000000..7dd4f62 --- /dev/null +++ b/utils/src/logger.rs @@ -0,0 +1,87 @@ +use std::path::Path; + +use colored::Colorize; +use env_logger::{Builder, Target}; +use just_enough_vcs::{ + utils::string_proc::format_path::format_path, vcs::data::vault::vault_config::LoggerLevel, +}; +use log::{Level, LevelFilter}; + +pub fn build_env_logger(log_path: impl AsRef<Path>, logger_level: LoggerLevel) { + use std::io::{self, Write}; + + struct MultiWriter<A, B> { + a: A, + b: B, + } + + impl<A: Write, B: Write> MultiWriter<A, B> { + fn new(a: A, b: B) -> Self { + Self { a, b } + } + } + + impl<A: Write, B: Write> Write for MultiWriter<A, B> { + fn write(&mut self, buf: &[u8]) -> io::Result<usize> { + let _ = self.a.write(buf); + self.b.write(buf) + } + + fn flush(&mut self) -> io::Result<()> { + let _ = self.a.flush(); + self.b.flush() + } + } + + let log_path = { + let path = log_path.as_ref(); + let Ok(path) = format_path(path) else { + eprintln!( + "Build logger failed: {} is not a vaild path.", + path.display() + ); + return; + }; + path + }; + + let mut builder = Builder::new(); + + let log_format = |buf: &mut env_logger::fmt::Formatter, record: &log::Record| { + let now = chrono::Local::now(); + + let level_style = match record.level() { + Level::Error => record.args().to_string().red().bold(), + Level::Warn => record.args().to_string().yellow().bold(), + Level::Info => record.args().to_string().white(), + Level::Debug => record.args().to_string().white(), + Level::Trace => record.args().to_string().cyan(), + }; + + writeln!( + buf, + "{} {}", + now.format("%H:%M:%S") + .to_string() + .truecolor(105, 105, 105) + .bold(), + level_style + ) + }; + + let log_file = std::fs::File::create(log_path).expect("Failed to create log file"); + let combined_target = Target::Pipe(Box::new(MultiWriter::new(std::io::stdout(), log_file))); + + let level = match logger_level { + LoggerLevel::Debug => LevelFilter::Debug, + LoggerLevel::Trace => LevelFilter::Trace, + LoggerLevel::Info => LevelFilter::Info, + }; + + builder + .format(log_format) + .filter(None, level.clone()) + .filter_module("just_enough_vcs", level) + .target(combined_target) + .init(); +} diff --git a/utils/src/push_version.rs b/utils/src/push_version.rs new file mode 100644 index 0000000..6da9039 --- /dev/null +++ b/utils/src/push_version.rs @@ -0,0 +1,30 @@ +pub fn push_version(current_version: impl Into<String>) -> Option<String> { + let version_str = current_version.into(); + let parts: Vec<&str> = version_str.split('.').collect(); + + if parts.len() != 3 { + return None; + } + + let major: Result<u32, _> = parts[0].parse(); + let minor: Result<u32, _> = parts[1].parse(); + let patch: Result<u32, _> = parts[2].parse(); + + if let (Ok(mut major), Ok(mut minor), Ok(mut patch)) = (major, minor, patch) { + patch += 1; + + if patch > 99 { + patch = 0; + minor += 1; + + if minor > 99 { + minor = 0; + major += 1; + } + } + + Some(format!("{}.{}.{}", major, minor, patch)) + } else { + None + } +} diff --git a/utils/src/socket_addr_helper.rs b/utils/src/socket_addr_helper.rs new file mode 100644 index 0000000..29ccd9f --- /dev/null +++ b/utils/src/socket_addr_helper.rs @@ -0,0 +1,194 @@ +use std::net::SocketAddr; +use tokio::net::lookup_host; + +/// Helper function to parse a string into a SocketAddr with optional default port +pub async fn get_socket_addr( + address_str: impl AsRef<str>, + default_port: u16, +) -> Result<SocketAddr, std::io::Error> { + let address = address_str.as_ref().trim(); + + // Return error if input is empty after trimming + if address.is_empty() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Empty address string", + )); + } + + // Check if the address contains a port + if let Some((host, port_str)) = parse_host_and_port(address) { + let port = port_str.parse::<u16>().map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("Invalid port number '{}': {}", port_str, e), + ) + })?; + + return resolve_to_socket_addr(host, port).await; + } + + // No port specified, use default port + resolve_to_socket_addr(address, default_port).await +} + +/// Parse host and port from address string +fn parse_host_and_port(address: &str) -> Option<(&str, &str)> { + if address.starts_with('[') + && let Some(close_bracket) = address.find(']') + && close_bracket + 1 < address.len() + && address.as_bytes()[close_bracket + 1] == b':' + { + let host = &address[1..close_bracket]; + let port = &address[close_bracket + 2..]; + return Some((host, port)); + } + + // Handle IPv4 addresses and hostnames with ports + if let Some(colon_pos) = address.rfind(':') { + // Check if this is not part of an IPv6 address without brackets + if !address.contains('[') && !address.contains(']') { + let host = &address[..colon_pos]; + let port = &address[colon_pos + 1..]; + + // Basic validation to avoid false positives + if !host.is_empty() && !port.is_empty() && port.chars().all(|c| c.is_ascii_digit()) { + return Some((host, port)); + } + } + } + + None +} + +/// Resolve host to SocketAddr, handling both IP addresses and domain names +async fn resolve_to_socket_addr(host: &str, port: u16) -> Result<SocketAddr, std::io::Error> { + // First try to parse as IP address (IPv4 or IPv6) + if let Ok(ip_addr) = host.parse() { + return Ok(SocketAddr::new(ip_addr, port)); + } + + // If it's not a valid IP address, treat it as a domain name and perform DNS lookup + let lookup_addr = format!("{}:{}", host, port); + let mut addrs = lookup_host(&lookup_addr).await?; + + if let Some(addr) = addrs.next() { + Ok(addr) + } else { + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("Could not resolve host '{}'", host), + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_ipv4_with_port() { + let result = get_socket_addr("127.0.0.1:8080", 80).await; + assert!(result.is_ok()); + let addr = result.unwrap(); + assert_eq!(addr.port(), 8080); + assert_eq!(addr.ip().to_string(), "127.0.0.1"); + } + + #[tokio::test] + async fn test_ipv4_without_port() { + let result = get_socket_addr("192.168.1.1", 443).await; + assert!(result.is_ok()); + let addr = result.unwrap(); + assert_eq!(addr.port(), 443); + assert_eq!(addr.ip().to_string(), "192.168.1.1"); + } + + #[tokio::test] + async fn test_ipv6_with_port() { + let result = get_socket_addr("[::1]:8080", 80).await; + assert!(result.is_ok()); + let addr = result.unwrap(); + assert_eq!(addr.port(), 8080); + assert_eq!(addr.ip().to_string(), "::1"); + } + + #[tokio::test] + async fn test_ipv6_without_port() { + let result = get_socket_addr("[::1]", 443).await; + assert!(result.is_ok()); + let addr = result.unwrap(); + assert_eq!(addr.port(), 443); + assert_eq!(addr.ip().to_string(), "::1"); + } + + #[tokio::test] + async fn test_invalid_port() { + let result = get_socket_addr("127.0.0.1:99999", 80).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_empty_string() { + let result = get_socket_addr("", 80).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_whitespace_trimming() { + let result = get_socket_addr(" 127.0.0.1:8080 ", 80).await; + assert!(result.is_ok()); + let addr = result.unwrap(); + assert_eq!(addr.port(), 8080); + } + + #[tokio::test] + async fn test_domain_name_with_port() { + // This test will only pass if localhost resolves + let result = get_socket_addr("localhost:8080", 80).await; + if result.is_ok() { + let addr = result.unwrap(); + assert_eq!(addr.port(), 8080); + // localhost should resolve to 127.0.0.1 or ::1 + assert!(addr.ip().is_loopback()); + } + } + + #[tokio::test] + async fn test_domain_name_without_port() { + // This test will only pass if localhost resolves + let result = get_socket_addr("localhost", 443).await; + if result.is_ok() { + let addr = result.unwrap(); + assert_eq!(addr.port(), 443); + // localhost should resolve to 127.0.0.1 or ::1 + assert!(addr.ip().is_loopback()); + } + } + + #[tokio::test] + async fn test_parse_host_and_port() { + // IPv4 with port + assert_eq!( + parse_host_and_port("192.168.1.1:8080"), + Some(("192.168.1.1", "8080")) + ); + + // IPv6 with port + assert_eq!(parse_host_and_port("[::1]:8080"), Some(("::1", "8080"))); + + // Hostname with port + assert_eq!( + parse_host_and_port("example.com:443"), + Some(("example.com", "443")) + ); + + // No port + assert_eq!(parse_host_and_port("192.168.1.1"), None); + assert_eq!(parse_host_and_port("example.com"), None); + + // Invalid cases + assert_eq!(parse_host_and_port(":"), None); + assert_eq!(parse_host_and_port("192.168.1.1:"), None); + } +} |
