use async_trait::async_trait;
use super::{Builtin, Context, read_text_file};
use crate::error::Result;
use crate::interpreter::ExecResult;
const DEFAULT_LINES: usize = 10;
pub struct Head;
#[async_trait]
impl Builtin for Head {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
if let Some(r) = super::check_help_version(
ctx.args,
"Usage: head [OPTION]... [FILE]...\nPrint the first 10 lines of each FILE to standard output.\n\n -n NUM\toutput the first NUM lines\n -c NUM\toutput the first NUM bytes\n -NUM\t\tshorthand for -n NUM\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n",
Some("head (bashkit) 0.1"),
) {
return Ok(r);
}
let (count, byte_mode, files) = match parse_head_args(ctx.args, DEFAULT_LINES) {
Ok(v) => v,
Err(e) => return Ok(e),
};
let mut output = String::new();
if files.is_empty() {
if let Some(stdin) = ctx.stdin {
if byte_mode {
return Ok(ExecResult::ok_bytes(
stdin.as_bytes()[..stdin.len().min(count)].to_vec(),
));
} else {
output = take_first_lines(&stdin.text_lossy(), count);
}
}
} else {
let multiple_files = files.len() > 1;
for (i, file) in files.iter().enumerate() {
if multiple_files {
if i > 0 {
output.push('\n');
}
output.push_str(&format!("==> {} <==\n", file));
}
let path = if file.starts_with('/') {
std::path::PathBuf::from(file)
} else {
ctx.cwd.join(file)
};
match ctx.fs.read_file(&path).await {
Ok(content) => {
if byte_mode {
let bytes = &content[..content.len().min(count)];
if files.len() == 1 {
return Ok(ExecResult::ok_bytes(bytes.to_vec()));
}
output.push_str(&String::from_utf8_lossy(bytes));
} else {
output.push_str(&take_first_lines(
&String::from_utf8_lossy(&content),
count,
));
}
}
Err(e) => {
return Ok(ExecResult::err(format!("head: {}: {}\n", file, e), 1));
}
}
}
}
Ok(ExecResult::ok(output))
}
}
pub struct Tail;
#[async_trait]
impl Builtin for Tail {
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
if let Some(r) = super::check_help_version(
ctx.args,
"Usage: tail [OPTION]... [FILE]...\nPrint the last 10 lines of each FILE to standard output.\n\n -n NUM\toutput the last NUM lines\n -n +NUM\toutput starting with line NUM\n -NUM\t\tshorthand for -n NUM\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n",
Some("tail (bashkit) 0.1"),
) {
return Ok(r);
}
let (num_lines, from_start, files) = match parse_tail_args(ctx.args, DEFAULT_LINES) {
Ok(v) => v,
Err(e) => return Ok(e),
};
let mut output = String::new();
if files.is_empty() {
if let Some(stdin) = ctx.stdin {
let stdin = stdin.text_lossy();
output = if from_start {
take_from_line(&stdin, num_lines)
} else {
take_last_lines(&stdin, num_lines)
};
}
} else {
let multiple_files = files.len() > 1;
for (i, file) in files.iter().enumerate() {
if multiple_files {
if i > 0 {
output.push('\n');
}
output.push_str(&format!("==> {} <==\n", file));
}
let path = if file.starts_with('/') {
std::path::PathBuf::from(file)
} else {
ctx.cwd.join(file)
};
let text = match read_text_file(&*ctx.fs, &path, "tail").await {
Ok(t) => t,
Err(e) => return Ok(e),
};
let selected = if from_start {
take_from_line(&text, num_lines)
} else {
take_last_lines(&text, num_lines)
};
output.push_str(&selected);
}
}
Ok(ExecResult::ok(output))
}
}
#[allow(clippy::result_large_err)]
fn parse_head_args(
args: &[String],
default: usize,
) -> std::result::Result<(usize, bool, Vec<String>), ExecResult> {
let mut count = default;
let mut byte_mode = false;
let mut files = Vec::new();
let mut p = super::arg_parser::ArgParser::new(args);
while !p.is_done() {
if let Some(val) = p.flag_value_opt("-n") {
count = val.parse().unwrap_or(default);
byte_mode = false;
} else if let Some(val) = p.flag_value_opt("-c") {
count = val.parse().unwrap_or(default);
byte_mode = true;
} else if let Some(arg) = p.current().filter(|a| a.starts_with('-')) {
if let Some(num_str) = arg.strip_prefix('-')
&& let Ok(n) = num_str.parse::<usize>()
{
count = n;
p.advance();
} else if arg == "-" || arg == "--" {
p.advance();
} else {
return Err(super::invalid_option("head", arg, 1));
}
} else if let Some(arg) = p.positional() {
files.push(arg.to_string());
}
}
Ok((count, byte_mode, files))
}
#[allow(clippy::result_large_err)]
fn parse_tail_args(
args: &[String],
default: usize,
) -> std::result::Result<(usize, bool, Vec<String>), ExecResult> {
let mut num_lines = default;
let mut from_start = false;
let mut files = Vec::new();
let mut p = super::arg_parser::ArgParser::new(args);
while !p.is_done() {
if let Some(val) = p.flag_value_opt("-n") {
if let Some(pos_str) = val.strip_prefix('+') {
from_start = true;
num_lines = pos_str.parse().unwrap_or(default);
} else {
from_start = false;
num_lines = val.parse().unwrap_or(default);
}
} else if let Some(arg) = p.current().filter(|a| a.starts_with('-')) {
if let Some(num_str) = arg.strip_prefix('-')
&& let Ok(n) = num_str.parse::<usize>()
{
num_lines = n;
p.advance();
} else if arg == "-" || arg == "--" {
p.advance();
} else {
return Err(super::invalid_option("tail", arg, 1));
}
} else if let Some(arg) = p.positional() {
files.push(arg.to_string());
}
}
Ok((num_lines, from_start, files))
}
fn take_first_lines(text: &str, n: usize) -> String {
let lines: Vec<&str> = text.lines().take(n).collect();
if lines.is_empty() {
String::new()
} else {
let mut result = lines.join("\n");
if text.ends_with('\n') || !text.is_empty() {
result.push('\n');
}
result
}
}
fn take_from_line(text: &str, n: usize) -> String {
let lines: Vec<&str> = text.lines().collect();
let start = if n == 0 { 0 } else { n - 1 };
let selected: Vec<&str> = lines.into_iter().skip(start).collect();
if selected.is_empty() {
String::new()
} else {
let mut result = selected.join("\n");
if text.ends_with('\n') || !text.is_empty() {
result.push('\n');
}
result
}
}
fn take_last_lines(text: &str, n: usize) -> String {
let lines: Vec<&str> = text.lines().collect();
let start = lines.len().saturating_sub(n);
let selected: Vec<&str> = lines[start..].to_vec();
if selected.is_empty() {
String::new()
} else {
let mut result = selected.join("\n");
if text.ends_with('\n') || !text.is_empty() {
result.push('\n');
}
result
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use crate::fs::InMemoryFs;
async fn run_head(args: &[&str], stdin: Option<&str>) -> ExecResult {
let fs = Arc::new(InMemoryFs::new());
let mut variables = HashMap::new();
let env = HashMap::new();
let mut cwd = PathBuf::from("/");
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
let ctx = Context {
args: &args,
env: &env,
variables: &mut variables,
cwd: &mut cwd,
fs,
stdin: crate::builtins::test_stream_opt(stdin),
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};
Head.execute(ctx).await.unwrap()
}
async fn run_tail(args: &[&str], stdin: Option<&str>) -> ExecResult {
let fs = Arc::new(InMemoryFs::new());
let mut variables = HashMap::new();
let env = HashMap::new();
let mut cwd = PathBuf::from("/");
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
let ctx = Context {
args: &args,
env: &env,
variables: &mut variables,
cwd: &mut cwd,
fs,
stdin: crate::builtins::test_stream_opt(stdin),
#[cfg(feature = "http_client")]
http_client: None,
#[cfg(feature = "git")]
git_client: None,
#[cfg(feature = "ssh")]
ssh_client: None,
shell: None,
};
Tail.execute(ctx).await.unwrap()
}
#[tokio::test]
async fn test_head_default() {
let input = "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n";
let result = run_head(&[], Some(input)).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n");
}
#[tokio::test]
async fn test_head_n_flag() {
let input = "a\nb\nc\nd\ne\n";
let result = run_head(&["-n", "3"], Some(input)).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "a\nb\nc\n");
}
#[tokio::test]
async fn test_head_shorthand() {
let input = "a\nb\nc\nd\ne\n";
let result = run_head(&["-2"], Some(input)).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "a\nb\n");
}
#[tokio::test]
async fn test_head_invalid_option() {
let result = run_head(&["-Q"], Some("a\nb\n")).await;
assert_eq!(result.exit_code, 1);
assert!(result.stderr.contains("invalid option -- 'Q'"));
}
#[tokio::test]
async fn test_head_c_multibyte_stdin_respects_byte_limit() {
let result = run_head(&["-c", "1"], Some("éX")).await;
assert_eq!(result.exit_code, 0);
assert!(
result.stdout.len() <= 1,
"head -c 1 must not emit more than 1 byte for UTF-8 stdin"
);
let result = run_head(&["-c", "2"], Some("éX")).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "é");
assert_eq!(result.stdout.len(), 2);
}
#[tokio::test]
async fn test_tail_default() {
let input = "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n";
let result = run_tail(&[], Some(input)).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n");
}
#[tokio::test]
async fn test_tail_n_flag() {
let input = "a\nb\nc\nd\ne\n";
let result = run_tail(&["-n", "3"], Some(input)).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "c\nd\ne\n");
}
#[tokio::test]
async fn test_tail_shorthand() {
let input = "a\nb\nc\nd\ne\n";
let result = run_tail(&["-2"], Some(input)).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "d\ne\n");
}
#[tokio::test]
async fn test_tail_invalid_option() {
let result = run_tail(&["-Q"], Some("a\nb\n")).await;
assert_eq!(result.exit_code, 1);
assert!(result.stderr.contains("invalid option -- 'Q'"));
}
#[tokio::test]
async fn test_head_empty_input() {
let result = run_head(&[], Some("")).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "");
}
#[tokio::test]
async fn test_tail_empty_input() {
let result = run_tail(&[], Some("")).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "");
}
#[tokio::test]
async fn test_head_fewer_lines_than_requested() {
let input = "a\nb\n";
let result = run_head(&["-n", "10"], Some(input)).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "a\nb\n");
}
#[tokio::test]
async fn test_tail_fewer_lines_than_requested() {
let input = "a\nb\n";
let result = run_tail(&["-n", "10"], Some(input)).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "a\nb\n");
}
#[tokio::test]
async fn test_tail_plus_n_from_start() {
let input = "header\nline1\nline2\nline3\n";
let result = run_tail(&["-n", "+2"], Some(input)).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "line1\nline2\nline3\n");
}
#[tokio::test]
async fn test_tail_plus_1_all_lines() {
let input = "a\nb\nc\n";
let result = run_tail(&["-n", "+1"], Some(input)).await;
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "a\nb\nc\n");
}
}