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) = parse_head_args(ctx.args, DEFAULT_LINES)?;
let mut output = String::new();
if files.is_empty() {
if let Some(stdin) = ctx.stdin {
if byte_mode {
output = take_first_bytes(stdin, count);
} else {
output = take_first_lines(stdin, 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)];
output.push_str(&decode_file_bytes_for_path(&path, bytes));
} else {
output.push_str(&take_first_lines(
&decode_file_bytes_for_path(&path, &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) = parse_tail_args(ctx.args, DEFAULT_LINES)?;
let mut output = String::new();
if files.is_empty() {
if let Some(stdin) = ctx.stdin {
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))
}
}
fn parse_head_args(args: &[String], default: usize) -> Result<(usize, bool, Vec<String>)> {
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 let Some(arg) = p.positional() {
files.push(arg.to_string());
}
}
Ok((count, byte_mode, files))
}
fn normalize_vfs_path(path: &std::path::Path) -> std::path::PathBuf {
path.components()
.fold(std::path::PathBuf::new(), |mut acc, c| match c {
std::path::Component::ParentDir => {
acc.pop();
acc
}
std::path::Component::CurDir => acc,
c => {
acc.push(c);
acc
}
})
}
fn decode_file_bytes_for_path(path: &std::path::Path, bytes: &[u8]) -> String {
let normalized = normalize_vfs_path(path);
if normalized == std::path::Path::new("/dev/urandom")
|| normalized == std::path::Path::new("/dev/random")
{
latin1_bytes_to_string(bytes)
} else {
std::str::from_utf8(bytes)
.map(str::to_owned)
.unwrap_or_else(|_| latin1_bytes_to_string(bytes))
}
}
fn latin1_bytes_to_string(bytes: &[u8]) -> String {
bytes.iter().map(|&b| b as char).collect()
}
fn take_first_bytes(text: &str, n: usize) -> String {
if looks_like_latin1_binary(text) {
return text.chars().take(n).collect();
}
let mut end = text.len().min(n);
while end > 0 && !text.is_char_boundary(end) {
end -= 1;
}
text[..end].to_string()
}
fn looks_like_latin1_binary(text: &str) -> bool {
text.chars()
.any(|c| matches!(c, '\0'..='\x08' | '\x0b'..='\x0c' | '\x0e'..='\x1f' | '\x7f'..='\u{9f}'))
}
fn parse_tail_args(args: &[String], default: usize) -> Result<(usize, bool, Vec<String>)> {
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 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,
#[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,
#[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_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);
}
#[test]
fn test_head_c_preserves_latin1_binary_byte_model() {
let input = "A\0éZ";
let output = take_first_bytes(input, 3);
assert_eq!(output.chars().count(), 3);
assert_eq!(output, "A\0é");
}
#[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_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");
}
}