diff --git a/executables/shell/command_line/src/commands/head.rs b/executables/shell/command_line/src/commands/head.rs new file mode 100644 index 00000000..be6d9348 --- /dev/null +++ b/executables/shell/command_line/src/commands/head.rs @@ -0,0 +1,175 @@ +use crate::{Error, Result}; +use alloc::{borrow::ToOwned, vec::Vec}; +use executable_macros::GetArgs; +use xila::{ + file_system::{AccessFlags, Path}, + virtual_file_system::{self, File}, +}; + +use super::{CommandContext, UserCommand}; + +pub struct HeadCommand; + +impl UserCommand for HeadCommand { + async fn execute<'a, I, C>( + &self, + context: &mut C, + options: &mut getargs::Options<&'a str, I>, + _paths: &[&Path], + ) -> Result<()> + where + I: Iterator, + C: CommandContext, + { + execute_head(context, options).await + } +} + +#[derive(GetArgs)] +struct HeadArguments<'a> { + path: &'a str, + #[arg(short = 'n', long = "lines", default = 10)] + lines: usize, +} + +fn resolve_path( + context: &C, + path: &str, +) -> Result { + let path = Path::from_str(path); + + if path.is_absolute() { + Ok(path.to_owned()) + } else { + context + .current_directory_owned() + .join(path) + .ok_or(Error::FailedToJoinPath) + } +} + +async fn read_head_bytes(mut file: File, lines_to_keep: usize) -> Result> { + let mut output = Vec::new(); + if lines_to_keep == 0 { + return Ok(output); + } + + let mut remaining_lines = lines_to_keep; + let mut buffer = [0u8; 256]; + + loop { + let bytes_read = file + .read(&mut buffer) + .await + .map_err(Error::FailedToReadFile)?; + + if bytes_read == 0 { + break; + } + + for &byte in &buffer[..bytes_read] { + output.push(byte); + + if byte == b'\n' { + remaining_lines -= 1; + if remaining_lines == 0 { + return Ok(output); + } + } + } + } + + Ok(output) +} + +async fn execute_head<'a, I, C>( + context: &mut C, + options: &mut getargs::Options<&'a str, I>, +) -> Result<()> +where + I: Iterator, + C: CommandContext, +{ + let HeadArguments { path, lines } = HeadArguments::parse(options)?; + let path = resolve_path(context, path)?; + + let file = File::open( + virtual_file_system::get_instance(), + context.task_id(), + &path, + AccessFlags::Read.into(), + ) + .await + .map_err(Error::FailedToOpenFile)?; + + let output = read_head_bytes(file, lines).await?; + context.write_out(&output).await; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use alloc::{vec, vec::Vec}; + use getargs::Options; + + use super::HeadArguments; + + fn select_head_bytes(input: &[u8], lines_to_keep: usize) -> Vec { + let mut output = Vec::new(); + if lines_to_keep == 0 { + return output; + } + + let mut remaining_lines = lines_to_keep; + for &byte in input { + output.push(byte); + if byte == b'\n' { + remaining_lines -= 1; + if remaining_lines == 0 { + break; + } + } + } + + output + } + + #[test] + fn keeps_first_two_lines_with_trailing_newline() { + let input = b"line1\nline2\nline3\n"; + assert_eq!(select_head_bytes(input, 2), b"line1\nline2\n"); + } + + #[test] + fn keeps_all_when_fewer_than_requested() { + let input = b"line1\nline2\n"; + assert_eq!(select_head_bytes(input, 10), input); + } + + #[test] + fn handles_file_without_trailing_newline() { + let input = b"line1\nline2"; + assert_eq!(select_head_bytes(input, 1), b"line1\n"); + assert_eq!(select_head_bytes(input, 2), input); + } + + #[test] + fn keeps_nothing_when_zero_lines_requested() { + let input = b"line1\nline2\n"; + assert_eq!(select_head_bytes(input, 0), vec![]); + } + + #[test] + fn parses_option_value_before_positional_path() { + let args = ["-n", "2", "notes.txt"]; + let mut options = Options::new(args.into_iter()); + + let parsed = HeadArguments::parse(&mut options); + + assert!(parsed.is_ok()); + let parsed = parsed.unwrap(); + assert_eq!(parsed.lines, 2); + assert_eq!(parsed.path, "notes.txt"); + } +} diff --git a/executables/shell/command_line/src/commands/mod.rs b/executables/shell/command_line/src/commands/mod.rs index 49079bf3..afe1d9bf 100644 --- a/executables/shell/command_line/src/commands/mod.rs +++ b/executables/shell/command_line/src/commands/mod.rs @@ -8,11 +8,13 @@ mod echo; mod environment_variables; mod execute; mod exit; +mod head; mod ip; mod list; mod ping; mod print_working_directory; mod statistics; +mod tail; mod web_request; mod which; mod word_count; @@ -35,11 +37,13 @@ use self::{ SetEnvironmentVariableCommand, }, exit::ExitCommand, + head::HeadCommand, ip::IpCommand, list::ListCommand, ping::PingCommand, print_working_directory::PrintWorkingDirectoryCommand, statistics::StatisticsCommand, + tail::TailCommand, web_request::WebRequestCommand, which::WhichCommand, word_count::WordCountCommand, @@ -92,6 +96,8 @@ pub enum UserCommandKind { PrintEnvironmentVariable, Which, WordCount, + Head, + Tail, } pub fn resolve_user_command(name: &str) -> Option { @@ -115,6 +121,8 @@ pub fn resolve_user_command(name: &str) -> Option { "printenv" => Some(UserCommandKind::PrintEnvironmentVariable), "which" => Some(UserCommandKind::Which), "wc" => Some(UserCommandKind::WordCount), + "head" => Some(UserCommandKind::Head), + "tail" => Some(UserCommandKind::Tail), _ => None, } } @@ -173,6 +181,8 @@ where } UserCommandKind::Which => WhichCommand.execute(context, options, paths).await, UserCommandKind::WordCount => WordCountCommand.execute(context, options, paths).await, + UserCommandKind::Head => HeadCommand.execute(context, options, paths).await, + UserCommandKind::Tail => TailCommand.execute(context, options, paths).await, } } @@ -269,6 +279,14 @@ mod tests { resolve_user_command("wc"), Some(UserCommandKind::WordCount) )); + assert!(matches!( + resolve_user_command("head"), + Some(UserCommandKind::Head) + )); + assert!(matches!( + resolve_user_command("tail"), + Some(UserCommandKind::Tail) + )); assert!(resolve_user_command("unknown").is_none()); } } diff --git a/executables/shell/command_line/src/commands/tail.rs b/executables/shell/command_line/src/commands/tail.rs new file mode 100644 index 00000000..2226adf9 --- /dev/null +++ b/executables/shell/command_line/src/commands/tail.rs @@ -0,0 +1,167 @@ +use crate::{Error, Result}; +use alloc::{borrow::ToOwned, vec::Vec}; +use executable_macros::GetArgs; +use xila::{ + file_system::{AccessFlags, Path}, + virtual_file_system::{self, File}, +}; + +use super::{CommandContext, UserCommand}; + +pub struct TailCommand; + +impl UserCommand for TailCommand { + async fn execute<'a, I, C>( + &self, + context: &mut C, + options: &mut getargs::Options<&'a str, I>, + _paths: &[&Path], + ) -> Result<()> + where + I: Iterator, + C: CommandContext, + { + execute_tail(context, options).await + } +} + +#[derive(GetArgs)] +struct TailArguments<'a> { + path: &'a str, + #[arg(short = 'n', long = "lines", default = 10)] + lines: usize, +} + +fn resolve_path( + context: &C, + path: &str, +) -> Result { + let path = Path::from_str(path); + + if path.is_absolute() { + Ok(path.to_owned()) + } else { + context + .current_directory_owned() + .join(path) + .ok_or(Error::FailedToJoinPath) + } +} + +fn trim_to_last_lines(buffer: &mut Vec, lines_to_keep: usize) { + if lines_to_keep == 0 { + buffer.clear(); + return; + } + + let trailing_newline = buffer.last().copied() == Some(b'\n'); + let newline_count = buffer.iter().filter(|&&byte| byte == b'\n').count(); + let line_count = newline_count + usize::from(!buffer.is_empty() && !trailing_newline); + + if line_count <= lines_to_keep { + return; + } + + let separators_to_skip = line_count - lines_to_keep; + + let mut skipped = 0; + let mut start_index = 0; + + for (index, &byte) in buffer.iter().enumerate() { + if byte == b'\n' { + skipped += 1; + if skipped == separators_to_skip { + start_index = index + 1; + break; + } + } + } + + buffer.drain(..start_index); +} + +async fn read_tail_bytes(mut file: File, lines_to_keep: usize) -> Result> { + let mut output = Vec::new(); + if lines_to_keep == 0 { + return Ok(output); + } + + let mut buffer = [0u8; 256]; + loop { + let bytes_read = file + .read(&mut buffer) + .await + .map_err(Error::FailedToReadFile)?; + + if bytes_read == 0 { + break; + } + + output.extend_from_slice(&buffer[..bytes_read]); + trim_to_last_lines(&mut output, lines_to_keep); + } + + Ok(output) +} + +async fn execute_tail<'a, I, C>( + context: &mut C, + options: &mut getargs::Options<&'a str, I>, +) -> Result<()> +where + I: Iterator, + C: CommandContext, +{ + let TailArguments { path, lines } = TailArguments::parse(options)?; + let path = resolve_path(context, path)?; + + let file = File::open( + virtual_file_system::get_instance(), + context.task_id(), + &path, + AccessFlags::Read.into(), + ) + .await + .map_err(Error::FailedToOpenFile)?; + + let output = read_tail_bytes(file, lines).await?; + context.write_out(&output).await; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::trim_to_last_lines; + use alloc::{vec, vec::Vec}; + + fn select_tail_bytes(input: &[u8], lines_to_keep: usize) -> Vec { + let mut output = input.to_vec(); + trim_to_last_lines(&mut output, lines_to_keep); + output + } + + #[test] + fn keeps_last_two_lines_with_trailing_newline() { + let input = b"line1\nline2\nline3\n"; + assert_eq!(select_tail_bytes(input, 2), b"line2\nline3\n"); + } + + #[test] + fn keeps_all_when_fewer_than_requested() { + let input = b"line1\nline2\n"; + assert_eq!(select_tail_bytes(input, 10), input); + } + + #[test] + fn handles_file_without_trailing_newline() { + let input = b"line1\nline2\nline3"; + assert_eq!(select_tail_bytes(input, 2), b"line2\nline3"); + } + + #[test] + fn keeps_nothing_when_zero_lines_requested() { + let input = b"line1\nline2\n"; + assert_eq!(select_tail_bytes(input, 0), vec![]); + } +} diff --git a/executables/shell/graphical/src/lib.rs b/executables/shell/graphical/src/lib.rs index 5a435763..7bfd9c8c 100644 --- a/executables/shell/graphical/src/lib.rs +++ b/executables/shell/graphical/src/lib.rs @@ -115,3 +115,21 @@ impl Shell { Ok(()) } } + +#[cfg(test)] +mod tests { + use getargs::Options; + + use super::GraphicalShellArguments; + + #[test] + fn parses_show_keyboard_flag_without_panic() { + let args = ["--show-keyboard"]; + let mut options = Options::new(args.into_iter()); + + let parsed = GraphicalShellArguments::parse(&mut options); + + assert!(parsed.is_ok()); + assert!(parsed.unwrap().show_keyboard); + } +} diff --git a/modules/executable/macros/src/lib.rs b/modules/executable/macros/src/lib.rs index 0cbb8c02..d7aed0a0 100644 --- a/modules/executable/macros/src/lib.rs +++ b/modules/executable/macros/src/lib.rs @@ -212,15 +212,15 @@ pub fn derive_get_args(input: TokenStream) -> TokenStream { } else if is_str_reference_type(field_type) { quote! { let value = options - .next_positional() - .ok_or(crate::Error::MissingPositionalArgument(#long_name))?; + .value() + .map_err(|_| crate::Error::MissingPositionalArgument(#long_name))?; #value_ident = value; } } else { quote! { let value = options - .next_positional() - .ok_or(crate::Error::MissingPositionalArgument(#long_name))?; + .value() + .map_err(|_| crate::Error::MissingPositionalArgument(#long_name))?; #value_ident = value.parse().map_err(|_| crate::Error::InvalidOption)?; } };