From 588412810f824ae8fc90fd143f93f478cd4ac4cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Le=20Duc=20L=C3=A9andre?= Date: Wed, 10 Sep 2025 19:00:42 +0200 Subject: [PATCH] refactor: index gestion, metadata display and mod session --- src/commands/list.rs | 99 ++++---------------- src/session/display.rs | 66 +++++++++++++ src/session/index.rs | 126 +++++++++++++++++++++++++ src/{session.rs => session/mod.rs} | 145 ++++------------------------- 4 files changed, 228 insertions(+), 208 deletions(-) create mode 100644 src/session/display.rs create mode 100644 src/session/index.rs rename src/{session.rs => session/mod.rs} (62%) diff --git a/src/commands/list.rs b/src/commands/list.rs index 6b11fcb..beacd42 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -1,8 +1,7 @@ use super::RunnableCommand; use crate::errors::ReplayResult; -use crate::session::MetaData; +use crate::session::DisplayMeta; use crate::session::Session; -use chrono::Utc; use clap::Args; #[derive(Args, PartialEq, Eq, Debug)] @@ -10,68 +9,21 @@ pub struct ListCommand {} impl RunnableCommand for ListCommand { fn run(&self) -> ReplayResult<()> { - let list_lines = Self::list()?; - for line in list_lines { - let line = line?; - println!("{}", line); + for session_infos in Self::list()? { + println!("{}", session_infos?) } Ok(()) } } impl ListCommand { - pub fn list() -> ReplayResult>> { - let iter = Session::iter_session_ids_rev()?; - Ok(iter.enumerate().map(|(i, line_res)| { - let line = line_res?; - let session_metadata = Session::load_metadata_by_index(&line)?; - Ok(format!( - "replay@{{{}}}: {}", - i, - Self::display_metadata(session_metadata) - )) - })) - } - fn truncate_description(line: &str, max_len: usize) -> String { - let truncated: String = line.chars().take(max_len).collect(); - if line.chars().count() > max_len { - truncated + "..." - } else { - truncated - } - } - - fn display_metadata(metadata: MetaData) -> String { - if let Some(dess) = metadata.description { - let list_message = format!( - "{}, message: {}", - Self::adapt_date_metadata(metadata.timestamp), - dess, - ); - Self::truncate_description(&list_message, 50) - } else { - let first_commands_stylized = metadata.first_commands.join(" | "); - let list_message = format!( - "{}, commands: {}", - Self::adapt_date_metadata(metadata.timestamp), - first_commands_stylized, - ); - Self::truncate_description(&list_message, 50) - } - } - - fn adapt_date_metadata(timestamp: chrono::DateTime) -> String { - let duration = Utc::now().signed_duration_since(timestamp); - - if duration.num_days() > 0 { - format!("{} days ago", duration.num_days()) - } else if duration.num_hours() > 0 { - format!("{} hours ago", duration.num_hours()) - } else if duration.num_minutes() > 0 { - format!("{} minutes ago", duration.num_minutes()) - } else { - format!("{} seconds ago", duration.num_seconds()) - } + fn list() -> ReplayResult>> { + Ok(Session::get_all_session_metadata()?.enumerate().map( + |(i, metadata)| -> ReplayResult { + let md = metadata?; + Ok(DisplayMeta { index: i, meta: md }.to_string()) + }, + )) } } @@ -79,6 +31,7 @@ impl ListCommand { mod tests { use super::*; use crate::session::tests::setup; + use regex::Regex; use serial_test::serial; #[test] @@ -100,26 +53,14 @@ mod tests { .unwrap() .collect::, _>>() .unwrap(); - assert_eq!( - format!( - "replay@{{0}}: {}, message: session message is too lon...", - ListCommand::adapt_date_metadata(session_3.timestamp), - ), - list_output[0] - ); - assert_eq!( - format!( - "replay@{{1}}: {}, message: test session 2", - ListCommand::adapt_date_metadata(session_2.timestamp) - ), - list_output[1] - ); - assert_eq!( - format!( - "replay@{{2}}: {}, commands: ls | echo test", - ListCommand::adapt_date_metadata(session_1.timestamp) - ), - list_output[2] - ); + let re1 = Regex::new( + r"^replay@\{0\}: \d+ seconds ago, message: session message is too lon\.\.\.$", + ) + .unwrap(); + assert!(re1.is_match(&list_output[0])); + let re2 = Regex::new(r"^replay@\{1\}: \d+ seconds ago, message: test session 2$").unwrap(); + assert!(re2.is_match(&list_output[1])); + let re3 = Regex::new(r"^replay@\{2\}: \d+ seconds ago, commands: ls | echo test$").unwrap(); + assert!(re3.is_match(&list_output[2])); } } diff --git a/src/session/display.rs b/src/session/display.rs new file mode 100644 index 0000000..e5d1c09 --- /dev/null +++ b/src/session/display.rs @@ -0,0 +1,66 @@ +use super::MetaData; +use chrono::Utc; + +/// Small owned wrapper that knows how to format a MetaData for display. +/// We keep it *owned* to simplify usage where metadata comes from an iterator. +pub struct DisplayMeta { + pub index: usize, + pub meta: MetaData, +} + +impl std::fmt::Display for DisplayMeta { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some(desc) = &self.meta.description { + let list_message = format!( + "{}, message: {}", + Self::format_time_ago(self.meta.timestamp), + desc, + ); + write!( + f, + "replay@{{{}}}: {}", + self.index, + Self::truncate_description(&list_message, 50) + ) + } else { + let first_commands_stylized = self.meta.first_commands.join(" | "); + let list_message = format!( + "{}, commands: {}", + Self::format_time_ago(self.meta.timestamp), + first_commands_stylized, + ); + write!( + f, + "replay@{{{}}}: {}", + self.index, + Self::truncate_description(&list_message, 50) + ) + } + } +} + +/// Helpers (kept here for locality). +impl DisplayMeta { + fn format_time_ago(timestamp: chrono::DateTime) -> String { + let duration = Utc::now().signed_duration_since(timestamp); + + if duration.num_days() > 0 { + format!("{} days ago", duration.num_days()) + } else if duration.num_hours() > 0 { + format!("{} hours ago", duration.num_hours()) + } else if duration.num_minutes() > 0 { + format!("{} minutes ago", duration.num_minutes()) + } else { + format!("{} seconds ago", duration.num_seconds()) + } + } + + fn truncate_description(line: &str, max_len: usize) -> String { + let truncated: String = line.chars().take(max_len).collect(); + if line.chars().count() > max_len { + truncated + "..." + } else { + truncated + } + } +} diff --git a/src/session/index.rs b/src/session/index.rs new file mode 100644 index 0000000..1ca0b07 --- /dev/null +++ b/src/session/index.rs @@ -0,0 +1,126 @@ +use crate::errors::{ReplayError, ReplayResult}; +use crate::paths; +use std::fs::File; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::PathBuf; + +const INDEX_SIZE: u64 = 64; +pub struct SessionIndexFile; + +struct RevIndexIter { + file: File, + index_size: u64, + current: u64, +} + +impl RevIndexIter { + pub fn new(file: File, index_size: u64) -> ReplayResult { + let metadata = file.metadata()?; + let total_records = metadata.len() / index_size; + Ok(Self { + file, + index_size, + current: total_records, + }) + } +} + +impl Iterator for RevIndexIter { + type Item = ReplayResult; + + fn next(&mut self) -> Option { + if self.current == 0 { + return None; + } + self.current -= 1; + let pos = self.current * self.index_size; + let result = (|| -> ReplayResult { + self.file.seek(SeekFrom::Start(pos))?; + let mut buf = vec![0u8; self.index_size as usize]; + self.file.read_exact(&mut buf)?; + Ok(String::from_utf8_lossy(&buf).to_string()) + })(); + + Some(result) + } +} + +impl SessionIndexFile { + pub(super) fn get_path() -> PathBuf { + paths::replay_dir().join("session_idx") + } + + fn open_file() -> ReplayResult { + Ok(std::fs::OpenOptions::new() + .read(true) + .create(true) + .append(true) + .open(Self::get_path())?) + } + + pub fn push_session(session_id: &str) -> ReplayResult<()> { + let mut file = Self::open_file()?; + write!(file, "{}", session_id)?; + Ok(()) + } + + fn get_id_offset_by_index(n: u32) -> ReplayResult { + let file = Self::open_file()?; + let file_size = file.metadata()?.len(); + if file_size == 0 { + return Err(ReplayError::SessionError("No replay entries found".into())); + } + + let total_lines = file_size / INDEX_SIZE; + if n as u64 >= total_lines { + return Err(ReplayError::SessionError( + "Replay index out of range".into(), + )); + } + + Ok(file_size - (n as u64 + 1) * INDEX_SIZE) + } + + fn read_id_at(offset: u64) -> Result { + let mut file = Self::open_file()?; + file.seek(SeekFrom::Start(offset))?; + let mut buf = vec![0u8; INDEX_SIZE as usize]; + file.read_exact(&mut buf)?; + + Ok(String::from_utf8_lossy(&buf).to_string()) + } + + /// Get the nth session id and remove it from the file + pub fn remove_session_id(n: u32) -> ReplayResult { + let mut file = Self::open_file()?; + let id_offset = Self::get_id_offset_by_index(n)?; + + let session_id = Self::read_id_at(id_offset)?; + + // Calculate the position of next id + let next_id_offset = id_offset + INDEX_SIZE; + + // Read the end of the file after this line + let mut rest = Vec::new(); + file.seek(SeekFrom::Start(next_id_offset))?; + file.read_to_end(&mut rest)?; + + // Truncate and rewrite the rest + file.set_len(id_offset)?; + file.seek(SeekFrom::Start(id_offset))?; + file.write_all(&rest)?; + file.flush()?; + + Ok(session_id) + } + + pub fn get_session_id(index: u32) -> ReplayResult { + let line_offset = Self::get_id_offset_by_index(index)?; + Self::read_id_at(line_offset) + } + pub fn iter_session_ids_rev() -> ReplayResult>> { + let file = SessionIndexFile::open_file()?; + let iter = RevIndexIter::new(file, INDEX_SIZE)?; + Ok(iter) + } +} diff --git a/src/session.rs b/src/session/mod.rs similarity index 62% rename from src/session.rs rename to src/session/mod.rs index 3f736c3..c63f754 100644 --- a/src/session.rs +++ b/src/session/mod.rs @@ -1,13 +1,17 @@ -use crate::errors::{ReplayError, ReplayResult}; +use crate::errors::ReplayResult; use crate::paths; use chrono::Utc; -use rev_lines::RevLines; use serde::{de::DeserializeOwned, Deserialize, Deserializer, Serialize}; use sha2::{Digest, Sha256}; use std::fs::File; -use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write}; +use std::io::BufReader; use std::path::PathBuf; +mod display; +pub mod index; + +pub use display::DisplayMeta; +pub use index::SessionIndexFile; const DEFAULT_COMPRESSION_LEVEL: i32 = 3; #[derive(Default, Serialize, Deserialize)] @@ -37,128 +41,6 @@ where .map(|cmd| cmd.replace("\r", "")) .collect()) } -struct SessionIndexFile; - -impl SessionIndexFile { - fn get_path() -> PathBuf { - paths::replay_dir().join("session_idx") - } - - fn open_file() -> ReplayResult { - Ok(std::fs::OpenOptions::new() - .read(true) - .create(true) - .append(true) - .open(Self::get_path())?) - } - - pub fn push_session(session_id: &str) -> ReplayResult<()> { - let mut file = Self::open_file()?; - writeln!(file, "{}", session_id)?; - Ok(()) - } - - /// Read the file by the end and give the byte offset of the nth line - fn get_line_offset_by_index(n: u32) -> ReplayResult { - let mut file = Self::open_file()?; - let mut offset = file.seek(SeekFrom::End(0))?; - let mut buf = [0u8; 1]; - let mut newlines_count = 0; - - if offset == 0 { - return Err(ReplayError::SessionError("No replay entries found".into())); - } - - // If the file is not empty, check the last byte - file.seek(SeekFrom::Start(offset - 1))?; - file.read_exact(&mut buf)?; - - // If the last byte is a newline, skip it - // This ensures we don't count an extra empty line at the end - if buf[0] == b'\n' { - offset -= 1; - } - - // Now we scan the file backwards to count newlines - while offset > 0 { - // Move back by one byte - offset -= 1; - file.seek(SeekFrom::Start(offset))?; - file.read_exact(&mut buf)?; - - // If we encounter a newline, we found the end of the previous line - if buf[0] == b'\n' { - newlines_count += 1; - - // If we've counted enough newlines to reach the target line - // `n + 1` because index 0 refers to the last line, index 1 to the second last, etc. - if newlines_count == n + 1 { - // The start of the target line is just after this newline - return Ok(offset + 1); - } - } - } - - if newlines_count <= n { - if n == newlines_count { - return Ok(0); - } else { - return Err(ReplayError::SessionError( - "Replay index out of range".into(), - )); - } - } - - Err(ReplayError::SessionError("No replay entries found".into())) - } - - /// Read the line starting at a given byte position - fn read_line_at(offset: u64) -> Result { - let mut file = Self::open_file()?; - file.seek(SeekFrom::Start(offset))?; - // We use a BufReader for the `read_until()` func - let mut reader = BufReader::new(file); - let mut buf = Vec::new(); - // We read until a \n instead of reading the entire file - reader.read_until(b'\n', &mut buf)?; - let line = String::from_utf8_lossy(&buf) - .trim_end_matches('\n') - .to_string(); - Ok(line) - } - - /// Get the nth session id and remove it from the file - pub fn remove_session_id(n: u32) -> ReplayResult { - let mut file = Self::open_file()?; - let line_start_offset = Self::get_line_offset_by_index(n)?; - - let session_id = Self::read_line_at(line_start_offset)?; - - // Calculate the position of next line - // Note: `read_line_at` returns the line without the trailing newline character, - // so `session_id.len()` does not include the newline. The actual line in the file - // is `session_id.len() + 1` bytes (session ID plus '\n'), so this calculation is correct. - let next_line_offset = line_start_offset + session_id.len() as u64 + 1; - - // Read the end of the file after this line - let mut rest = Vec::new(); - file.seek(SeekFrom::Start(next_line_offset))?; - file.read_to_end(&mut rest)?; - - // Truncate and rewrite the rest - file.set_len(line_start_offset)?; - file.seek(SeekFrom::Start(line_start_offset))?; - file.write_all(&rest)?; - file.flush()?; - - Ok(session_id) - } - - pub fn get_session_id(index: u32) -> ReplayResult { - let line_offset = Self::get_line_offset_by_index(index)?; - Self::read_line_at(line_offset) - } -} impl Session { pub fn new(description: Option) -> ReplayResult { @@ -272,16 +154,21 @@ impl Session { paths::session_dir().join(format!("{}.{}", id, extension)) } - pub fn iter_session_ids_rev() -> ReplayResult>> { - let file = SessionIndexFile::open_file()?; - let rev_lines = RevLines::new(file); - Ok(rev_lines.map(|line_res| line_res.map_err(ReplayError::from))) + pub fn get_all_session_metadata() -> ReplayResult>> + { + Ok( + SessionIndexFile::iter_session_ids_rev()?.map(|index| -> ReplayResult { + let index = index?; + Session::load_metadata_by_index(&index) + }), + ) } } #[cfg(test)] pub mod tests { use super::*; + use crate::errors::ReplayError; use serial_test::serial; pub fn setup() {