summaryrefslogtreecommitdiff
path: root/utils
diff options
context:
space:
mode:
author魏曹先生 <1992414357@qq.com>2026-03-12 15:54:59 +0800
committer魏曹先生 <1992414357@qq.com>2026-03-12 15:54:59 +0800
commit9d812580557cdc343378816cd65678b8aa75d944 (patch)
treeb1a3397e38d9620a487aed409fc94310f101bc27 /utils
parent0a95bae451c1847f4f0b9601e60959f4e8e6b669 (diff)
Add lang field to command context and reorganize utils modules
Diffstat (limited to 'utils')
-rw-r--r--utils/src/display.rs1
-rw-r--r--utils/src/display/colorful.rs150
-rw-r--r--utils/src/display/pager.rs37
-rw-r--r--utils/src/env.rs3
-rw-r--r--utils/src/env/editor.rs19
-rw-r--r--utils/src/env/locales.rs28
-rw-r--r--utils/src/env/pager.rs15
-rw-r--r--utils/src/input.rs2
-rw-r--r--utils/src/input/confirm.rs52
-rw-r--r--utils/src/input/editor.rs66
-rw-r--r--utils/src/lib.rs2
11 files changed, 359 insertions, 16 deletions
diff --git a/utils/src/display.rs b/utils/src/display.rs
index a9c48e8..16c94a9 100644
--- a/utils/src/display.rs
+++ b/utils/src/display.rs
@@ -1,2 +1,3 @@
pub mod colorful;
+pub mod pager;
pub mod table;
diff --git a/utils/src/display/colorful.rs b/utils/src/display/colorful.rs
index 7daa6f2..40f83bf 100644
--- a/utils/src/display/colorful.rs
+++ b/utils/src/display/colorful.rs
@@ -3,19 +3,19 @@ use std::collections::VecDeque;
use crossterm::style::Stylize;
/// Trait for adding markdown formatting to strings
-pub trait Colorful {
- fn colorful(&self) -> String;
+pub trait Markdown {
+ fn markdown(&self) -> String;
}
-impl Colorful for &str {
- fn colorful(&self) -> String {
- colorful(self)
+impl Markdown for &str {
+ fn markdown(&self) -> String {
+ markdown(self)
}
}
-impl Colorful for String {
- fn colorful(&self) -> String {
- colorful(self)
+impl Markdown for String {
+ fn markdown(&self) -> String {
+ markdown(self)
}
}
@@ -29,6 +29,8 @@ impl Colorful for String {
/// - Inline code: `` `text` `` (displayed as green)
/// - Color tags: `[[color_name]]` and `[[/]]` to close color
/// - Escape characters: `\*`, `\<`, `\>`, `` \` ``, `\_` for literal characters
+/// - Headings: `# Heading 1`, `## Heading 2`, up to `###### Heading 6`
+/// - Blockquote: `> text` (displays a gray background marker at the beginning of the line)
///
/// Color tags support the following color names:
/// Color tags support the following color names:
@@ -66,23 +68,139 @@ impl Colorful for String {
///
/// # Examples
/// ```
-/// use testing::fmt::colorful;
-///
-/// let formatted = colorful("Hello **world**!");
+/// # use cli_utils::display::colorful::markdown;
+/// let formatted = markdown("Hello **world**!");
/// println!("{}", formatted);
///
-/// let colored = colorful("[[red]]Red text[[/]] and normal text");
+/// let colored = markdown("[[red]]Red text[[/]] and normal text");
/// println!("{}", colored);
///
-/// let nested = colorful("[[blue]]Blue [[green]]Green[[/]] Blue[[/]] normal");
+/// let nested = markdown("[[blue]]Blue [[green]]Green[[/]] Blue[[/]] normal");
/// println!("{}", nested);
/// ```
-pub fn colorful(text: impl AsRef<str>) -> String {
- let text = text.as_ref().trim();
+pub fn markdown(text: impl AsRef<str>) -> String {
+ let text = text.as_ref();
+ let lines: Vec<&str> = text.lines().collect();
+ let mut result = String::new();
+ let mut content_indent = 0;
+
+ for line in lines {
+ // Don't trim the line initially, we need to check if it starts with #
+ let trimmed_line = line.trim();
+ let mut line_result = String::new();
+
+ // Check if line starts with # for heading
+ // Check if the original line (not trimmed) starts with #
+ if line.trim_start().starts_with('#') {
+ let chars: Vec<char> = line.trim_start().chars().collect();
+ let mut level = 0;
+
+ // Count # characters at the beginning
+ while level < chars.len() && level < 7 && chars[level] == '#' {
+ level += 1;
+ }
+
+ // Cap level at 6
+ let effective_level = if level > 6 { 6 } else { level };
+
+ // Skip # characters and any whitespace after them
+ let mut content_start = level;
+ while content_start < chars.len() && chars[content_start].is_whitespace() {
+ content_start += 1;
+ }
+
+ // Extract heading content
+ let heading_content: String = if content_start < chars.len() {
+ chars[content_start..].iter().collect()
+ } else {
+ String::new()
+ };
+
+ // Process the heading content with formatting
+ let processed_content = process_line(&heading_content);
+
+ // Format heading as white background, black text, bold
+ // ANSI codes: \x1b[1m for bold, \x1b[47m for white background, \x1b[30m for black text
+ let formatted_heading =
+ format!("\x1b[1m\x1b[47m\x1b[30m {} \x1b[0m", processed_content);
+
+ // Add indentation to the heading line itself
+ // Heading indentation = level - 1
+ let heading_indent = if effective_level > 0 {
+ effective_level - 1
+ } else {
+ 0
+ };
+ let indent = " ".repeat(heading_indent);
+ line_result.push_str(&indent);
+ line_result.push_str(&formatted_heading);
+
+ // Update content indent level for subsequent content
+ // Content after heading should be indented by effective_level
+ content_indent = effective_level;
+ } else if !trimmed_line.is_empty() {
+ // Process regular line with existing formatting
+ let processed_line = process_line_with_quote(trimmed_line);
+
+ // Add indentation based on content_indent
+ let indent = " ".repeat(content_indent);
+ line_result.push_str(&indent);
+ line_result.push_str(&processed_line);
+ } else {
+ line_result.push_str(" ");
+ }
+
+ if !line_result.is_empty() {
+ result.push_str(&line_result);
+ result.push('\n');
+ }
+ }
+
+ // Remove trailing newline
+ if result.ends_with('\n') {
+ result.pop();
+ }
+
+ result
+}
+
+// Helper function to process a single line with existing formatting and handle > quotes
+fn process_line_with_quote(line: &str) -> String {
+ let chars: Vec<char> = line.chars().collect();
+
+ // Check if line starts with '>' and not escaped
+ if !chars.is_empty() && chars[0] == '>' {
+ // Check if it's escaped
+ if chars.len() > 1 && chars[1] == '\\' {
+ // It's \>, so treat as normal text starting from position 0
+ return process_line(line);
+ }
+
+ // It's a regular > at the beginning, replace with gray background gray text space
+ let gray_bg_space = "\x1b[48;5;242m\x1b[38;5;242m \x1b[0m";
+ let rest_of_line = if chars.len() > 1 {
+ chars[1..].iter().collect::<String>()
+ } else {
+ String::new()
+ };
+
+ // Process the rest of the line normally
+ let processed_rest = process_line(&rest_of_line);
+
+ // Combine the gray background space with the processed rest
+ format!("{}{}", gray_bg_space, processed_rest)
+ } else {
+ // No > at the beginning, process normally
+ process_line(line)
+ }
+}
+
+// Helper function to process a single line with existing formatting
+fn process_line(line: &str) -> String {
let mut result = String::new();
let mut color_stack: VecDeque<String> = VecDeque::new();
- let chars: Vec<char> = text.chars().collect();
+ let chars: Vec<char> = line.chars().collect();
let mut i = 0;
while i < chars.len() {
diff --git a/utils/src/display/pager.rs b/utils/src/display/pager.rs
new file mode 100644
index 0000000..79c2ccb
--- /dev/null
+++ b/utils/src/display/pager.rs
@@ -0,0 +1,37 @@
+use crate::env::pager::get_default_pager;
+use tokio::{fs, process::Command};
+
+/// 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 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?;
+
+ // Get the default pager
+ let pager_cmd = get_default_pager().await;
+
+ // Try to use the pager
+ let status = Command::new(&pager_cmd).arg(cache_path).status().await;
+
+ match status {
+ Ok(status) if status.success() => Ok(()),
+ _ => {
+ // If pager 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/env.rs b/utils/src/env.rs
new file mode 100644
index 0000000..98aaa6b
--- /dev/null
+++ b/utils/src/env.rs
@@ -0,0 +1,3 @@
+pub mod editor;
+pub mod locales;
+pub mod pager;
diff --git a/utils/src/env/editor.rs b/utils/src/env/editor.rs
new file mode 100644
index 0000000..c7bd446
--- /dev/null
+++ b/utils/src/env/editor.rs
@@ -0,0 +1,19 @@
+/// 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/env/locales.rs b/utils/src/env/locales.rs
new file mode 100644
index 0000000..302c874
--- /dev/null
+++ b/utils/src/env/locales.rs
@@ -0,0 +1,28 @@
+/// 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()
+}
diff --git a/utils/src/env/pager.rs b/utils/src/env/pager.rs
new file mode 100644
index 0000000..3fdb1c3
--- /dev/null
+++ b/utils/src/env/pager.rs
@@ -0,0 +1,15 @@
+/// Gets the default pager based on environment variables.
+///
+/// The function checks the JV_PAGER environment variable
+/// and returns its value if it is set. If the variable is not set,
+/// it returns "less" as the default pager.
+///
+/// # Returns
+/// A String containing the default pager
+pub async fn get_default_pager() -> String {
+ if let Ok(pager) = std::env::var("JV_PAGER") {
+ return pager;
+ }
+
+ "less".to_string()
+}
diff --git a/utils/src/input.rs b/utils/src/input.rs
new file mode 100644
index 0000000..bf60b88
--- /dev/null
+++ b/utils/src/input.rs
@@ -0,0 +1,2 @@
+pub mod confirm;
+pub mod editor;
diff --git a/utils/src/input/confirm.rs b/utils/src/input/confirm.rs
new file mode 100644
index 0000000..91d91a7
--- /dev/null
+++ b/utils/src/input/confirm.rs
@@ -0,0 +1,52 @@
+use tokio::io::{self, AsyncBufReadExt, AsyncWriteExt, BufReader};
+
+/// Confirm the current operation
+/// Waits for user input of 'y' or 'n'
+pub async fn confirm_hint(text: impl Into<String>) -> bool {
+ 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
+}
diff --git a/utils/src/input/editor.rs b/utils/src/input/editor.rs
new file mode 100644
index 0000000..34377fd
--- /dev/null
+++ b/utils/src/input/editor.rs
@@ -0,0 +1,66 @@
+use tokio::{fs, process::Command};
+
+use crate::env::editor::get_default_editor;
+
+/// 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> {
+ input_with_editor_cutsom(
+ default_text,
+ cache_file,
+ comment_char,
+ get_default_editor().await,
+ )
+ .await
+}
+
+pub async fn input_with_editor_cutsom(
+ default_text: impl AsRef<str>,
+ cache_file: impl AsRef<std::path::Path>,
+ comment_char: impl AsRef<str>,
+ editor: String,
+) -> 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?;
+
+ // 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)
+}
diff --git a/utils/src/lib.rs b/utils/src/lib.rs
index ca2be9c..f61179e 100644
--- a/utils/src/lib.rs
+++ b/utils/src/lib.rs
@@ -1,4 +1,6 @@
pub mod display;
+pub mod env;
+pub mod input;
pub mod macros;
pub mod math;