use regex::Regex;
use std::iter::Peekable;
use std::path::Path;
use std::str::Chars;
use unicode_width::UnicodeWidthStr;
pub fn display_width(s: &str) -> usize {
UnicodeWidthStr::width(s)
}
pub fn truncate_string(s: &str, max_width: usize) -> String {
if display_width(s) <= max_width {
return s.to_string();
}
if max_width <= 3 {
return s.chars().take(max_width).collect();
}
let mut result = String::new();
let mut current_width = 0;
for ch in s.chars() {
let char_width = UnicodeWidthStr::width(ch.to_string().as_str());
if current_width + char_width + 3 > max_width {
result.push_str("...");
break;
}
result.push(ch);
current_width += char_width;
}
result
}
pub fn pad_string(s: &str, width: usize, align: Alignment) -> String {
let current_width = display_width(s);
if current_width >= width {
return s.to_string();
}
let padding = width - current_width;
match align {
Alignment::Left => format!("{}{}", s, " ".repeat(padding)),
Alignment::Right => format!("{}{}", " ".repeat(padding), s),
Alignment::Center => {
let left_padding = padding / 2;
let right_padding = padding - left_padding;
format!(
"{}{}{}",
" ".repeat(left_padding),
s,
" ".repeat(right_padding)
)
}
}
}
#[derive(Debug, Clone, Copy)]
pub enum Alignment {
Left,
Right,
Center,
}
pub fn is_text_file(path: &Path) -> bool {
if let Some(ext) = path.extension() {
if let Some(ext_str) = ext.to_str() {
return matches!(
ext_str.to_lowercase().as_str(),
"md" | "markdown"
| "mdown"
| "txt"
| "text"
| "rst"
| "adoc"
| "asciidoc"
| "org"
| "wiki"
| "creole"
| "textile"
| "rdoc"
| "pod"
| "man"
| "1"
| "2"
| "3"
| "4"
| "5"
| "6"
| "7"
| "8"
| "9"
| "py"
| "rs"
| "js"
| "ts"
| "go"
| "c"
| "cpp"
| "h"
| "hpp"
| "java"
| "rb"
| "php"
| "pl"
| "sh"
| "bash"
| "zsh"
| "fish"
| "json"
| "yaml"
| "yml"
| "toml"
| "xml"
| "html"
| "css"
| "sql"
| "r"
| "m"
| "scala"
| "clj"
| "hs"
| "elm"
| "ex"
| "swift"
| "kt"
| "dart"
| "lua"
| "vim"
| "el"
| "lisp"
| "cfg"
| "conf"
| "ini"
| "properties"
| "env"
| "log"
| "diff"
| "patch"
);
}
}
if path.extension().is_none() {
if let Some(filename) = path.file_name() {
if let Some(filename_str) = filename.to_str() {
return matches!(
filename_str.to_uppercase().as_str(),
"README"
| "LICENSE"
| "CHANGELOG"
| "CONTRIBUTING"
| "AUTHORS"
| "COPYING"
| "INSTALL"
| "NEWS"
| "TODO"
| "HISTORY"
| "MAKEFILE"
| "DOCKERFILE"
| "VAGRANTFILE"
);
}
}
}
false
}
pub fn is_markdown_content(content: &str) -> bool {
let lines: Vec<&str> = content.lines().take(20).collect();
let mut markdown_indicators = 0;
for line in &lines {
let trimmed = line.trim();
if trimmed.starts_with('#') && trimmed.len() > 1 && trimmed.chars().nth(1) == Some(' ') {
markdown_indicators += 2;
}
if trimmed.starts_with("- ")
|| trimmed.starts_with("* ")
|| (trimmed.len() > 2
&& trimmed.chars().nth(1) == Some('.')
&& trimmed.chars().nth(2) == Some(' '))
{
markdown_indicators += 1;
}
if trimmed.contains("](") || trimmed.contains("[^") {
markdown_indicators += 1;
}
if trimmed.contains("**")
|| trimmed.contains("__")
|| (trimmed.contains('*') && !trimmed.starts_with('*'))
{
markdown_indicators += 1;
}
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
markdown_indicators += 2;
}
if trimmed.starts_with("> ") {
markdown_indicators += 1;
}
if trimmed == "---" || trimmed == "***" || trimmed == "___" {
markdown_indicators += 1;
}
if trimmed.contains('|') && trimmed.len() > 3 {
markdown_indicators += 1;
}
}
markdown_indicators >= 3
}
pub fn strip_ansi(s: &str) -> String {
let ansi_regex = Regex::new(r"\x1b\[[0-9;]*m").unwrap();
let without_ansi = ansi_regex.replace_all(s, "");
let osc8_start_regex = Regex::new(r"\x1b\]8;;[^\x1b]*\x1b\\").unwrap();
let without_osc8_start = osc8_start_regex.replace_all(&without_ansi, "");
let osc8_end_regex = Regex::new(r"\x1b\]8;;\x1b\\").unwrap();
osc8_end_regex
.replace_all(&without_osc8_start, "")
.to_string()
}
pub fn sanitize_filename(s: &str) -> String {
s.chars()
.map(|c| match c {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
c if c.is_control() => '_',
c => c,
})
.collect()
}
pub fn format_file_size(size: u64) -> String {
const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
let mut size = size as f64;
let mut unit_index = 0;
while size >= 1024.0 && unit_index < UNITS.len() - 1 {
size /= 1024.0;
unit_index += 1;
}
if unit_index == 0 {
format!("{} {}", size as u64, UNITS[unit_index])
} else {
format!("{:.1} {}", size, UNITS[unit_index])
}
}
pub fn extract_title(content: &str) -> Option<String> {
let lines: Vec<&str> = content.lines().collect();
for (i, line) in lines.iter().enumerate().take(10) {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if trimmed.starts_with('#') {
let title = trimmed.trim_start_matches('#').trim();
if !title.is_empty() {
return Some(title.to_string());
}
}
if i + 1 < lines.len() {
let next_line = lines[i + 1];
let next_trimmed = next_line.trim();
if (next_trimmed.chars().all(|c| c == '=') || next_trimmed.chars().all(|c| c == '-'))
&& next_trimmed.len() >= trimmed.len() / 2
{
return Some(trimmed.to_string());
}
}
return Some(trimmed.to_string());
}
None
}
pub fn split_preserving_whitespace(text: &str) -> Vec<(String, bool)> {
let mut result = Vec::new();
let mut current_word = String::new();
let mut in_whitespace = false;
for ch in text.chars() {
if ch.is_whitespace() {
if !in_whitespace && !current_word.is_empty() {
result.push((current_word.clone(), false));
current_word.clear();
}
current_word.push(ch);
in_whitespace = true;
} else {
if in_whitespace && !current_word.is_empty() {
result.push((current_word.clone(), true));
current_word.clear();
}
current_word.push(ch);
in_whitespace = false;
}
}
if !current_word.is_empty() {
result.push((current_word, in_whitespace));
}
result
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum WrapMode {
None,
Character,
Word,
}
pub fn wrap_text(text: &str, width: usize) -> String {
wrap_text_with_mode(text, width, WrapMode::Character)
}
pub fn wrap_text_with_mode(text: &str, width: usize, mode: WrapMode) -> String {
if width == 0 || mode == WrapMode::None {
return text.to_string();
}
let lines: Vec<&str> = text.split('\n').collect();
let mut wrapped_lines = Vec::new();
for line in lines {
if line.trim().is_empty() {
wrapped_lines.push(String::new());
continue;
}
let wrapped = match mode {
WrapMode::None => vec![line.to_string()],
WrapMode::Character => wrap_line_character(line, width),
WrapMode::Word => wrap_line_word(line, width),
};
wrapped_lines.extend(wrapped);
}
wrapped_lines.join("\n")
}
fn consume_escape_sequence(chars: &mut Peekable<Chars<'_>>) -> String {
let mut sequence = String::from('\x1b');
if let Some(&next) = chars.peek() {
match next {
'[' => {
sequence.push(chars.next().unwrap());
while let Some(ch) = chars.next() {
sequence.push(ch);
if ('@'..='~').contains(&ch) {
break;
}
}
}
']' => {
sequence.push(chars.next().unwrap());
while let Some(ch) = chars.next() {
sequence.push(ch);
if ch == '\x07' {
break;
}
if ch == '\x1b' {
if let Some(&following) = chars.peek() {
if following == '\\' {
sequence.push(chars.next().unwrap());
break;
}
}
}
}
}
_ => {
sequence.push(chars.next().unwrap());
}
}
}
sequence
}
fn is_sgr_sequence(sequence: &str) -> bool {
sequence.starts_with("\x1b[") && sequence.ends_with('m')
}
fn is_sgr_reset(sequence: &str) -> bool {
if !is_sgr_sequence(sequence) {
return false;
}
let inner = &sequence[2..sequence.len().saturating_sub(1)];
inner
.split(';')
.any(|param| param.trim().is_empty() || param.trim() == "0")
}
fn wrap_line_character(line: &str, width: usize) -> Vec<String> {
if width == 0 {
return vec![line.to_string()];
}
let clean_line = strip_ansi(line);
if display_width(&clean_line) <= width {
return vec![line.to_string()];
}
let mut result = Vec::new();
let mut current_line = String::new();
let mut current_width = 0;
let mut ansi_stack = String::new();
let mut chars = line.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\x1b' {
let sequence = consume_escape_sequence(&mut chars);
current_line.push_str(&sequence);
if is_sgr_sequence(&sequence) {
if is_sgr_reset(&sequence) {
ansi_stack.clear();
} else {
ansi_stack.push_str(&sequence);
}
}
} else if ch.is_whitespace() {
let char_width = if ch == '\t' { 4 } else { 1 };
if current_width + char_width > width && !current_line.trim().is_empty() {
result.push(current_line.trim_end().to_string());
current_line = ansi_stack.clone(); current_width = 0;
} else {
current_line.push(ch);
current_width += char_width;
}
} else {
let char_width = UnicodeWidthStr::width(ch.to_string().as_str());
if current_width + char_width > width && !current_line.trim().is_empty() {
result.push(current_line);
current_line = ansi_stack.clone();
current_width = 0;
}
current_line.push(ch);
current_width += char_width;
}
}
if !current_line.trim().is_empty() {
result.push(current_line);
}
if result.is_empty() {
result.push(String::new());
}
result
}
fn wrap_line_word(line: &str, width: usize) -> Vec<String> {
if width == 0 {
return vec![line.to_string()];
}
let clean_line = strip_ansi(line);
if display_width(&clean_line) <= width {
return vec![line.to_string()];
}
let mut result = Vec::new();
let mut current_line = String::new();
let mut current_width = 0;
let mut ansi_stack = String::new();
let words = split_line_into_words_with_ansi(line);
for (word, is_whitespace) in words {
let clean_word = strip_ansi(&word);
let word_width = display_width(&clean_word);
if word.contains('\x1b') {
update_ansi_stack(&mut ansi_stack, &word);
}
if is_whitespace {
if current_width + word_width <= width {
current_line.push_str(&word);
current_width += word_width;
} else if !current_line.trim().is_empty() {
result.push(current_line.trim_end().to_string());
current_line = ansi_stack.clone();
current_width = 0;
}
} else {
if current_width + word_width <= width || current_line.trim().is_empty() {
current_line.push_str(&word);
current_width += word_width;
} else {
result.push(current_line.trim_end().to_string());
current_line = format!("{}{}", ansi_stack, word);
current_width = word_width;
}
}
}
if !current_line.trim().is_empty() {
result.push(current_line);
}
if result.is_empty() {
result.push(String::new());
}
result
}
fn split_line_into_words_with_ansi(line: &str) -> Vec<(String, bool)> {
let mut result = Vec::new();
let mut current_word = String::new();
let mut in_whitespace = false;
let mut chars = line.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\x1b' {
let sequence = consume_escape_sequence(&mut chars);
current_word.push_str(&sequence);
} else if ch.is_whitespace() {
if !in_whitespace && !current_word.is_empty() {
result.push((current_word.clone(), false));
current_word.clear();
}
current_word.push(ch);
in_whitespace = true;
} else {
if in_whitespace && !current_word.is_empty() {
result.push((current_word.clone(), true));
current_word.clear();
}
current_word.push(ch);
in_whitespace = false;
}
}
if !current_word.is_empty() {
result.push((current_word, in_whitespace));
}
result
}
fn update_ansi_stack(ansi_stack: &mut String, word: &str) {
let mut chars = word.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\x1b' {
let sequence = consume_escape_sequence(&mut chars);
if is_sgr_sequence(&sequence) {
if is_sgr_reset(&sequence) {
ansi_stack.clear();
} else {
ansi_stack.push_str(&sequence);
}
}
}
}
}
pub fn wrap_text_with_indent(text: &str, width: usize, indent: usize) -> String {
wrap_text_with_indent_and_mode(text, width, indent, WrapMode::Character)
}
pub fn wrap_text_with_indent_and_mode(
text: &str,
width: usize,
indent: usize,
mode: WrapMode,
) -> String {
if width <= indent || mode == WrapMode::None {
return text.to_string();
}
let effective_width = width - indent;
let wrapped = wrap_text_with_mode(text, effective_width, mode);
let indent_str = " ".repeat(indent);
wrapped
.lines()
.map(|line| {
if line.trim().is_empty() {
String::new()
} else {
format!("{}{}", indent_str, line)
}
})
.collect::<Vec<_>>()
.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_display_width() {
assert_eq!(display_width("hello"), 5);
assert_eq!(display_width("héllo"), 5);
assert_eq!(display_width("ä½ å¥½"), 4); }
#[test]
fn test_truncate_string() {
assert_eq!(truncate_string("hello world", 20), "hello world");
assert_eq!(truncate_string("hello world", 8), "hello...");
assert_eq!(truncate_string("hello", 3), "hel");
}
#[test]
fn test_pad_string() {
assert_eq!(pad_string("hello", 10, Alignment::Left), "hello ");
assert_eq!(pad_string("hello", 10, Alignment::Right), " hello");
assert_eq!(pad_string("hello", 10, Alignment::Center), " hello ");
}
#[test]
fn test_is_text_file() {
assert!(is_text_file(&PathBuf::from("test.md")));
assert!(is_text_file(&PathBuf::from("test.txt")));
assert!(is_text_file(&PathBuf::from("README")));
assert!(!is_text_file(&PathBuf::from("test.jpg")));
assert!(!is_text_file(&PathBuf::from("test.exe")));
}
#[test]
fn test_is_markdown_content() {
let markdown = "# Title\n\nThis is **bold** text.\n\n- List item\n- Another item";
assert!(is_markdown_content(markdown));
let plain_text = "This is just plain text without any markdown formatting.";
assert!(!is_markdown_content(plain_text));
}
#[test]
fn test_strip_ansi() {
let colored = "\x1b[31mRed text\x1b[0m";
assert_eq!(strip_ansi(colored), "Red text");
let clickable = "\x1b]8;;https://example.com\x1b\\link text\x1b]8;;\x1b\\";
assert_eq!(strip_ansi(clickable), "link text");
let combined = "\x1b[31m\x1b]8;;https://example.com\x1b\\red link\x1b]8;;\x1b\\\x1b[0m";
assert_eq!(strip_ansi(combined), "red link");
}
#[test]
fn test_sanitize_filename() {
assert_eq!(sanitize_filename("hello/world"), "hello_world");
assert_eq!(sanitize_filename("file:name"), "file_name");
assert_eq!(sanitize_filename("normal_file.txt"), "normal_file.txt");
}
#[test]
fn test_format_file_size() {
assert_eq!(format_file_size(512), "512 B");
assert_eq!(format_file_size(1024), "1.0 KB");
assert_eq!(format_file_size(1536), "1.5 KB");
assert_eq!(format_file_size(1048576), "1.0 MB");
}
#[test]
fn test_extract_title() {
let content1 = "# Main Title\n\nSome content here.";
assert_eq!(extract_title(content1), Some("Main Title".to_string()));
let content2 = "Main Title\n==========\n\nSome content here.";
assert_eq!(extract_title(content2), Some("Main Title".to_string()));
let content3 = "Just some regular text without a clear title.";
assert_eq!(
extract_title(content3),
Some("Just some regular text without a clear title.".to_string())
);
}
#[test]
fn test_wrap_text() {
let text = "This is a long line that should be wrapped at a specific width to test the wrapping functionality.";
let wrapped = wrap_text(text, 20);
for line in wrapped.lines() {
let clean_line = strip_ansi(line);
assert!(
display_width(&clean_line) <= 20,
"Line too long: '{}'",
line
);
}
assert!(
wrapped.contains('\n'),
"Text should be wrapped into multiple lines"
);
let original_chars = text.chars().filter(|c| !c.is_whitespace()).count();
let wrapped_chars = wrapped.chars().filter(|c| !c.is_whitespace()).count();
assert!(
wrapped_chars >= original_chars - 2,
"Most characters should be preserved"
);
}
#[test]
fn test_wrap_text_with_ansi() {
let text =
"\x1b[31mThis is red text that should be wrapped\x1b[0m while preserving colors.";
let wrapped = wrap_text(text, 20);
assert!(wrapped.contains("\x1b[31m"));
assert!(wrapped.contains("\x1b[0m"));
for line in wrapped.lines() {
let clean_line = strip_ansi(line);
assert!(display_width(&clean_line) <= 20);
}
}
#[test]
fn test_wrap_text_with_indent() {
let text = "This is a long line that should be wrapped with indentation.";
let wrapped = wrap_text_with_indent(text, 30, 4);
for line in wrapped.lines() {
if !line.trim().is_empty() {
assert!(
line.starts_with(" "),
"Line should be indented: '{}'",
line
);
}
}
}
#[test]
fn test_wrap_modes() {
let text = "This is a very long line that should be wrapped differently based on the wrapping mode.";
let char_wrapped = wrap_text_with_mode(text, 20, WrapMode::Character);
assert!(char_wrapped.contains('\n'));
let word_wrapped = wrap_text_with_mode(text, 20, WrapMode::Word);
assert!(word_wrapped.contains('\n'));
let no_wrapped = wrap_text_with_mode(text, 20, WrapMode::None);
assert!(!no_wrapped.contains('\n'));
assert_eq!(no_wrapped, text);
}
#[test]
fn test_word_wrapping_preserves_words() {
let text = "Hello world this is a test";
let wrapped = wrap_text_with_mode(text, 10, WrapMode::Word);
for line in wrapped.lines() {
let words: Vec<&str> = line.trim().split_whitespace().collect();
for word in words {
assert!(text.contains(word), "Word '{}' should be preserved", word);
}
}
}
}