use adb_client::ADBServerDevice;
use ratatui::style::Color;
use regex::Regex;
use std::collections::{BTreeSet, HashSet, VecDeque};
use std::fmt;
use std::io::{self, Write};
use std::net::{Ipv4Addr, SocketAddrV4};
use std::sync::mpsc;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum LogLevel {
Verbose,
Debug,
Info,
Warn,
Error,
Fatal,
Unknown,
}
impl LogLevel {
pub fn from_char(c: char) -> Self {
match c {
'V' => LogLevel::Verbose,
'D' => LogLevel::Debug,
'I' => LogLevel::Info,
'W' => LogLevel::Warn,
'E' => LogLevel::Error,
'F' => LogLevel::Fatal,
_ => LogLevel::Unknown,
}
}
pub fn as_char(&self) -> char {
match self {
LogLevel::Verbose => 'V',
LogLevel::Debug => 'D',
LogLevel::Info => 'I',
LogLevel::Warn => 'W',
LogLevel::Error => 'E',
LogLevel::Fatal => 'F',
LogLevel::Unknown => '?',
}
}
pub fn color(&self) -> Color {
match self {
LogLevel::Verbose => Color::DarkGray,
LogLevel::Debug => Color::Cyan,
LogLevel::Info => Color::Green,
LogLevel::Warn => Color::Yellow,
LogLevel::Error => Color::Red,
LogLevel::Fatal => Color::LightRed,
LogLevel::Unknown => Color::Gray,
}
}
pub fn label_color(&self) -> Color {
match self {
LogLevel::Verbose => Color::Gray,
LogLevel::Debug => Color::LightCyan,
LogLevel::Info => Color::LightGreen,
LogLevel::Warn => Color::LightYellow,
LogLevel::Error => Color::LightRed,
LogLevel::Fatal => Color::Magenta,
LogLevel::Unknown => Color::White,
}
}
pub fn order(&self) -> u8 {
match self {
LogLevel::Verbose => 0,
LogLevel::Debug => 1,
LogLevel::Info => 2,
LogLevel::Warn => 3,
LogLevel::Error => 4,
LogLevel::Fatal => 5,
LogLevel::Unknown => 0,
}
}
pub fn all() -> &'static [LogLevel] {
&[
LogLevel::Verbose,
LogLevel::Debug,
LogLevel::Info,
LogLevel::Warn,
LogLevel::Error,
LogLevel::Fatal,
]
}
}
impl PartialOrd for LogLevel {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for LogLevel {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.order().cmp(&other.order())
}
}
impl fmt::Display for LogLevel {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.as_char())
}
}
fn is_continuation_line(msg: &str) -> bool {
let trimmed = msg.trim_start();
trimmed.starts_with("at ")
|| trimmed.starts_with("Caused by:")
|| trimmed.starts_with("... ")
|| (trimmed.starts_with("java.")
|| trimmed.starts_with("kotlin.")
|| trimmed.starts_with("android.")
|| trimmed.starts_with("javax."))
&& (trimmed.contains("Exception") || trimmed.contains("Error"))
}
#[derive(Debug, Clone)]
pub struct LogEntry {
pub raw: String,
pub timestamp: Option<String>,
pub pid: Option<String>,
pub tid: Option<String>,
pub level: LogLevel,
pub tag: Option<String>,
pub message: String,
pub is_stack_continuation: bool,
}
impl LogEntry {
pub fn parse(line: &str) -> Self {
if let Some(entry) = Self::parse_threadtime(line) {
return entry;
}
if let Some(entry) = Self::parse_brief(line) {
return entry;
}
LogEntry {
raw: line.to_string(),
timestamp: None,
pid: None,
tid: None,
level: LogLevel::Unknown,
tag: None,
message: line.to_string(),
is_stack_continuation: is_continuation_line(line),
}
}
fn parse_threadtime(line: &str) -> Option<LogEntry> {
if line.len() < 30 {
return None;
}
let bytes = line.as_bytes();
if bytes.len() < 5 {
return None;
}
if !(bytes[0].is_ascii_digit()
&& bytes[1].is_ascii_digit()
&& bytes[2] == b'-'
&& bytes[3].is_ascii_digit()
&& bytes[4].is_ascii_digit()
&& bytes[5] == b' ')
{
return None;
}
let timestamp = &line[0..18];
let rest = &line[18..];
let rest = rest.trim_start();
let mut parts = rest.splitn(4, char::is_whitespace);
let pid = parts.next()?.to_string();
let remaining = rest[pid.len()..].trim_start();
let mut parts2 = remaining.splitn(3, char::is_whitespace);
let tid = parts2.next()?.to_string();
let remaining2 = remaining[tid.len()..].trim_start();
let level_char = remaining2.chars().next()?;
let level = LogLevel::from_char(level_char);
if level == LogLevel::Unknown && level_char != '?' {
}
let after_level = &remaining2[level_char.len_utf8()..].trim_start();
let colon_pos = after_level.find(": ")?;
let tag = after_level[..colon_pos].trim().to_string();
let message = after_level[colon_pos + 2..].to_string();
Some(LogEntry {
raw: line.to_string(),
timestamp: Some(timestamp.to_string()),
pid: Some(pid),
tid: Some(tid),
level,
tag: if tag.is_empty() { None } else { Some(tag) },
message: message.clone(),
is_stack_continuation: is_continuation_line(&message),
})
}
fn parse_brief(line: &str) -> Option<LogEntry> {
let mut chars = line.chars();
let level_char = chars.next()?;
let slash = chars.next()?;
if slash != '/' {
return None;
}
let level = LogLevel::from_char(level_char);
let rest = &line[2..];
let paren_open = rest.find('(')?;
let tag = rest[..paren_open].to_string();
let after_paren = &rest[paren_open + 1..];
let paren_close = after_paren.find(')')?;
let pid = after_paren[..paren_close].trim().to_string();
let after_close = &after_paren[paren_close + 1..];
let message = if let Some(stripped) = after_close.strip_prefix(": ") {
stripped.to_string()
} else if let Some(stripped) = after_close.strip_prefix(':') {
stripped.trim_start().to_string()
} else {
after_close.to_string()
};
Some(LogEntry {
raw: line.to_string(),
timestamp: None,
pid: Some(pid),
tid: None,
level,
tag: if tag.is_empty() { None } else { Some(tag) },
message: message.clone(),
is_stack_continuation: is_continuation_line(&message),
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FilterField {
Search,
Tag,
Package,
Exclude,
None,
}
pub struct LogcatFilter {
pub search_query: String,
pub search_cursor: usize,
pub min_level: LogLevel,
pub tag_filter: String,
pub tag_cursor: usize,
pub package_filter: String,
pub package_cursor: usize,
pub active_field: FilterField,
pub exclude_query: String,
pub exclude_cursor: usize,
pub use_regex: bool,
compiled_regex: Option<Regex>,
compiled_exclude: Option<Regex>,
}
impl fmt::Debug for LogcatFilter {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("LogcatFilter")
.field("search_query", &self.search_query)
.field("search_cursor", &self.search_cursor)
.field("min_level", &self.min_level)
.field("tag_filter", &self.tag_filter)
.field("tag_cursor", &self.tag_cursor)
.field("package_filter", &self.package_filter)
.field("package_cursor", &self.package_cursor)
.field("active_field", &self.active_field)
.field("exclude_query", &self.exclude_query)
.field("exclude_cursor", &self.exclude_cursor)
.field("use_regex", &self.use_regex)
.finish()
}
}
impl Clone for LogcatFilter {
fn clone(&self) -> Self {
let mut cloned = Self {
search_query: self.search_query.clone(),
search_cursor: self.search_cursor,
min_level: self.min_level,
tag_filter: self.tag_filter.clone(),
tag_cursor: self.tag_cursor,
package_filter: self.package_filter.clone(),
package_cursor: self.package_cursor,
active_field: self.active_field,
exclude_query: self.exclude_query.clone(),
exclude_cursor: self.exclude_cursor,
use_regex: self.use_regex,
compiled_regex: None,
compiled_exclude: None,
};
cloned.recompile_regex();
cloned
}
}
impl Default for LogcatFilter {
fn default() -> Self {
Self {
search_query: String::new(),
search_cursor: 0,
min_level: LogLevel::Verbose,
tag_filter: String::new(),
tag_cursor: 0,
package_filter: String::new(),
package_cursor: 0,
active_field: FilterField::None,
exclude_query: String::new(),
exclude_cursor: 0,
use_regex: false,
compiled_regex: None,
compiled_exclude: None,
}
}
}
impl LogcatFilter {
pub fn matches(&self, entry: &LogEntry) -> bool {
if entry.level.order() < self.min_level.order() {
return false;
}
if !self.search_query.is_empty() {
if self.use_regex {
if let Some(ref re) = self.compiled_regex {
if !re.is_match(&entry.raw) {
return false;
}
}
} else {
let query_lower = self.search_query.to_lowercase();
let raw_lower = entry.raw.to_lowercase();
if !raw_lower.contains(&query_lower) {
return false;
}
}
}
if !self.exclude_query.is_empty() {
if self.use_regex {
if let Some(ref re) = self.compiled_exclude {
if re.is_match(&entry.raw) {
return false;
}
}
} else {
let q = self.exclude_query.to_lowercase();
if entry.raw.to_lowercase().contains(&q) {
return false;
}
}
}
if !self.tag_filter.is_empty() {
let tag_lower = self.tag_filter.to_lowercase();
match &entry.tag {
Some(tag) => {
if !tag.to_lowercase().contains(&tag_lower) {
return false;
}
}
None => return false,
}
}
if !self.package_filter.is_empty() {
let pkg_lower = self.package_filter.to_lowercase();
match &entry.pid {
Some(pid) => {
if !pid.to_lowercase().contains(&pkg_lower) {
return false;
}
}
None => return false,
}
}
true
}
pub fn clear_search(&mut self) {
self.search_query.clear();
self.search_cursor = 0;
}
pub fn clear_tag(&mut self) {
self.tag_filter.clear();
self.tag_cursor = 0;
}
pub fn clear_package(&mut self) {
self.package_filter.clear();
self.package_cursor = 0;
}
pub fn clear_exclude(&mut self) {
self.exclude_query.clear();
self.exclude_cursor = 0;
}
pub fn recompile_regex(&mut self) {
self.compiled_regex = if self.use_regex && !self.search_query.is_empty() {
Regex::new(&self.search_query).ok()
} else {
None
};
self.compiled_exclude = if self.use_regex && !self.exclude_query.is_empty() {
Regex::new(&self.exclude_query).ok()
} else {
None
};
}
pub fn toggle_regex(&mut self) {
self.use_regex = !self.use_regex;
self.recompile_regex();
}
pub fn insert_char(&mut self, c: char) {
{
let (field, cursor) = self.active_field_mut();
let byte_idx = char_to_byte_index(field, *cursor);
field.insert(byte_idx, c);
*cursor += 1;
}
if self.use_regex {
self.recompile_regex();
}
}
pub fn delete_char(&mut self) {
{
let (field, cursor) = self.active_field_mut();
if *cursor > 0 {
*cursor -= 1;
let byte_idx = char_to_byte_index(field, *cursor);
field.remove(byte_idx);
}
}
if self.use_regex {
self.recompile_regex();
}
}
pub fn delete_char_forward(&mut self) {
{
let (field, cursor) = self.active_field_mut();
let char_count = field.chars().count();
if *cursor < char_count {
let byte_idx = char_to_byte_index(field, *cursor);
field.remove(byte_idx);
}
}
if self.use_regex {
self.recompile_regex();
}
}
pub fn move_cursor_left(&mut self) {
let (_field, cursor) = self.active_field_mut();
*cursor = cursor.saturating_sub(1);
}
pub fn move_cursor_right(&mut self) {
let (field, cursor) = self.active_field_mut();
let char_count = field.chars().count();
if *cursor < char_count {
*cursor += 1;
}
}
pub fn cycle_level(&mut self) {
self.min_level = match self.min_level {
LogLevel::Verbose => LogLevel::Debug,
LogLevel::Debug => LogLevel::Info,
LogLevel::Info => LogLevel::Warn,
LogLevel::Warn => LogLevel::Error,
LogLevel::Error => LogLevel::Fatal,
LogLevel::Fatal => LogLevel::Verbose,
LogLevel::Unknown => LogLevel::Verbose,
};
}
fn active_field_mut(&mut self) -> (&mut String, &mut usize) {
match self.active_field {
FilterField::Search => (&mut self.search_query, &mut self.search_cursor),
FilterField::Tag => (&mut self.tag_filter, &mut self.tag_cursor),
FilterField::Package => (&mut self.package_filter, &mut self.package_cursor),
FilterField::Exclude => (&mut self.exclude_query, &mut self.exclude_cursor),
FilterField::None => (&mut self.search_query, &mut self.search_cursor),
}
}
}
fn char_to_byte_index(s: &str, char_idx: usize) -> usize {
s.char_indices()
.nth(char_idx)
.map(|(i, _)| i)
.unwrap_or(s.len())
}
pub fn tag_color(tag: &str) -> Color {
let mut hash: u32 = 5381;
for b in tag.bytes() {
hash = hash.wrapping_mul(33).wrapping_add(b as u32);
}
const PALETTE: &[Color] = &[
Color::Rgb(86, 156, 214), Color::Rgb(78, 201, 176), Color::Rgb(220, 220, 170), Color::Rgb(206, 145, 120), Color::Rgb(181, 206, 168), Color::Rgb(200, 130, 200), Color::Rgb(100, 200, 220), Color::Rgb(220, 180, 100), Color::Rgb(130, 180, 220), Color::Rgb(180, 140, 180), Color::Rgb(150, 220, 150), Color::Rgb(220, 150, 150), Color::Rgb(170, 200, 130), Color::Rgb(140, 180, 200), Color::Rgb(200, 170, 140), Color::Rgb(160, 200, 200), ];
PALETTE[(hash as usize) % PALETTE.len()]
}
#[derive(Debug, Clone)]
pub struct LogStats {
pub counts: [u64; 7],
pub lines_per_sec: f64,
samples: VecDeque<u64>,
_last_total: u64,
}
impl LogStats {
pub fn new() -> Self {
Self {
counts: [0; 7],
lines_per_sec: 0.0,
samples: VecDeque::with_capacity(32),
_last_total: 0,
}
}
pub fn record(&mut self, level: &LogLevel) {
self.counts[level.order() as usize] += 1;
}
pub fn update_rate(&mut self, total_received: u64) {
self.samples.push_back(total_received);
if self.samples.len() > 30 {
self.samples.pop_front();
}
if self.samples.len() >= 2 {
let newest = *self.samples.back().unwrap();
let oldest = *self.samples.front().unwrap();
let window = self.samples.len() as f64;
self.lines_per_sec = (newest - oldest) as f64 / (window / 30.0); }
}
pub fn reset(&mut self) {
*self = Self::new();
}
}
impl Default for LogStats {
fn default() -> Self {
Self::new()
}
}
pub struct ChannelWriter {
sender: mpsc::SyncSender<String>,
buffer: Vec<u8>,
}
impl ChannelWriter {
pub fn new(sender: mpsc::SyncSender<String>) -> Self {
Self {
sender,
buffer: Vec::with_capacity(4096),
}
}
}
impl Write for ChannelWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.buffer.extend_from_slice(buf);
while let Some(newline_pos) = self.buffer.iter().position(|&b| b == b'\n') {
let line_bytes: Vec<u8> = self.buffer.drain(..=newline_pos).collect();
let line = String::from_utf8_lossy(&line_bytes)
.trim_end_matches('\n')
.trim_end_matches('\r')
.to_string();
if self.sender.send(line).is_err() {
return Err(io::Error::new(
io::ErrorKind::BrokenPipe,
"logcat receiver dropped",
));
}
}
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
if !self.buffer.is_empty() {
let remaining = String::from_utf8_lossy(&self.buffer).to_string();
self.buffer.clear();
if !remaining.is_empty() {
let _ = self.sender.send(remaining);
}
}
Ok(())
}
}
impl fmt::Debug for ChannelWriter {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ChannelWriter")
.field("buffer_len", &self.buffer.len())
.finish()
}
}
pub fn copy_to_clipboard(text: &str) -> std::result::Result<(), String> {
use std::io::Write as _;
use std::process::{Command, Stdio};
#[cfg(target_os = "macos")]
let mut child = Command::new("pbcopy")
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("Failed to spawn pbcopy: {}", e))?;
#[cfg(target_os = "linux")]
let mut child = {
Command::new("xclip")
.args(["-selection", "clipboard"])
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.or_else(|_| {
Command::new("xsel")
.args(["--clipboard", "--input"])
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
})
.map_err(|e| format!("No clipboard tool found (xclip/xsel): {}", e))?
};
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
return Err("Clipboard not supported on this platform".into());
if let Some(ref mut stdin) = child.stdin {
stdin
.write_all(text.as_bytes())
.map_err(|e| format!("Write to clipboard failed: {}", e))?;
}
drop(child.stdin.take());
child
.wait()
.map_err(|e| format!("Clipboard command failed: {}", e))?;
Ok(())
}
const MAX_ENTRIES: usize = 50_000;
const MAX_DRAIN_PER_TICK: usize = 500;
const CHANNEL_CAPACITY: usize = 10_000;
pub struct LogcatState {
pub entries: Vec<LogEntry>,
pub filtered_indices: Vec<usize>,
pub filter: LogcatFilter,
pub scroll_position: usize,
pub auto_scroll: bool,
pub paused: bool,
receiver: Option<mpsc::Receiver<String>>,
trimmed_total: usize,
pub is_streaming: bool,
pub total_received: u64,
pub status_message: Option<String>,
pub word_wrap: bool,
pub viewport_height: usize,
pub detail_open: bool,
pub selected_line: usize,
pub folded_groups: HashSet<usize>,
pub stats: LogStats,
pub h_scroll: usize,
pub compact: bool,
pub bookmarks: BTreeSet<usize>,
}
impl LogcatState {
pub fn new() -> Self {
Self {
entries: Vec::with_capacity(1024),
filtered_indices: Vec::with_capacity(1024),
filter: LogcatFilter::default(),
scroll_position: 0,
auto_scroll: true,
paused: false,
receiver: None,
is_streaming: false,
total_received: 0,
status_message: None,
word_wrap: false,
viewport_height: 30,
trimmed_total: 0,
detail_open: false,
selected_line: 0,
folded_groups: HashSet::new(),
stats: LogStats::new(),
h_scroll: 0,
compact: false,
bookmarks: BTreeSet::new(),
}
}
pub fn start_streaming(&mut self, serial: String) {
self.stop_streaming();
let (tx, rx) = mpsc::sync_channel::<String>(CHANNEL_CAPACITY);
self.receiver = Some(rx);
self.is_streaming = true;
self.status_message = Some(format!("Streaming logcat from {}…", serial));
std::thread::spawn(move || {
let addr = SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), 5037);
let mut device = ADBServerDevice::new(serial, Some(addr));
let writer = ChannelWriter::new(tx.clone());
if let Err(e) = device.get_logs(writer) {
let _ = tx.send(format!("--- LOGCAT ERROR: {} ---", e));
}
});
}
pub fn stop_streaming(&mut self) {
self.receiver = None;
self.is_streaming = false;
self.status_message = Some("Logcat streaming stopped.".to_string());
}
pub fn poll_new_entries(&mut self) {
let receiver = match &self.receiver {
Some(rx) => rx,
None => return,
};
let mut new_count: usize = 0;
for _ in 0..MAX_DRAIN_PER_TICK {
match receiver.try_recv() {
Ok(line) => {
self.total_received += 1;
if self.paused {
continue;
}
let entry = LogEntry::parse(&line);
let idx = self.entries.len();
self.stats.record(&entry.level);
self.entries.push(entry);
new_count += 1;
if self.filter.matches(&self.entries[idx]) {
self.filtered_indices.push(idx);
}
}
Err(mpsc::TryRecvError::Empty) => break,
Err(mpsc::TryRecvError::Disconnected) => {
self.is_streaming = false;
self.status_message =
Some("Logcat stream ended (thread disconnected).".to_string());
break;
}
}
}
if self.entries.len() > MAX_ENTRIES {
let excess = self.entries.len() - MAX_ENTRIES;
self.entries.drain(0..excess);
self.trimmed_total += excess;
let mut write = 0;
for read in 0..self.filtered_indices.len() {
let idx = self.filtered_indices[read];
if idx >= excess {
self.filtered_indices[write] = idx - excess;
write += 1;
}
}
self.filtered_indices.truncate(write);
let len = self.filtered_indices.len();
if self.scroll_position >= len && len > 0 {
self.scroll_position = len.saturating_sub(self.viewport_height);
} else if len == 0 {
self.scroll_position = 0;
}
}
if new_count > 0 && self.auto_scroll {
self.scroll_position = self.filtered_indices.len();
self.selected_line = self.filtered_indices.len().saturating_sub(1);
}
self.stats.update_rate(self.total_received);
}
pub fn rebuild_filtered(&mut self) {
self.filtered_indices.clear();
let mut skip_continuations = false;
for (i, entry) in self.entries.iter().enumerate() {
if !entry.is_stack_continuation {
skip_continuations = self.folded_groups.contains(&i);
}
if skip_continuations && entry.is_stack_continuation {
continue; }
if self.filter.matches(entry) {
self.filtered_indices.push(i);
}
}
let len = self.filtered_indices.len();
if len == 0 {
self.scroll_position = 0;
self.selected_line = 0;
} else {
if self.scroll_position >= len {
self.scroll_position = len.saturating_sub(self.viewport_height);
}
if self.selected_line >= len {
self.selected_line = len.saturating_sub(1);
}
}
}
pub fn visible_entries(&mut self, height: usize) -> Vec<usize> {
self.viewport_height = height;
if self.filtered_indices.is_empty() || height == 0 {
return vec![];
}
let total = self.filtered_indices.len();
let start = if self.auto_scroll {
let s = total.saturating_sub(height);
self.scroll_position = s;
s
} else {
self.scroll_position
.min(total.saturating_sub(height).max(0))
};
let end = (start + height).min(total);
self.filtered_indices[start..end].to_vec()
}
pub fn scroll_up(&mut self, n: usize) {
if self.auto_scroll {
let total = self.filtered_indices.len();
self.scroll_position = total.saturating_sub(self.viewport_height);
}
self.auto_scroll = false;
self.scroll_position = self.scroll_position.saturating_sub(n);
self.selected_line = self.scroll_position;
}
pub fn scroll_down(&mut self, n: usize) {
if self.auto_scroll {
return;
}
let max_scroll = self
.filtered_indices
.len()
.saturating_sub(self.viewport_height);
self.scroll_position = (self.scroll_position + n).min(max_scroll);
let max_line = self.filtered_indices.len().saturating_sub(1);
self.selected_line =
(self.scroll_position + self.viewport_height.saturating_sub(1)).min(max_line);
}
pub fn scroll_to_bottom(&mut self) {
self.auto_scroll = true;
let total = self.filtered_indices.len();
self.scroll_position = total.saturating_sub(self.viewport_height);
self.selected_line = self.filtered_indices.len().saturating_sub(1);
}
pub fn scroll_to_top(&mut self) {
self.auto_scroll = false;
self.scroll_position = 0;
self.selected_line = 0;
}
pub fn clear(&mut self) {
self.entries.clear();
self.filtered_indices.clear();
self.scroll_position = 0;
self.total_received = 0;
self.trimmed_total = 0;
self.auto_scroll = true;
self.stats.reset();
self.bookmarks.clear();
self.folded_groups.clear();
self.detail_open = false;
}
pub fn default_save_dir() -> std::path::PathBuf {
std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
}
pub fn default_save_filename() -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
format!("logcat_{}.log", now)
}
pub fn toggle_pause(&mut self) {
self.paused = !self.paused;
if self.paused {
self.status_message = Some("Logcat paused.".to_string());
} else {
self.status_message = Some("Logcat resumed.".to_string());
}
}
pub fn entry_count(&self) -> usize {
self.filtered_indices.len()
}
pub fn total_count(&self) -> usize {
self.entries.len()
}
pub fn save_to_file(&self, path: &std::path::Path) -> std::io::Result<usize> {
use std::io::BufWriter;
let file = std::fs::File::create(path)?;
let mut writer = BufWriter::new(file);
let mut count = 0;
for entry in &self.entries {
writeln!(writer, "{}", entry.raw)?;
count += 1;
}
writer.flush()?;
Ok(count)
}
pub fn save_filtered_to_file(&self, path: &std::path::Path) -> std::io::Result<usize> {
use std::io::BufWriter;
let file = std::fs::File::create(path)?;
let mut writer = BufWriter::new(file);
let mut count = 0;
for &idx in &self.filtered_indices {
if let Some(entry) = self.entries.get(idx) {
writeln!(writer, "{}", entry.raw)?;
count += 1;
}
}
writer.flush()?;
Ok(count)
}
pub fn toggle_word_wrap(&mut self) {
self.word_wrap = !self.word_wrap;
}
pub fn toggle_detail(&mut self) {
self.detail_open = !self.detail_open;
}
pub fn selected_entry(&self) -> Option<&LogEntry> {
self.filtered_indices
.get(self.selected_line)
.and_then(|&idx| self.entries.get(idx))
}
pub fn select_up(&mut self) {
if self.selected_line > 0 {
self.selected_line -= 1;
}
if self.selected_line < self.scroll_position {
self.scroll_position = self.selected_line;
}
self.auto_scroll = false;
}
pub fn select_down(&mut self) {
let max = self.filtered_indices.len().saturating_sub(1);
if self.selected_line < max {
self.selected_line += 1;
}
if self.selected_line >= self.scroll_position + self.viewport_height {
self.scroll_position = self.selected_line.saturating_sub(self.viewport_height - 1);
}
}
pub fn toggle_fold_at_selected(&mut self) {
if let Some(&entry_idx) = self.filtered_indices.get(self.selected_line) {
let head = if self.entries[entry_idx].is_stack_continuation {
(0..entry_idx)
.rev()
.find(|&i| !self.entries[i].is_stack_continuation)
.unwrap_or(entry_idx)
} else {
entry_idx
};
if self.folded_groups.contains(&head) {
self.folded_groups.remove(&head);
} else {
self.folded_groups.insert(head);
}
self.rebuild_filtered();
}
}
pub fn h_scroll_left(&mut self, n: usize) {
self.h_scroll = self.h_scroll.saturating_sub(n);
}
pub fn h_scroll_right(&mut self, n: usize) {
self.h_scroll += n;
}
pub fn h_scroll_reset(&mut self) {
self.h_scroll = 0;
}
pub fn toggle_compact(&mut self) {
self.compact = !self.compact;
}
pub fn copy_selected_to_clipboard(&self) -> std::result::Result<(), String> {
if let Some(entry) = self.selected_entry() {
copy_to_clipboard(&entry.raw)
} else {
Err("No line selected".into())
}
}
pub fn toggle_bookmark(&mut self) {
if let Some(&entry_idx) = self.filtered_indices.get(self.selected_line) {
if !self.bookmarks.remove(&entry_idx) {
self.bookmarks.insert(entry_idx);
}
}
}
pub fn is_bookmarked(&self, entry_idx: usize) -> bool {
self.bookmarks.contains(&entry_idx)
}
pub fn next_bookmark(&mut self) {
if self.bookmarks.is_empty() {
return;
}
let current_entry = self
.filtered_indices
.get(self.selected_line)
.copied()
.unwrap_or(0);
if let Some(&next) = self.bookmarks.range((current_entry + 1)..).next() {
if let Some(pos) = self.filtered_indices.iter().position(|&i| i == next) {
self.selected_line = pos;
self.auto_scroll = false;
if self.selected_line < self.scroll_position
|| self.selected_line >= self.scroll_position + self.viewport_height
{
self.scroll_position =
self.selected_line.saturating_sub(self.viewport_height / 2);
}
}
} else {
if let Some(&first) = self.bookmarks.iter().next() {
if let Some(pos) = self.filtered_indices.iter().position(|&i| i == first) {
self.selected_line = pos;
self.auto_scroll = false;
if self.selected_line < self.scroll_position
|| self.selected_line >= self.scroll_position + self.viewport_height
{
self.scroll_position =
self.selected_line.saturating_sub(self.viewport_height / 2);
}
}
}
}
}
pub fn prev_bookmark(&mut self) {
if self.bookmarks.is_empty() {
return;
}
let current_entry = self
.filtered_indices
.get(self.selected_line)
.copied()
.unwrap_or(0);
if let Some(&prev) = self.bookmarks.range(..current_entry).next_back() {
if let Some(pos) = self.filtered_indices.iter().position(|&i| i == prev) {
self.selected_line = pos;
self.auto_scroll = false;
if self.selected_line < self.scroll_position
|| self.selected_line >= self.scroll_position + self.viewport_height
{
self.scroll_position =
self.selected_line.saturating_sub(self.viewport_height / 2);
}
}
} else {
if let Some(&last) = self.bookmarks.iter().next_back() {
if let Some(pos) = self.filtered_indices.iter().position(|&i| i == last) {
self.selected_line = pos;
self.auto_scroll = false;
if self.selected_line < self.scroll_position
|| self.selected_line >= self.scroll_position + self.viewport_height
{
self.scroll_position =
self.selected_line.saturating_sub(self.viewport_height / 2);
}
}
}
}
}
}
impl Default for LogcatState {
fn default() -> Self {
Self::new()
}
}
impl fmt::Debug for LogcatState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("LogcatState")
.field("entries_len", &self.entries.len())
.field("filtered_len", &self.filtered_indices.len())
.field("filter", &self.filter)
.field("scroll_position", &self.scroll_position)
.field("auto_scroll", &self.auto_scroll)
.field("paused", &self.paused)
.field(
"receiver",
&if self.receiver.is_some() {
"Some(..)"
} else {
"None"
},
)
.field("is_streaming", &self.is_streaming)
.field("total_received", &self.total_received)
.field("trimmed_total", &self.trimmed_total)
.field("status_message", &self.status_message)
.field("word_wrap", &self.word_wrap)
.field("viewport_height", &self.viewport_height)
.field("detail_open", &self.detail_open)
.field("selected_line", &self.selected_line)
.field("folded_groups_len", &self.folded_groups.len())
.field("stats", &self.stats)
.field("h_scroll", &self.h_scroll)
.field("compact", &self.compact)
.field("bookmarks_len", &self.bookmarks.len())
.finish()
}
}
#[cfg(test)]
#[allow(clippy::field_reassign_with_default)]
mod tests {
use super::*;
#[test]
fn test_log_level_from_char() {
assert_eq!(LogLevel::from_char('V'), LogLevel::Verbose);
assert_eq!(LogLevel::from_char('D'), LogLevel::Debug);
assert_eq!(LogLevel::from_char('I'), LogLevel::Info);
assert_eq!(LogLevel::from_char('W'), LogLevel::Warn);
assert_eq!(LogLevel::from_char('E'), LogLevel::Error);
assert_eq!(LogLevel::from_char('F'), LogLevel::Fatal);
assert_eq!(LogLevel::from_char('X'), LogLevel::Unknown);
}
#[test]
fn test_log_level_as_char_roundtrip() {
for level in LogLevel::all() {
assert_eq!(LogLevel::from_char(level.as_char()), *level);
}
}
#[test]
fn test_log_level_ordering() {
assert!(LogLevel::Verbose < LogLevel::Debug);
assert!(LogLevel::Debug < LogLevel::Info);
assert!(LogLevel::Info < LogLevel::Warn);
assert!(LogLevel::Warn < LogLevel::Error);
assert!(LogLevel::Error < LogLevel::Fatal);
}
#[test]
fn test_log_level_all_count() {
assert_eq!(LogLevel::all().len(), 6);
}
#[test]
fn test_parse_threadtime_format() {
let line = "01-15 12:34:56.789 1234 5678 I ActivityManager: Start proc com.example";
let entry = LogEntry::parse(line);
assert_eq!(entry.timestamp.as_deref(), Some("01-15 12:34:56.789"));
assert_eq!(entry.pid.as_deref(), Some("1234"));
assert_eq!(entry.tid.as_deref(), Some("5678"));
assert_eq!(entry.level, LogLevel::Info);
assert_eq!(entry.tag.as_deref(), Some("ActivityManager"));
assert_eq!(entry.message, "Start proc com.example");
}
#[test]
fn test_parse_brief_format() {
let line = "I/ActivityManager( 1234): Start proc com.example";
let entry = LogEntry::parse(line);
assert_eq!(entry.level, LogLevel::Info);
assert_eq!(entry.tag.as_deref(), Some("ActivityManager"));
assert_eq!(entry.pid.as_deref(), Some("1234"));
assert_eq!(entry.message, "Start proc com.example");
}
#[test]
fn test_parse_unknown_format() {
let line = "--- some random logcat line ---";
let entry = LogEntry::parse(line);
assert_eq!(entry.level, LogLevel::Unknown);
assert_eq!(entry.message, line);
assert!(entry.timestamp.is_none());
}
#[test]
fn test_parse_empty_line() {
let entry = LogEntry::parse("");
assert_eq!(entry.level, LogLevel::Unknown);
assert_eq!(entry.message, "");
}
#[test]
fn test_filter_matches_level() {
let mut filter = LogcatFilter::default();
filter.min_level = LogLevel::Warn;
let info_entry = LogEntry {
raw: String::new(),
timestamp: None,
pid: None,
tid: None,
level: LogLevel::Info,
tag: None,
message: "hello".to_string(),
is_stack_continuation: false,
};
let warn_entry = LogEntry {
raw: String::new(),
timestamp: None,
pid: None,
tid: None,
level: LogLevel::Warn,
tag: None,
message: "warning".to_string(),
is_stack_continuation: false,
};
assert!(!filter.matches(&info_entry));
assert!(filter.matches(&warn_entry));
}
#[test]
fn test_filter_matches_search() {
let mut filter = LogcatFilter::default();
filter.search_query = "hello".to_string();
let matching = LogEntry {
raw: "01-01 00:00:00.000 I Hello World".to_string(),
timestamp: None,
pid: None,
tid: None,
level: LogLevel::Info,
tag: None,
message: "Hello World".to_string(),
is_stack_continuation: false,
};
let not_matching = LogEntry {
raw: "01-01 00:00:00.000 I Goodbye".to_string(),
timestamp: None,
pid: None,
tid: None,
level: LogLevel::Info,
tag: None,
message: "Goodbye".to_string(),
is_stack_continuation: false,
};
assert!(filter.matches(&matching));
assert!(!filter.matches(¬_matching));
}
#[test]
fn test_filter_matches_tag() {
let mut filter = LogcatFilter::default();
filter.tag_filter = "Activity".to_string();
let matching = LogEntry {
raw: String::new(),
timestamp: None,
pid: None,
tid: None,
level: LogLevel::Info,
tag: Some("ActivityManager".to_string()),
message: "test".to_string(),
is_stack_continuation: false,
};
let not_matching = LogEntry {
raw: String::new(),
timestamp: None,
pid: None,
tid: None,
level: LogLevel::Info,
tag: Some("WindowManager".to_string()),
message: "test".to_string(),
is_stack_continuation: false,
};
assert!(filter.matches(&matching));
assert!(!filter.matches(¬_matching));
}
#[test]
fn test_filter_cycle_level() {
let mut filter = LogcatFilter::default();
assert_eq!(filter.min_level, LogLevel::Verbose);
filter.cycle_level();
assert_eq!(filter.min_level, LogLevel::Debug);
filter.cycle_level();
assert_eq!(filter.min_level, LogLevel::Info);
filter.cycle_level();
assert_eq!(filter.min_level, LogLevel::Warn);
filter.cycle_level();
assert_eq!(filter.min_level, LogLevel::Error);
filter.cycle_level();
assert_eq!(filter.min_level, LogLevel::Fatal);
filter.cycle_level();
assert_eq!(filter.min_level, LogLevel::Verbose);
}
#[test]
fn test_filter_insert_and_delete() {
let mut filter = LogcatFilter::default();
filter.active_field = FilterField::Search;
filter.insert_char('h');
filter.insert_char('i');
assert_eq!(filter.search_query, "hi");
assert_eq!(filter.search_cursor, 2);
filter.delete_char();
assert_eq!(filter.search_query, "h");
assert_eq!(filter.search_cursor, 1);
filter.insert_char('e');
filter.insert_char('y');
assert_eq!(filter.search_query, "hey");
filter.move_cursor_left();
filter.move_cursor_left();
filter.delete_char_forward();
assert_eq!(filter.search_query, "hy");
}
#[test]
fn test_filter_clear() {
let mut filter = LogcatFilter::default();
filter.search_query = "test".to_string();
filter.search_cursor = 4;
filter.clear_search();
assert_eq!(filter.search_query, "");
assert_eq!(filter.search_cursor, 0);
}
#[test]
fn test_channel_writer_sends_lines() {
let (tx, rx) = mpsc::sync_channel(100);
let mut writer = ChannelWriter::new(tx);
writer.write_all(b"hello\nworld\n").unwrap();
assert_eq!(rx.recv().unwrap(), "hello");
assert_eq!(rx.recv().unwrap(), "world");
}
#[test]
fn test_channel_writer_partial_lines() {
let (tx, rx) = mpsc::sync_channel(100);
let mut writer = ChannelWriter::new(tx);
writer.write_all(b"hel").unwrap();
writer.write_all(b"lo\n").unwrap();
assert_eq!(rx.recv().unwrap(), "hello");
}
#[test]
fn test_channel_writer_flush_sends_remaining() {
let (tx, rx) = mpsc::sync_channel(100);
let mut writer = ChannelWriter::new(tx);
writer.write_all(b"partial").unwrap();
assert!(rx.try_recv().is_err());
writer.flush().unwrap();
assert_eq!(rx.recv().unwrap(), "partial");
}
#[test]
fn test_channel_writer_strips_cr_lf() {
let (tx, rx) = mpsc::sync_channel(100);
let mut writer = ChannelWriter::new(tx);
writer.write_all(b"line\r\n").unwrap();
assert_eq!(rx.recv().unwrap(), "line");
}
#[test]
fn test_logcat_state_new() {
let state = LogcatState::new();
assert!(state.entries.is_empty());
assert!(state.filtered_indices.is_empty());
assert!(state.auto_scroll);
assert!(!state.paused);
assert!(!state.is_streaming);
assert_eq!(state.total_received, 0);
}
#[test]
fn test_logcat_state_clear() {
let mut state = LogcatState::new();
state.entries.push(LogEntry::parse("test line"));
state.filtered_indices.push(0);
state.total_received = 5;
state.scroll_position = 3;
state.auto_scroll = false;
state.clear();
assert!(state.entries.is_empty());
assert!(state.filtered_indices.is_empty());
assert_eq!(state.total_received, 0);
assert_eq!(state.scroll_position, 0);
assert!(state.auto_scroll);
}
#[test]
fn test_logcat_state_toggle_pause() {
let mut state = LogcatState::new();
assert!(!state.paused);
state.toggle_pause();
assert!(state.paused);
state.toggle_pause();
assert!(!state.paused);
}
#[test]
fn test_logcat_state_scroll() {
let mut state = LogcatState::new();
state.viewport_height = 10; for i in 0..20 {
state.entries.push(LogEntry::parse(&format!("line {}", i)));
state.filtered_indices.push(i);
}
state.scroll_up(5);
assert!(!state.auto_scroll);
assert_eq!(state.scroll_position, 5);
state.scroll_down(3);
assert_eq!(state.scroll_position, 8);
state.scroll_to_top();
assert_eq!(state.scroll_position, 0);
state.scroll_to_bottom();
assert!(state.auto_scroll);
assert_eq!(state.scroll_position, 10);
}
#[test]
fn test_logcat_state_rebuild_filtered() {
let mut state = LogcatState::new();
state.entries.push(LogEntry {
raw: "info line".to_string(),
timestamp: None,
pid: None,
tid: None,
level: LogLevel::Info,
tag: None,
message: "info line".to_string(),
is_stack_continuation: false,
});
state.entries.push(LogEntry {
raw: "debug line".to_string(),
timestamp: None,
pid: None,
tid: None,
level: LogLevel::Debug,
tag: None,
message: "debug line".to_string(),
is_stack_continuation: false,
});
state.entries.push(LogEntry {
raw: "warn line".to_string(),
timestamp: None,
pid: None,
tid: None,
level: LogLevel::Warn,
tag: None,
message: "warn line".to_string(),
is_stack_continuation: false,
});
state.filter.min_level = LogLevel::Warn;
state.rebuild_filtered();
assert_eq!(state.filtered_indices.len(), 1);
assert_eq!(state.filtered_indices[0], 2);
}
#[test]
fn test_logcat_state_visible_entries() {
let mut state = LogcatState::new();
for i in 0..10 {
state.entries.push(LogEntry::parse(&format!("line {}", i)));
state.filtered_indices.push(i);
}
state.auto_scroll = false;
state.scroll_position = 3;
let visible = state.visible_entries(5);
assert_eq!(visible.len(), 5);
assert_eq!(visible[0], 3);
assert_eq!(visible[4], 7);
state.auto_scroll = true;
let visible = state.visible_entries(4);
assert_eq!(visible.len(), 4);
assert_eq!(visible[0], 6);
assert_eq!(visible[3], 9);
assert_eq!(state.scroll_position, 6);
}
#[test]
fn test_logcat_state_entry_count() {
let mut state = LogcatState::new();
state.entries.push(LogEntry::parse("line 1"));
state.entries.push(LogEntry::parse("line 2"));
state.filtered_indices.push(0);
assert_eq!(state.entry_count(), 1);
assert_eq!(state.total_count(), 2);
}
#[test]
fn test_logcat_state_debug_impl() {
let state = LogcatState::new();
let debug_str = format!("{:?}", state);
assert!(debug_str.contains("LogcatState"));
assert!(debug_str.contains("entries_len"));
assert!(debug_str.contains("viewport_height"));
assert!(debug_str.contains("trimmed_total"));
}
#[test]
fn test_logcat_state_poll_with_no_receiver() {
let mut state = LogcatState::new();
state.poll_new_entries();
assert_eq!(state.total_received, 0);
}
#[test]
fn test_logcat_state_poll_drains_channel() {
let mut state = LogcatState::new();
let (tx, rx) = mpsc::channel();
state.receiver = Some(rx);
state.is_streaming = true;
tx.send("01-15 12:34:56.789 1234 5678 I TestTag: hello world".to_string())
.unwrap();
tx.send("01-15 12:34:57.000 1234 5678 W TestTag: warning".to_string())
.unwrap();
drop(tx);
state.poll_new_entries();
assert_eq!(state.entries.len(), 2);
assert_eq!(state.total_received, 2);
assert_eq!(state.filtered_indices.len(), 2);
}
#[test]
fn test_logcat_state_stop_streaming() {
let mut state = LogcatState::new();
let (_tx, rx) = mpsc::channel::<String>();
state.receiver = Some(rx);
state.is_streaming = true;
state.stop_streaming();
assert!(!state.is_streaming);
assert!(state.receiver.is_none());
}
#[test]
fn test_regex_search_matches() {
let mut filter = LogcatFilter::default();
filter.use_regex = true;
filter.search_query = "Error|Warning".to_string();
filter.recompile_regex();
let entry_match = LogEntry {
raw: "Some Error occurred".to_string(),
timestamp: None,
pid: None,
tid: None,
level: LogLevel::Error,
tag: None,
message: "Some Error occurred".to_string(),
is_stack_continuation: false,
};
let entry_no = LogEntry {
raw: "All fine here".to_string(),
timestamp: None,
pid: None,
tid: None,
level: LogLevel::Info,
tag: None,
message: "All fine here".to_string(),
is_stack_continuation: false,
};
assert!(filter.matches(&entry_match));
assert!(!filter.matches(&entry_no));
}
#[test]
fn test_regex_toggle() {
let mut filter = LogcatFilter::default();
assert!(!filter.use_regex);
filter.toggle_regex();
assert!(filter.use_regex);
filter.toggle_regex();
assert!(!filter.use_regex);
}
#[test]
fn test_regex_invalid_pattern_no_panic() {
let mut filter = LogcatFilter::default();
filter.use_regex = true;
filter.search_query = "[invalid".to_string();
filter.recompile_regex();
let entry = LogEntry::parse("test line");
assert!(filter.matches(&entry));
}
#[test]
fn test_exclude_filter() {
let mut filter = LogcatFilter::default();
filter.exclude_query = "noisy".to_string();
let entry_excluded = LogEntry {
raw: "this is noisy spam".to_string(),
timestamp: None,
pid: None,
tid: None,
level: LogLevel::Debug,
tag: None,
message: "this is noisy spam".to_string(),
is_stack_continuation: false,
};
let entry_kept = LogEntry {
raw: "this is useful".to_string(),
timestamp: None,
pid: None,
tid: None,
level: LogLevel::Debug,
tag: None,
message: "this is useful".to_string(),
is_stack_continuation: false,
};
assert!(!filter.matches(&entry_excluded));
assert!(filter.matches(&entry_kept));
}
#[test]
fn test_exclude_with_regex() {
let mut filter = LogcatFilter::default();
filter.use_regex = true;
filter.exclude_query = "spam|noise".to_string();
filter.recompile_regex();
let excluded = LogEntry {
raw: "lots of noise here".to_string(),
timestamp: None,
pid: None,
tid: None,
level: LogLevel::Debug,
tag: None,
message: "lots of noise here".to_string(),
is_stack_continuation: false,
};
assert!(!filter.matches(&excluded));
}
#[test]
fn test_exclude_filter_field() {
let mut filter = LogcatFilter::default();
filter.active_field = FilterField::Exclude;
filter.insert_char('t');
filter.insert_char('e');
assert_eq!(filter.exclude_query, "te");
filter.delete_char();
assert_eq!(filter.exclude_query, "t");
filter.clear_exclude();
assert!(filter.exclude_query.is_empty());
}
#[test]
fn test_selected_entry() {
let mut state = LogcatState::new();
for i in 0..5 {
state.entries.push(LogEntry::parse(&format!("line {}", i)));
state.filtered_indices.push(i);
}
state.selected_line = 2;
let entry = state.selected_entry().unwrap();
assert!(entry.raw.contains("line 2"));
}
#[test]
fn test_toggle_detail() {
let mut state = LogcatState::new();
assert!(!state.detail_open);
state.toggle_detail();
assert!(state.detail_open);
state.toggle_detail();
assert!(!state.detail_open);
}
#[test]
fn test_select_up_down() {
let mut state = LogcatState::new();
state.viewport_height = 10;
for i in 0..20 {
state.entries.push(LogEntry::parse(&format!("line {}", i)));
state.filtered_indices.push(i);
}
state.selected_line = 5;
state.auto_scroll = false;
state.scroll_position = 0;
state.select_up();
assert_eq!(state.selected_line, 4);
state.select_down();
state.select_down();
assert_eq!(state.selected_line, 6);
state.selected_line = 0;
state.select_up();
assert_eq!(state.selected_line, 0);
}
#[test]
fn test_tag_color_deterministic() {
let c1 = tag_color("MyTag");
let c2 = tag_color("MyTag");
assert_eq!(c1, c2);
}
#[test]
fn test_tag_color_different_tags() {
let c1 = tag_color("ActivityManager");
let c2 = tag_color("WindowManager");
let _ = c1;
let _ = c2;
}
#[test]
fn test_stack_continuation_detection() {
let e1 = LogEntry::parse("03-25 12:00:00.000 1234 5678 E MyApp : NullPointerException");
assert!(!e1.is_stack_continuation);
let e2 = LogEntry::parse(" at com.example.MyClass.method(MyClass.java:42)");
assert!(e2.is_stack_continuation);
let e3 = LogEntry::parse("Caused by: java.io.IOException: file not found");
assert!(e3.is_stack_continuation);
let e4 = LogEntry::parse(" ... 15 more");
assert!(e4.is_stack_continuation);
}
#[test]
fn test_fold_toggle() {
let mut state = LogcatState::new();
state.entries.push(LogEntry {
raw: "Error happened".to_string(),
timestamp: None,
pid: None,
tid: None,
level: LogLevel::Error,
tag: None,
message: "Error happened".to_string(),
is_stack_continuation: false,
});
state.entries.push(LogEntry {
raw: " at com.example.Foo.bar(Foo.java:10)".to_string(),
timestamp: None,
pid: None,
tid: None,
level: LogLevel::Error,
tag: None,
message: "at com.example.Foo.bar(Foo.java:10)".to_string(),
is_stack_continuation: true,
});
state.entries.push(LogEntry {
raw: " at com.example.Baz.qux(Baz.java:20)".to_string(),
timestamp: None,
pid: None,
tid: None,
level: LogLevel::Error,
tag: None,
message: "at com.example.Baz.qux(Baz.java:20)".to_string(),
is_stack_continuation: true,
});
state.entries.push(LogEntry {
raw: "Normal line".to_string(),
timestamp: None,
pid: None,
tid: None,
level: LogLevel::Info,
tag: None,
message: "Normal line".to_string(),
is_stack_continuation: false,
});
state.filtered_indices = vec![0, 1, 2, 3];
state.selected_line = 0;
state.toggle_fold_at_selected();
assert!(state.folded_groups.contains(&0));
assert_eq!(state.filtered_indices.len(), 2); assert_eq!(state.filtered_indices[0], 0);
assert_eq!(state.filtered_indices[1], 3);
state.selected_line = 0;
state.toggle_fold_at_selected();
assert!(!state.folded_groups.contains(&0));
assert_eq!(state.filtered_indices.len(), 4);
}
#[test]
fn test_stats_record() {
let mut stats = LogStats::new();
stats.record(&LogLevel::Info);
stats.record(&LogLevel::Info);
stats.record(&LogLevel::Error);
assert_eq!(stats.counts[LogLevel::Info.order() as usize], 2);
assert_eq!(stats.counts[LogLevel::Error.order() as usize], 1);
}
#[test]
fn test_stats_reset() {
let mut stats = LogStats::new();
stats.record(&LogLevel::Debug);
stats.reset();
assert_eq!(stats.counts[LogLevel::Debug.order() as usize], 0);
assert_eq!(stats.lines_per_sec, 0.0);
}
#[test]
fn test_h_scroll() {
let mut state = LogcatState::new();
assert_eq!(state.h_scroll, 0);
state.h_scroll_right(5);
assert_eq!(state.h_scroll, 5);
state.h_scroll_left(2);
assert_eq!(state.h_scroll, 3);
state.h_scroll_left(100); assert_eq!(state.h_scroll, 0);
state.h_scroll_right(10);
state.h_scroll_reset();
assert_eq!(state.h_scroll, 0);
}
#[test]
fn test_compact_toggle() {
let mut state = LogcatState::new();
assert!(!state.compact);
state.toggle_compact();
assert!(state.compact);
state.toggle_compact();
assert!(!state.compact);
}
#[test]
fn test_copy_selected_no_entries() {
let state = LogcatState::new();
assert!(state.copy_selected_to_clipboard().is_err());
}
#[test]
fn test_bookmark_toggle() {
let mut state = LogcatState::new();
for i in 0..10 {
state.entries.push(LogEntry::parse(&format!("line {}", i)));
state.filtered_indices.push(i);
}
state.selected_line = 3;
state.toggle_bookmark();
assert!(state.is_bookmarked(3));
state.toggle_bookmark();
assert!(!state.is_bookmarked(3));
}
#[test]
fn test_bookmark_navigation() {
let mut state = LogcatState::new();
state.viewport_height = 20;
for i in 0..20 {
state.entries.push(LogEntry::parse(&format!("line {}", i)));
state.filtered_indices.push(i);
}
state.bookmarks.insert(5);
state.bookmarks.insert(15);
state.selected_line = 0;
state.next_bookmark();
assert_eq!(state.selected_line, 5);
state.next_bookmark();
assert_eq!(state.selected_line, 15);
state.next_bookmark();
assert_eq!(state.selected_line, 5);
state.prev_bookmark();
state.prev_bookmark();
assert_eq!(state.selected_line, 5);
}
#[test]
fn test_bookmark_empty_no_panic() {
let mut state = LogcatState::new();
state.next_bookmark(); state.prev_bookmark(); }
#[test]
fn test_clear_resets_bookmarks_and_folds() {
let mut state = LogcatState::new();
state.bookmarks.insert(5);
state.folded_groups.insert(0);
state.detail_open = true;
state.clear();
assert!(state.bookmarks.is_empty());
assert!(state.folded_groups.is_empty());
assert!(!state.detail_open);
}
}