1use crate::filesystem::validate_path;
2use crate::prelude::*;
3use std::path::PathBuf;
4use tokio::fs;
5
6#[derive(Debug, Deserialize, JsonSchema)]
8pub struct FileInfoInput {
9 pub path: PathBuf,
11}
12
13fn format_size(size: u64) -> String {
15 if size < 1024 {
16 format!("{} bytes", size)
17 } else if size < 1024 * 1024 {
18 format!("{:.2} KB ({} bytes)", size as f64 / 1024.0, size)
19 } else if size < 1024 * 1024 * 1024 {
20 format!("{:.2} MB ({} bytes)", size as f64 / (1024.0 * 1024.0), size)
21 } else {
22 format!(
23 "{:.2} GB ({} bytes)",
24 size as f64 / (1024.0 * 1024.0 * 1024.0),
25 size
26 )
27 }
28}
29
30pub struct FileInfoTool {
32 base_path: PathBuf,
33}
34
35impl Default for FileInfoTool {
36 fn default() -> Self {
37 Self::new()
38 }
39}
40
41impl FileInfoTool {
42 pub fn new() -> Self {
51 Self {
52 base_path: std::env::current_dir().expect("Failed to get current working directory"),
53 }
54 }
55
56 pub fn try_new() -> std::io::Result<Self> {
60 Ok(Self {
61 base_path: std::env::current_dir()?,
62 })
63 }
64
65 pub fn with_base_path(base_path: PathBuf) -> Self {
69 Self { base_path }
70 }
71}
72
73impl Tool for FileInfoTool {
74 type Input = FileInfoInput;
75
76 fn name(&self) -> &str {
77 "file_info"
78 }
79
80 fn description(&self) -> &str {
81 "Get detailed information about a file including size, type, and modification time."
82 }
83
84 async fn execute(&self, input: Self::Input) -> std::result::Result<ToolResult, ToolError> {
85 let _validated_path = validate_path(&self.base_path, &input.path)
87 .map_err(|e| ToolError::from(e.to_string()))?;
88
89 let uncanonicalized_path = if input.path.is_absolute() {
92 input.path.clone()
93 } else {
94 self.base_path.join(&input.path)
95 };
96
97 let metadata = fs::symlink_metadata(&uncanonicalized_path)
99 .await
100 .map_err(|e| ToolError::from(format!("Failed to read file metadata: {}", e)))?;
101
102 let file_type = if metadata.is_symlink() {
104 "Symbolic Link"
105 } else if metadata.is_dir() {
106 "Directory"
107 } else {
108 "File"
109 };
110
111 let size_str = format_size(metadata.len());
112
113 let modified = metadata
114 .modified()
115 .ok()
116 .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok())
117 .map(|duration| {
118 use chrono::{DateTime, Utc};
119 let datetime = DateTime::from_timestamp(duration.as_secs() as i64, 0)
120 .unwrap_or(DateTime::<Utc>::MIN_UTC);
121 datetime.format("%Y-%m-%d %H:%M:%S UTC").to_string()
122 })
123 .unwrap_or_else(|| "Unknown".to_string());
124
125 let mime_type = if metadata.is_symlink() {
127 "N/A".to_string()
128 } else if metadata.is_file() {
129 infer::get_from_path(&uncanonicalized_path)
130 .ok()
131 .flatten()
132 .map(|kind| kind.mime_type().to_string())
133 .or_else(|| {
134 mime_guess::from_path(&uncanonicalized_path)
135 .first()
136 .map(|m| m.to_string())
137 })
138 .unwrap_or_else(|| "application/octet-stream".to_string())
139 } else {
140 "N/A".to_string()
141 };
142
143 let readonly = metadata.permissions().readonly();
144
145 let symlink_target = if metadata.is_symlink() {
147 fs::read_link(&uncanonicalized_path)
148 .await
149 .ok()
150 .map(|p| p.display().to_string())
151 } else {
152 None
153 };
154
155 let content = if let Some(target) = symlink_target {
156 format!(
157 "File Information: {}\n\
158 Type: {}\n\
159 Target: {}\n\
160 Size: {}\n\
161 MIME Type: {}\n\
162 Modified: {}\n\
163 Read-only: {}",
164 input.path.display(),
165 file_type,
166 target,
167 size_str,
168 mime_type,
169 modified,
170 readonly
171 )
172 } else {
173 format!(
174 "File Information: {}\n\
175 Type: {}\n\
176 Size: {}\n\
177 MIME Type: {}\n\
178 Modified: {}\n\
179 Read-only: {}",
180 input.path.display(),
181 file_type,
182 size_str,
183 mime_type,
184 modified,
185 readonly
186 )
187 };
188
189 Ok(content.into())
190 }
191
192 fn format_output_plain(&self, result: &ToolResult) -> String {
193 let output = result.as_text();
194 let fields = parse_file_info(&output);
195 if fields.is_empty() {
196 return output.to_string();
197 }
198
199 let mut out = String::new();
200 for (key, value) in &fields {
201 let icon = match *key {
202 "File Information" => "",
203 "Type" => match *value {
204 "Directory" => "[D]",
205 "Symbolic Link" => "[L]",
206 _ => "[F]",
207 },
208 "Target" => "[→]",
209 "Size" => "[#]",
210 "MIME Type" => "[M]",
211 "Modified" => "[T]",
212 "Read-only" => "[R]",
213 _ => " ",
214 };
215
216 if *key == "File Information" {
217 out.push_str(&format!("{}\n", value));
218 out.push_str(&"─".repeat(value.len().min(40)));
219 out.push('\n');
220 } else {
221 out.push_str(&format!("{} {:12} {}\n", icon, key, value));
222 }
223 }
224 out
225 }
226
227 fn format_output_ansi(&self, result: &ToolResult) -> String {
228 let output = result.as_text();
229 let fields = parse_file_info(&output);
230 if fields.is_empty() {
231 return output.to_string();
232 }
233
234 let mut out = String::new();
235 for (key, value) in &fields {
236 if *key == "File Information" {
237 out.push_str(&format!("\x1b[1;36m{}\x1b[0m\n", value));
238 out.push_str(&format!(
239 "\x1b[2m{}\x1b[0m\n",
240 "─".repeat(value.len().min(40))
241 ));
242 } else {
243 let (icon, color) = match *key {
244 "Type" => match *value {
245 "Directory" => ("\x1b[34m\x1b[0m", "\x1b[34m"),
246 "Symbolic Link" => ("\x1b[36m\x1b[0m", "\x1b[36m"),
247 _ => ("\x1b[32m\x1b[0m", "\x1b[0m"),
248 },
249 "Target" => ("\x1b[36m\x1b[0m", "\x1b[36m"),
250 "Size" => ("\x1b[33m\x1b[0m", "\x1b[33m"),
251 "MIME Type" => ("\x1b[35m\x1b[0m", "\x1b[35m"),
252 "Modified" => ("\x1b[36m\x1b[0m", "\x1b[2m"),
253 "Read-only" => {
254 if *value == "true" {
255 ("\x1b[31m\x1b[0m", "\x1b[31m")
256 } else {
257 ("\x1b[32m\x1b[0m", "\x1b[32m")
258 }
259 }
260 _ => (" ", "\x1b[0m"),
261 };
262 out.push_str(&format!(
263 "{} \x1b[2m{:12}\x1b[0m {}{}\x1b[0m\n",
264 icon, key, color, value
265 ));
266 }
267 }
268 out
269 }
270
271 fn format_output_markdown(&self, result: &ToolResult) -> String {
272 let output = result.as_text();
273 let fields = parse_file_info(&output);
274 if fields.is_empty() {
275 return output.to_string();
276 }
277
278 let mut out = String::new();
279 for (key, value) in &fields {
280 if *key == "File Information" {
281 out.push_str(&format!("### `{}`\n\n", value));
282 out.push_str("| Property | Value |\n");
283 out.push_str("|----------|-------|\n");
284 } else {
285 out.push_str(&format!("| {} | `{}` |\n", key, value));
286 }
287 }
288 out
289 }
290}
291
292fn parse_file_info(output: &str) -> Vec<(&str, &str)> {
294 output
295 .lines()
296 .filter_map(|line| {
297 let parts: Vec<&str> = line.splitn(2, ": ").collect();
298 if parts.len() == 2 {
299 Some((parts[0], parts[1]))
300 } else {
301 None
302 }
303 })
304 .collect()
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310 use std::fs;
311 use tempfile::TempDir;
312
313 #[test]
314 fn test_tool_metadata() {
315 let tool: FileInfoTool = Default::default();
316 assert_eq!(tool.name(), "file_info");
317 assert!(!tool.description().is_empty());
318
319 let tool2 = FileInfoTool::new();
320 assert_eq!(tool2.name(), "file_info");
321 }
322
323 #[test]
324 fn test_try_new() {
325 let tool = FileInfoTool::try_new();
326 assert!(tool.is_ok());
327 }
328
329 #[test]
330 fn test_format_methods() {
331 let tool = FileInfoTool::new();
332 let params = serde_json::json!({"path": "test.txt"});
333
334 assert!(!tool.format_input_plain(¶ms).is_empty());
336 assert!(!tool.format_input_ansi(¶ms).is_empty());
337 assert!(!tool.format_input_markdown(¶ms).is_empty());
338
339 let result = ToolResult::from("Type: File\nSize: 100 bytes");
340 assert!(!tool.format_output_plain(&result).is_empty());
341 assert!(!tool.format_output_ansi(&result).is_empty());
342 assert!(!tool.format_output_markdown(&result).is_empty());
343 }
344
345 #[tokio::test]
348 async fn test_file_info() {
349 let temp_dir = TempDir::new().unwrap();
350 let file_path = temp_dir.path().join("test.txt");
351 fs::write(&file_path, "Hello, World!").unwrap();
352
353 let tool = FileInfoTool::with_base_path(temp_dir.path().to_path_buf());
354 let input = FileInfoInput {
355 path: PathBuf::from("test.txt"),
356 };
357
358 let result = tool.execute(input).await.unwrap();
359 assert!(result.as_text().contains("Type: File"));
360 assert!(result.as_text().contains("13 bytes"));
361 assert!(result.as_text().contains("text/plain"));
362 }
363
364 #[test]
367 fn test_format_size_bytes() {
368 assert_eq!(format_size(0), "0 bytes");
369 assert_eq!(format_size(1), "1 bytes");
370 assert_eq!(format_size(512), "512 bytes");
371 assert_eq!(format_size(1023), "1023 bytes");
372 }
373
374 #[test]
375 fn test_format_size_kilobytes() {
376 assert_eq!(format_size(1024), "1.00 KB (1024 bytes)");
377 assert_eq!(format_size(1536), "1.50 KB (1536 bytes)");
378 assert_eq!(format_size(1024 * 1024 - 1), "1024.00 KB (1048575 bytes)");
379 }
380
381 #[test]
382 fn test_format_size_megabytes() {
383 assert_eq!(format_size(1024 * 1024), "1.00 MB (1048576 bytes)");
384 assert_eq!(
385 format_size(1024 * 1024 * 500),
386 "500.00 MB (524288000 bytes)"
387 );
388 assert_eq!(
389 format_size(1024 * 1024 * 1024 - 1),
390 "1024.00 MB (1073741823 bytes)"
391 );
392 }
393
394 #[test]
395 fn test_format_size_gigabytes() {
396 assert_eq!(
397 format_size(1024 * 1024 * 1024),
398 "1.00 GB (1073741824 bytes)"
399 );
400 assert_eq!(
401 format_size(1024 * 1024 * 1024 * 5),
402 "5.00 GB (5368709120 bytes)"
403 );
404 }
405
406 #[test]
407 fn test_format_size_boundaries() {
408 let cases = [
410 (1023, "1023 bytes"),
411 (1024, "1.00 KB (1024 bytes)"),
412 (1024 * 1024 - 1, "1024.00 KB (1048575 bytes)"),
413 (1024 * 1024, "1.00 MB (1048576 bytes)"),
414 (1024 * 1024 * 1024 - 1, "1024.00 MB (1073741823 bytes)"),
415 (1024 * 1024 * 1024, "1.00 GB (1073741824 bytes)"),
416 ];
417
418 for (size, expected) in cases {
419 assert_eq!(
420 format_size(size),
421 expected,
422 "Size {} formatted incorrectly",
423 size
424 );
425 }
426 }
427
428 #[tokio::test]
431 async fn test_file_info_directory() {
432 let temp_dir = TempDir::new().unwrap();
434 let subdir = temp_dir.path().join("testdir");
435 fs::create_dir(&subdir).unwrap();
436
437 let tool = FileInfoTool::with_base_path(temp_dir.path().to_path_buf());
438 let input = FileInfoInput {
439 path: PathBuf::from("testdir"),
440 };
441
442 let result = tool.execute(input).await.unwrap();
443 let text = result.as_text();
444
445 assert!(text.contains("Type: Directory"));
446 assert!(text.contains("MIME Type: N/A"));
447 assert!(text.contains("testdir"));
448 }
449
450 #[tokio::test]
451 #[cfg(unix)]
452 async fn test_file_info_symlink() {
453 let temp_dir = TempDir::new().unwrap();
455 let real_file = temp_dir.path().join("real.txt");
456 let symlink = temp_dir.path().join("link.txt");
457
458 fs::write(&real_file, "target content").unwrap();
459 std::os::unix::fs::symlink(&real_file, &symlink).unwrap();
460
461 let tool = FileInfoTool::with_base_path(temp_dir.path().to_path_buf());
462 let input = FileInfoInput {
463 path: PathBuf::from("link.txt"),
464 };
465
466 let result = tool.execute(input).await.unwrap();
467 let text = result.as_text();
468
469 assert!(text.contains("Type: Symbolic Link"));
471
472 assert!(text.contains("Target:"));
474 assert!(text.contains("real.txt"));
475
476 assert!(text.contains("MIME Type: N/A"));
478
479 assert!(
481 !text.contains("14 bytes"),
482 "Should show symlink size, not target size"
483 );
484 }
485
486 #[tokio::test]
487 async fn test_file_info_nonexistent() {
488 let temp_dir = TempDir::new().unwrap();
490
491 let tool = FileInfoTool::with_base_path(temp_dir.path().to_path_buf());
492 let input = FileInfoInput {
493 path: PathBuf::from("does_not_exist.txt"),
494 };
495
496 let result = tool.execute(input).await;
497 assert!(result.is_err());
498
499 let err = result.unwrap_err().to_string();
500 assert!(err.contains("Failed to read file metadata"));
501 }
502
503 #[tokio::test]
504 async fn test_file_info_rejects_path_traversal() {
505 let temp_dir = TempDir::new().unwrap();
507
508 let tool = FileInfoTool::with_base_path(temp_dir.path().to_path_buf());
509 let input = FileInfoInput {
510 path: PathBuf::from("../../etc/passwd"),
511 };
512
513 let result = tool.execute(input).await;
514 assert!(result.is_err());
515
516 let err = result.unwrap_err().to_string();
517 assert!(err.contains("escapes") || err.contains("Path"));
518 }
519
520 #[tokio::test]
521 async fn test_file_info_mime_by_content() {
522 let temp_dir = TempDir::new().unwrap();
524
525 let png_bytes = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
527 fs::write(temp_dir.path().join("image.png"), png_bytes).unwrap();
528
529 let tool = FileInfoTool::with_base_path(temp_dir.path().to_path_buf());
530 let input = FileInfoInput {
531 path: PathBuf::from("image.png"),
532 };
533
534 let result = tool.execute(input).await.unwrap();
535 assert!(result.as_text().contains("image/png"));
536 }
537
538 #[tokio::test]
539 async fn test_file_info_mime_by_extension() {
540 let temp_dir = TempDir::new().unwrap();
542
543 fs::write(temp_dir.path().join("script.js"), "console.log('hi')").unwrap();
545
546 let tool = FileInfoTool::with_base_path(temp_dir.path().to_path_buf());
547 let input = FileInfoInput {
548 path: PathBuf::from("script.js"),
549 };
550
551 let result = tool.execute(input).await.unwrap();
552 let text = result.as_text();
553
554 assert!(
556 text.contains("text/javascript") || text.contains("application/javascript"),
557 "Unexpected MIME type in: {}",
558 text
559 );
560 }
561
562 #[tokio::test]
563 async fn test_file_info_unknown_mime_type() {
564 let temp_dir = TempDir::new().unwrap();
566
567 fs::write(
569 temp_dir.path().join("mystery.xyz999"),
570 vec![0xFF, 0xAB, 0xCD, 0xEF],
571 )
572 .unwrap();
573
574 let tool = FileInfoTool::with_base_path(temp_dir.path().to_path_buf());
575 let input = FileInfoInput {
576 path: PathBuf::from("mystery.xyz999"),
577 };
578
579 let result = tool.execute(input).await.unwrap();
580 assert!(result.as_text().contains("application/octet-stream"));
581 }
582
583 #[tokio::test]
584 async fn test_file_info_readonly() {
585 let temp_dir = TempDir::new().unwrap();
587 let file_path = temp_dir.path().join("readonly.txt");
588 fs::write(&file_path, "content").unwrap();
589
590 let mut perms = fs::metadata(&file_path).unwrap().permissions();
592 perms.set_readonly(true);
593 fs::set_permissions(&file_path, perms).unwrap();
594
595 let tool = FileInfoTool::with_base_path(temp_dir.path().to_path_buf());
596 let input = FileInfoInput {
597 path: PathBuf::from("readonly.txt"),
598 };
599
600 let result = tool.execute(input).await.unwrap();
601 assert!(result.as_text().contains("Read-only: true"));
602
603 let mut perms = fs::metadata(&file_path).unwrap().permissions();
605 #[allow(clippy::permissions_set_readonly_false)] perms.set_readonly(false);
607 fs::set_permissions(&file_path, perms).unwrap();
608 }
609
610 #[tokio::test]
611 async fn test_file_info_writable_file() {
612 let temp_dir = TempDir::new().unwrap();
614 let file_path = temp_dir.path().join("writable.txt");
615 fs::write(&file_path, "content").unwrap();
616
617 let tool = FileInfoTool::with_base_path(temp_dir.path().to_path_buf());
618 let input = FileInfoInput {
619 path: PathBuf::from("writable.txt"),
620 };
621
622 let result = tool.execute(input).await.unwrap();
623 assert!(result.as_text().contains("Read-only: false"));
624 }
625
626 #[test]
627 fn test_parse_file_info_structure() {
628 let output = "File Information: test.txt\nType: File\nSize: 100 bytes\nMIME Type: text/plain\nModified: 2024-01-01\nRead-only: false";
630 let fields = parse_file_info(output);
631
632 assert_eq!(fields.len(), 6);
633 assert_eq!(fields[0], ("File Information", "test.txt"));
634 assert_eq!(fields[1], ("Type", "File"));
635 assert_eq!(fields[2], ("Size", "100 bytes"));
636 assert_eq!(fields[3], ("MIME Type", "text/plain"));
637 assert_eq!(fields[4], ("Modified", "2024-01-01"));
638 assert_eq!(fields[5], ("Read-only", "false"));
639 }
640
641 #[test]
642 fn test_parse_file_info_empty() {
643 let fields = parse_file_info("");
645 assert_eq!(fields.len(), 0);
646 }
647
648 #[test]
649 fn test_parse_file_info_malformed() {
650 let output = "NoColonHere\nAlso no colon";
652 let fields = parse_file_info(output);
653 assert_eq!(fields.len(), 0);
654 }
655
656 #[tokio::test]
657 async fn test_format_output_ansi_directory_icon() {
658 let temp_dir = TempDir::new().unwrap();
660 fs::create_dir(temp_dir.path().join("mydir")).unwrap();
661
662 let tool = FileInfoTool::with_base_path(temp_dir.path().to_path_buf());
663 let input = FileInfoInput {
664 path: PathBuf::from("mydir"),
665 };
666
667 let result = tool.execute(input).await.unwrap();
668 let ansi = tool.format_output_ansi(&result);
669
670 assert!(ansi.contains("\x1b[34m"));
672 }
673
674 #[tokio::test]
675 #[cfg(unix)]
676 async fn test_format_output_ansi_symlink_icon() {
677 let temp_dir = TempDir::new().unwrap();
679 let real_file = temp_dir.path().join("real.txt");
680 let symlink = temp_dir.path().join("link.txt");
681
682 fs::write(&real_file, "content").unwrap();
683 std::os::unix::fs::symlink(&real_file, &symlink).unwrap();
684
685 let tool = FileInfoTool::with_base_path(temp_dir.path().to_path_buf());
686 let input = FileInfoInput {
687 path: PathBuf::from("link.txt"),
688 };
689
690 let result = tool.execute(input).await.unwrap();
691 let ansi = tool.format_output_ansi(&result);
692
693 assert!(
695 ansi.contains("\x1b[36m"),
696 "Symlinks should be formatted with cyan color"
697 );
698
699 assert!(ansi.contains(""), "Should show symlink icon");
701 }
702
703 #[tokio::test]
704 async fn test_format_output_ansi_readonly_colors() {
705 let temp_dir = TempDir::new().unwrap();
707 let file_path = temp_dir.path().join("readonly.txt");
708 fs::write(&file_path, "content").unwrap();
709
710 let mut perms = fs::metadata(&file_path).unwrap().permissions();
711 perms.set_readonly(true);
712 fs::set_permissions(&file_path, perms).unwrap();
713
714 let tool = FileInfoTool::with_base_path(temp_dir.path().to_path_buf());
715 let input = FileInfoInput {
716 path: PathBuf::from("readonly.txt"),
717 };
718
719 let result = tool.execute(input).await.unwrap();
720 let ansi = tool.format_output_ansi(&result);
721
722 assert!(ansi.contains("\x1b[31m"));
724
725 let mut perms = fs::metadata(&file_path).unwrap().permissions();
727 #[allow(clippy::permissions_set_readonly_false)] perms.set_readonly(false);
729 fs::set_permissions(&file_path, perms).unwrap();
730 }
731
732 #[tokio::test]
733 async fn test_format_output_markdown_structure() {
734 let temp_dir = TempDir::new().unwrap();
736 fs::write(temp_dir.path().join("test.txt"), "content").unwrap();
737
738 let tool = FileInfoTool::with_base_path(temp_dir.path().to_path_buf());
739 let input = FileInfoInput {
740 path: PathBuf::from("test.txt"),
741 };
742
743 let result = tool.execute(input).await.unwrap();
744 let markdown = tool.format_output_markdown(&result);
745
746 assert!(markdown.contains("###"));
748 assert!(markdown.contains("| Property | Value |"));
749 assert!(markdown.contains("|----------|-------|"));
750 assert!(markdown.contains("| Type |"));
751 assert!(markdown.contains("| Size |"));
752 }
753
754 #[tokio::test]
755 async fn test_format_output_plain_structure() {
756 let temp_dir = TempDir::new().unwrap();
758 fs::create_dir(temp_dir.path().join("testdir")).unwrap();
759
760 let tool = FileInfoTool::with_base_path(temp_dir.path().to_path_buf());
761 let input = FileInfoInput {
762 path: PathBuf::from("testdir"),
763 };
764
765 let result = tool.execute(input).await.unwrap();
766 let plain = tool.format_output_plain(&result);
767
768 assert!(plain.contains("[D]"));
770 assert!(plain.contains("─"));
772 }
773
774 #[tokio::test]
775 async fn test_empty_file() {
776 let temp_dir = TempDir::new().unwrap();
778 fs::write(temp_dir.path().join("empty.txt"), "").unwrap();
779
780 let tool = FileInfoTool::with_base_path(temp_dir.path().to_path_buf());
781 let input = FileInfoInput {
782 path: PathBuf::from("empty.txt"),
783 };
784
785 let result = tool.execute(input).await.unwrap();
786 let text = result.as_text();
787
788 assert!(text.contains("Type: File"));
789 assert!(text.contains("0 bytes"));
790 }
791
792 #[tokio::test]
793 async fn test_large_file_size_display() {
794 let temp_dir = TempDir::new().unwrap();
796
797 let large_content = vec![0u8; 2 * 1024 * 1024];
799 fs::write(temp_dir.path().join("large.bin"), large_content).unwrap();
800
801 let tool = FileInfoTool::with_base_path(temp_dir.path().to_path_buf());
802 let input = FileInfoInput {
803 path: PathBuf::from("large.bin"),
804 };
805
806 let result = tool.execute(input).await.unwrap();
807 let text = result.as_text();
808
809 assert!(text.contains("2.00 MB"));
811 assert!(text.contains("2097152 bytes"));
812 }
813
814 #[tokio::test]
815 async fn test_binary_file_mime_detection() {
816 let temp_dir = TempDir::new().unwrap();
818
819 let jpeg_bytes = vec![0xFF, 0xD8, 0xFF, 0xE0];
821 fs::write(temp_dir.path().join("photo.jpg"), jpeg_bytes).unwrap();
822
823 let tool = FileInfoTool::with_base_path(temp_dir.path().to_path_buf());
824 let input = FileInfoInput {
825 path: PathBuf::from("photo.jpg"),
826 };
827
828 let result = tool.execute(input).await.unwrap();
829 assert!(result.as_text().contains("image/jpeg"));
830 }
831
832 #[tokio::test]
833 #[cfg(unix)]
834 async fn test_file_info_permission_denied() {
835 let temp_dir = TempDir::new().unwrap();
839 let locked_dir = temp_dir.path().join("locked");
840 fs::create_dir(&locked_dir).unwrap();
841 let secret_file = locked_dir.join("secret.txt");
842 fs::write(&secret_file, "secret").unwrap();
843
844 use std::os::unix::fs::PermissionsExt;
846 let mut perms = fs::metadata(&locked_dir).unwrap().permissions();
847 perms.set_mode(0o000);
848 fs::set_permissions(&locked_dir, perms).unwrap();
849
850 let tool = FileInfoTool::with_base_path(temp_dir.path().to_path_buf());
851 let input = FileInfoInput {
852 path: PathBuf::from("locked/secret.txt"),
853 };
854
855 let result = tool.execute(input).await;
856
857 assert!(result.is_err());
859
860 let mut perms = fs::metadata(&locked_dir).unwrap().permissions();
862 perms.set_mode(0o755);
863 fs::set_permissions(&locked_dir, perms).unwrap();
864 }
865
866 #[tokio::test]
867 async fn test_file_with_no_extension() {
868 let temp_dir = TempDir::new().unwrap();
870 fs::write(temp_dir.path().join("Makefile"), "all:\n\techo hello").unwrap();
871
872 let tool = FileInfoTool::with_base_path(temp_dir.path().to_path_buf());
873 let input = FileInfoInput {
874 path: PathBuf::from("Makefile"),
875 };
876
877 let result = tool.execute(input).await.unwrap();
878 assert!(result.as_text().contains("MIME Type:"));
880 }
881}