use crate::filesystem::validate_path;
use crate::prelude::*;
use std::path::PathBuf;
use tokio::fs;
#[derive(Debug, Deserialize, JsonSchema)]
pub struct FileInfoInput {
pub path: PathBuf,
}
fn format_size(size: u64) -> String {
if size < 1024 {
format!("{} bytes", size)
} else if size < 1024 * 1024 {
format!("{:.2} KB ({} bytes)", size as f64 / 1024.0, size)
} else if size < 1024 * 1024 * 1024 {
format!("{:.2} MB ({} bytes)", size as f64 / (1024.0 * 1024.0), size)
} else {
format!(
"{:.2} GB ({} bytes)",
size as f64 / (1024.0 * 1024.0 * 1024.0),
size
)
}
}
pub struct FileInfoTool {
base_path: PathBuf,
}
impl Default for FileInfoTool {
fn default() -> Self {
Self::new()
}
}
impl FileInfoTool {
pub fn new() -> Self {
Self {
base_path: std::env::current_dir().expect("Failed to get current working directory"),
}
}
pub fn try_new() -> std::io::Result<Self> {
Ok(Self {
base_path: std::env::current_dir()?,
})
}
pub fn with_base_path(base_path: PathBuf) -> Self {
Self { base_path }
}
}
impl Tool for FileInfoTool {
type Input = FileInfoInput;
fn name(&self) -> &str {
"file_info"
}
fn description(&self) -> &str {
"Get detailed information about a file including size, type, and modification time."
}
async fn execute(&self, input: Self::Input) -> std::result::Result<ToolResult, ToolError> {
let _validated_path = validate_path(&self.base_path, &input.path)
.map_err(|e| ToolError::from(e.to_string()))?;
let uncanonicalized_path = if input.path.is_absolute() {
input.path.clone()
} else {
self.base_path.join(&input.path)
};
let metadata = fs::symlink_metadata(&uncanonicalized_path)
.await
.map_err(|e| ToolError::from(format!("Failed to read file metadata: {}", e)))?;
let file_type = if metadata.is_symlink() {
"Symbolic Link"
} else if metadata.is_dir() {
"Directory"
} else {
"File"
};
let size_str = format_size(metadata.len());
let modified = metadata
.modified()
.ok()
.and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
.map(|duration| {
use chrono::{DateTime, Utc};
let datetime = DateTime::from_timestamp(duration.as_secs() as i64, 0)
.unwrap_or(DateTime::<Utc>::MIN_UTC);
datetime.format("%Y-%m-%d %H:%M:%S UTC").to_string()
})
.unwrap_or_else(|| "Unknown".to_string());
let mime_type = if metadata.is_symlink() {
"N/A".to_string()
} else if metadata.is_file() {
infer::get_from_path(&uncanonicalized_path)
.ok()
.flatten()
.map(|kind| kind.mime_type().to_string())
.or_else(|| {
mime_guess::from_path(&uncanonicalized_path)
.first()
.map(|m| m.to_string())
})
.unwrap_or_else(|| "application/octet-stream".to_string())
} else {
"N/A".to_string()
};
let readonly = metadata.permissions().readonly();
let symlink_target = if metadata.is_symlink() {
fs::read_link(&uncanonicalized_path)
.await
.ok()
.map(|p| p.display().to_string())
} else {
None
};
let content = if let Some(target) = symlink_target {
format!(
"File Information: {}\n\
Type: {}\n\
Target: {}\n\
Size: {}\n\
MIME Type: {}\n\
Modified: {}\n\
Read-only: {}",
input.path.display(),
file_type,
target,
size_str,
mime_type,
modified,
readonly
)
} else {
format!(
"File Information: {}\n\
Type: {}\n\
Size: {}\n\
MIME Type: {}\n\
Modified: {}\n\
Read-only: {}",
input.path.display(),
file_type,
size_str,
mime_type,
modified,
readonly
)
};
Ok(content.into())
}
fn format_output_plain(&self, result: &ToolResult) -> String {
let output = result.as_text();
let fields = parse_file_info(&output);
if fields.is_empty() {
return output.to_string();
}
let mut out = String::new();
for (key, value) in &fields {
let icon = match *key {
"File Information" => "",
"Type" => match *value {
"Directory" => "[D]",
"Symbolic Link" => "[L]",
_ => "[F]",
},
"Target" => "[→]",
"Size" => "[#]",
"MIME Type" => "[M]",
"Modified" => "[T]",
"Read-only" => "[R]",
_ => " ",
};
if *key == "File Information" {
out.push_str(&format!("{}\n", value));
out.push_str(&"─".repeat(value.len().min(40)));
out.push('\n');
} else {
out.push_str(&format!("{} {:12} {}\n", icon, key, value));
}
}
out
}
fn format_output_ansi(&self, result: &ToolResult) -> String {
let output = result.as_text();
let fields = parse_file_info(&output);
if fields.is_empty() {
return output.to_string();
}
let mut out = String::new();
for (key, value) in &fields {
if *key == "File Information" {
out.push_str(&format!("\x1b[1;36m{}\x1b[0m\n", value));
out.push_str(&format!(
"\x1b[2m{}\x1b[0m\n",
"─".repeat(value.len().min(40))
));
} else {
let (icon, color) = match *key {
"Type" => match *value {
"Directory" => ("\x1b[34m󰉋\x1b[0m", "\x1b[34m"),
"Symbolic Link" => ("\x1b[36m󰌷\x1b[0m", "\x1b[36m"),
_ => ("\x1b[32m󰈔\x1b[0m", "\x1b[0m"),
},
"Target" => ("\x1b[36m󰌹\x1b[0m", "\x1b[36m"),
"Size" => ("\x1b[33mó°‹Š\x1b[0m", "\x1b[33m"),
"MIME Type" => ("\x1b[35m󰈙\x1b[0m", "\x1b[35m"),
"Modified" => ("\x1b[36mó°ƒ°\x1b[0m", "\x1b[2m"),
"Read-only" => {
if *value == "true" {
("\x1b[31m󰌾\x1b[0m", "\x1b[31m")
} else {
("\x1b[32m󰌿\x1b[0m", "\x1b[32m")
}
}
_ => (" ", "\x1b[0m"),
};
out.push_str(&format!(
"{} \x1b[2m{:12}\x1b[0m {}{}\x1b[0m\n",
icon, key, color, value
));
}
}
out
}
fn format_output_markdown(&self, result: &ToolResult) -> String {
let output = result.as_text();
let fields = parse_file_info(&output);
if fields.is_empty() {
return output.to_string();
}
let mut out = String::new();
for (key, value) in &fields {
if *key == "File Information" {
out.push_str(&format!("### `{}`\n\n", value));
out.push_str("| Property | Value |\n");
out.push_str("|----------|-------|\n");
} else {
out.push_str(&format!("| {} | `{}` |\n", key, value));
}
}
out
}
}
fn parse_file_info(output: &str) -> Vec<(&str, &str)> {
output
.lines()
.filter_map(|line| {
let parts: Vec<&str> = line.splitn(2, ": ").collect();
if parts.len() == 2 {
Some((parts[0], parts[1]))
} else {
None
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_tool_metadata() {
let tool: FileInfoTool = Default::default();
assert_eq!(tool.name(), "file_info");
assert!(!tool.description().is_empty());
let tool2 = FileInfoTool::new();
assert_eq!(tool2.name(), "file_info");
}
#[test]
fn test_try_new() {
let tool = FileInfoTool::try_new();
assert!(tool.is_ok());
}
#[test]
fn test_format_methods() {
let tool = FileInfoTool::new();
let params = serde_json::json!({"path": "test.txt"});
assert!(!tool.format_input_plain(¶ms).is_empty());
assert!(!tool.format_input_ansi(¶ms).is_empty());
assert!(!tool.format_input_markdown(¶ms).is_empty());
let result = ToolResult::from("Type: File\nSize: 100 bytes");
assert!(!tool.format_output_plain(&result).is_empty());
assert!(!tool.format_output_ansi(&result).is_empty());
assert!(!tool.format_output_markdown(&result).is_empty());
}
#[tokio::test]
async fn test_file_info() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.txt");
fs::write(&file_path, "Hello, World!").unwrap();
let tool = FileInfoTool::with_base_path(temp_dir.path().to_path_buf());
let input = FileInfoInput {
path: PathBuf::from("test.txt"),
};
let result = tool.execute(input).await.unwrap();
assert!(result.as_text().contains("Type: File"));
assert!(result.as_text().contains("13 bytes"));
assert!(result.as_text().contains("text/plain"));
}
#[test]
fn test_format_size_bytes() {
assert_eq!(format_size(0), "0 bytes");
assert_eq!(format_size(1), "1 bytes");
assert_eq!(format_size(512), "512 bytes");
assert_eq!(format_size(1023), "1023 bytes");
}
#[test]
fn test_format_size_kilobytes() {
assert_eq!(format_size(1024), "1.00 KB (1024 bytes)");
assert_eq!(format_size(1536), "1.50 KB (1536 bytes)");
assert_eq!(format_size(1024 * 1024 - 1), "1024.00 KB (1048575 bytes)");
}
#[test]
fn test_format_size_megabytes() {
assert_eq!(format_size(1024 * 1024), "1.00 MB (1048576 bytes)");
assert_eq!(
format_size(1024 * 1024 * 500),
"500.00 MB (524288000 bytes)"
);
assert_eq!(
format_size(1024 * 1024 * 1024 - 1),
"1024.00 MB (1073741823 bytes)"
);
}
#[test]
fn test_format_size_gigabytes() {
assert_eq!(
format_size(1024 * 1024 * 1024),
"1.00 GB (1073741824 bytes)"
);
assert_eq!(
format_size(1024 * 1024 * 1024 * 5),
"5.00 GB (5368709120 bytes)"
);
}
#[test]
fn test_format_size_boundaries() {
let cases = [
(1023, "1023 bytes"),
(1024, "1.00 KB (1024 bytes)"),
(1024 * 1024 - 1, "1024.00 KB (1048575 bytes)"),
(1024 * 1024, "1.00 MB (1048576 bytes)"),
(1024 * 1024 * 1024 - 1, "1024.00 MB (1073741823 bytes)"),
(1024 * 1024 * 1024, "1.00 GB (1073741824 bytes)"),
];
for (size, expected) in cases {
assert_eq!(
format_size(size),
expected,
"Size {} formatted incorrectly",
size
);
}
}
#[tokio::test]
async fn test_file_info_directory() {
let temp_dir = TempDir::new().unwrap();
let subdir = temp_dir.path().join("testdir");
fs::create_dir(&subdir).unwrap();
let tool = FileInfoTool::with_base_path(temp_dir.path().to_path_buf());
let input = FileInfoInput {
path: PathBuf::from("testdir"),
};
let result = tool.execute(input).await.unwrap();
let text = result.as_text();
assert!(text.contains("Type: Directory"));
assert!(text.contains("MIME Type: N/A"));
assert!(text.contains("testdir"));
}
#[tokio::test]
#[cfg(unix)]
async fn test_file_info_symlink() {
let temp_dir = TempDir::new().unwrap();
let real_file = temp_dir.path().join("real.txt");
let symlink = temp_dir.path().join("link.txt");
fs::write(&real_file, "target content").unwrap();
std::os::unix::fs::symlink(&real_file, &symlink).unwrap();
let tool = FileInfoTool::with_base_path(temp_dir.path().to_path_buf());
let input = FileInfoInput {
path: PathBuf::from("link.txt"),
};
let result = tool.execute(input).await.unwrap();
let text = result.as_text();
assert!(text.contains("Type: Symbolic Link"));
assert!(text.contains("Target:"));
assert!(text.contains("real.txt"));
assert!(text.contains("MIME Type: N/A"));
assert!(
!text.contains("14 bytes"),
"Should show symlink size, not target size"
);
}
#[tokio::test]
async fn test_file_info_nonexistent() {
let temp_dir = TempDir::new().unwrap();
let tool = FileInfoTool::with_base_path(temp_dir.path().to_path_buf());
let input = FileInfoInput {
path: PathBuf::from("does_not_exist.txt"),
};
let result = tool.execute(input).await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("Failed to read file metadata"));
}
#[tokio::test]
async fn test_file_info_rejects_path_traversal() {
let temp_dir = TempDir::new().unwrap();
let tool = FileInfoTool::with_base_path(temp_dir.path().to_path_buf());
let input = FileInfoInput {
path: PathBuf::from("../../etc/passwd"),
};
let result = tool.execute(input).await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("escapes") || err.contains("Path"));
}
#[tokio::test]
async fn test_file_info_mime_by_content() {
let temp_dir = TempDir::new().unwrap();
let png_bytes = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
fs::write(temp_dir.path().join("image.png"), png_bytes).unwrap();
let tool = FileInfoTool::with_base_path(temp_dir.path().to_path_buf());
let input = FileInfoInput {
path: PathBuf::from("image.png"),
};
let result = tool.execute(input).await.unwrap();
assert!(result.as_text().contains("image/png"));
}
#[tokio::test]
async fn test_file_info_mime_by_extension() {
let temp_dir = TempDir::new().unwrap();
fs::write(temp_dir.path().join("script.js"), "console.log('hi')").unwrap();
let tool = FileInfoTool::with_base_path(temp_dir.path().to_path_buf());
let input = FileInfoInput {
path: PathBuf::from("script.js"),
};
let result = tool.execute(input).await.unwrap();
let text = result.as_text();
assert!(
text.contains("text/javascript") || text.contains("application/javascript"),
"Unexpected MIME type in: {}",
text
);
}
#[tokio::test]
async fn test_file_info_unknown_mime_type() {
let temp_dir = TempDir::new().unwrap();
fs::write(
temp_dir.path().join("mystery.xyz999"),
vec![0xFF, 0xAB, 0xCD, 0xEF],
)
.unwrap();
let tool = FileInfoTool::with_base_path(temp_dir.path().to_path_buf());
let input = FileInfoInput {
path: PathBuf::from("mystery.xyz999"),
};
let result = tool.execute(input).await.unwrap();
assert!(result.as_text().contains("application/octet-stream"));
}
#[tokio::test]
async fn test_file_info_readonly() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("readonly.txt");
fs::write(&file_path, "content").unwrap();
let mut perms = fs::metadata(&file_path).unwrap().permissions();
perms.set_readonly(true);
fs::set_permissions(&file_path, perms).unwrap();
let tool = FileInfoTool::with_base_path(temp_dir.path().to_path_buf());
let input = FileInfoInput {
path: PathBuf::from("readonly.txt"),
};
let result = tool.execute(input).await.unwrap();
assert!(result.as_text().contains("Read-only: true"));
let mut perms = fs::metadata(&file_path).unwrap().permissions();
#[allow(clippy::permissions_set_readonly_false)] perms.set_readonly(false);
fs::set_permissions(&file_path, perms).unwrap();
}
#[tokio::test]
async fn test_file_info_writable_file() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("writable.txt");
fs::write(&file_path, "content").unwrap();
let tool = FileInfoTool::with_base_path(temp_dir.path().to_path_buf());
let input = FileInfoInput {
path: PathBuf::from("writable.txt"),
};
let result = tool.execute(input).await.unwrap();
assert!(result.as_text().contains("Read-only: false"));
}
#[test]
fn test_parse_file_info_structure() {
let output = "File Information: test.txt\nType: File\nSize: 100 bytes\nMIME Type: text/plain\nModified: 2024-01-01\nRead-only: false";
let fields = parse_file_info(output);
assert_eq!(fields.len(), 6);
assert_eq!(fields[0], ("File Information", "test.txt"));
assert_eq!(fields[1], ("Type", "File"));
assert_eq!(fields[2], ("Size", "100 bytes"));
assert_eq!(fields[3], ("MIME Type", "text/plain"));
assert_eq!(fields[4], ("Modified", "2024-01-01"));
assert_eq!(fields[5], ("Read-only", "false"));
}
#[test]
fn test_parse_file_info_empty() {
let fields = parse_file_info("");
assert_eq!(fields.len(), 0);
}
#[test]
fn test_parse_file_info_malformed() {
let output = "NoColonHere\nAlso no colon";
let fields = parse_file_info(output);
assert_eq!(fields.len(), 0);
}
#[tokio::test]
async fn test_format_output_ansi_directory_icon() {
let temp_dir = TempDir::new().unwrap();
fs::create_dir(temp_dir.path().join("mydir")).unwrap();
let tool = FileInfoTool::with_base_path(temp_dir.path().to_path_buf());
let input = FileInfoInput {
path: PathBuf::from("mydir"),
};
let result = tool.execute(input).await.unwrap();
let ansi = tool.format_output_ansi(&result);
assert!(ansi.contains("\x1b[34m"));
}
#[tokio::test]
#[cfg(unix)]
async fn test_format_output_ansi_symlink_icon() {
let temp_dir = TempDir::new().unwrap();
let real_file = temp_dir.path().join("real.txt");
let symlink = temp_dir.path().join("link.txt");
fs::write(&real_file, "content").unwrap();
std::os::unix::fs::symlink(&real_file, &symlink).unwrap();
let tool = FileInfoTool::with_base_path(temp_dir.path().to_path_buf());
let input = FileInfoInput {
path: PathBuf::from("link.txt"),
};
let result = tool.execute(input).await.unwrap();
let ansi = tool.format_output_ansi(&result);
assert!(
ansi.contains("\x1b[36m"),
"Symlinks should be formatted with cyan color"
);
assert!(ansi.contains("󰌷"), "Should show symlink icon");
}
#[tokio::test]
async fn test_format_output_ansi_readonly_colors() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("readonly.txt");
fs::write(&file_path, "content").unwrap();
let mut perms = fs::metadata(&file_path).unwrap().permissions();
perms.set_readonly(true);
fs::set_permissions(&file_path, perms).unwrap();
let tool = FileInfoTool::with_base_path(temp_dir.path().to_path_buf());
let input = FileInfoInput {
path: PathBuf::from("readonly.txt"),
};
let result = tool.execute(input).await.unwrap();
let ansi = tool.format_output_ansi(&result);
assert!(ansi.contains("\x1b[31m"));
let mut perms = fs::metadata(&file_path).unwrap().permissions();
#[allow(clippy::permissions_set_readonly_false)] perms.set_readonly(false);
fs::set_permissions(&file_path, perms).unwrap();
}
#[tokio::test]
async fn test_format_output_markdown_structure() {
let temp_dir = TempDir::new().unwrap();
fs::write(temp_dir.path().join("test.txt"), "content").unwrap();
let tool = FileInfoTool::with_base_path(temp_dir.path().to_path_buf());
let input = FileInfoInput {
path: PathBuf::from("test.txt"),
};
let result = tool.execute(input).await.unwrap();
let markdown = tool.format_output_markdown(&result);
assert!(markdown.contains("###"));
assert!(markdown.contains("| Property | Value |"));
assert!(markdown.contains("|----------|-------|"));
assert!(markdown.contains("| Type |"));
assert!(markdown.contains("| Size |"));
}
#[tokio::test]
async fn test_format_output_plain_structure() {
let temp_dir = TempDir::new().unwrap();
fs::create_dir(temp_dir.path().join("testdir")).unwrap();
let tool = FileInfoTool::with_base_path(temp_dir.path().to_path_buf());
let input = FileInfoInput {
path: PathBuf::from("testdir"),
};
let result = tool.execute(input).await.unwrap();
let plain = tool.format_output_plain(&result);
assert!(plain.contains("[D]"));
assert!(plain.contains("─"));
}
#[tokio::test]
async fn test_empty_file() {
let temp_dir = TempDir::new().unwrap();
fs::write(temp_dir.path().join("empty.txt"), "").unwrap();
let tool = FileInfoTool::with_base_path(temp_dir.path().to_path_buf());
let input = FileInfoInput {
path: PathBuf::from("empty.txt"),
};
let result = tool.execute(input).await.unwrap();
let text = result.as_text();
assert!(text.contains("Type: File"));
assert!(text.contains("0 bytes"));
}
#[tokio::test]
async fn test_large_file_size_display() {
let temp_dir = TempDir::new().unwrap();
let large_content = vec![0u8; 2 * 1024 * 1024];
fs::write(temp_dir.path().join("large.bin"), large_content).unwrap();
let tool = FileInfoTool::with_base_path(temp_dir.path().to_path_buf());
let input = FileInfoInput {
path: PathBuf::from("large.bin"),
};
let result = tool.execute(input).await.unwrap();
let text = result.as_text();
assert!(text.contains("2.00 MB"));
assert!(text.contains("2097152 bytes"));
}
#[tokio::test]
async fn test_binary_file_mime_detection() {
let temp_dir = TempDir::new().unwrap();
let jpeg_bytes = vec![0xFF, 0xD8, 0xFF, 0xE0];
fs::write(temp_dir.path().join("photo.jpg"), jpeg_bytes).unwrap();
let tool = FileInfoTool::with_base_path(temp_dir.path().to_path_buf());
let input = FileInfoInput {
path: PathBuf::from("photo.jpg"),
};
let result = tool.execute(input).await.unwrap();
assert!(result.as_text().contains("image/jpeg"));
}
#[tokio::test]
#[cfg(unix)]
async fn test_file_info_permission_denied() {
let temp_dir = TempDir::new().unwrap();
let locked_dir = temp_dir.path().join("locked");
fs::create_dir(&locked_dir).unwrap();
let secret_file = locked_dir.join("secret.txt");
fs::write(&secret_file, "secret").unwrap();
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&locked_dir).unwrap().permissions();
perms.set_mode(0o000);
fs::set_permissions(&locked_dir, perms).unwrap();
let tool = FileInfoTool::with_base_path(temp_dir.path().to_path_buf());
let input = FileInfoInput {
path: PathBuf::from("locked/secret.txt"),
};
let result = tool.execute(input).await;
assert!(result.is_err());
let mut perms = fs::metadata(&locked_dir).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&locked_dir, perms).unwrap();
}
#[tokio::test]
async fn test_file_with_no_extension() {
let temp_dir = TempDir::new().unwrap();
fs::write(temp_dir.path().join("Makefile"), "all:\n\techo hello").unwrap();
let tool = FileInfoTool::with_base_path(temp_dir.path().to_path_buf());
let input = FileInfoInput {
path: PathBuf::from("Makefile"),
};
let result = tool.execute(input).await.unwrap();
assert!(result.as_text().contains("MIME Type:"));
}
}