Skip to main content

mixtape_tools/filesystem/
file_info.rs

1use crate::filesystem::validate_path;
2use crate::prelude::*;
3use std::path::PathBuf;
4use tokio::fs;
5
6/// Input for getting file information
7#[derive(Debug, Deserialize, JsonSchema)]
8pub struct FileInfoInput {
9    /// Path to the file to get information about
10    pub path: PathBuf,
11}
12
13/// Format a file size in human-readable form
14fn 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
30/// Tool for retrieving file metadata
31pub 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    /// Creates a new tool using the current working directory as the base path.
43    ///
44    /// Equivalent to `Default::default()`.
45    ///
46    /// # Panics
47    ///
48    /// Panics if the current working directory cannot be determined.
49    /// Use [`try_new`](Self::try_new) or [`with_base_path`](Self::with_base_path) instead.
50    pub fn new() -> Self {
51        Self {
52            base_path: std::env::current_dir().expect("Failed to get current working directory"),
53        }
54    }
55
56    /// Creates a new tool using the current working directory as the base path.
57    ///
58    /// Returns an error if the current working directory cannot be determined.
59    pub fn try_new() -> std::io::Result<Self> {
60        Ok(Self {
61            base_path: std::env::current_dir()?,
62        })
63    }
64
65    /// Creates a tool with a custom base directory.
66    ///
67    /// All file operations will be constrained to this directory.
68    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        // Validate the path for security first (this catches path traversal attempts)
86        let _validated_path = validate_path(&self.base_path, &input.path)
87            .map_err(|e| ToolError::from(e.to_string()))?;
88
89        // Build the full path before canonicalization to detect symlinks
90        // We use the uncanonicalized path for symlink_metadata so we can detect symlinks
91        let uncanonicalized_path = if input.path.is_absolute() {
92            input.path.clone()
93        } else {
94            self.base_path.join(&input.path)
95        };
96
97        // Use symlink_metadata on the uncanonicalized path to detect symlinks
98        let metadata = fs::symlink_metadata(&uncanonicalized_path)
99            .await
100            .map_err(|e| ToolError::from(format!("Failed to read file metadata: {}", e)))?;
101
102        // Check symlink FIRST - a symlink to a directory would return true for both
103        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        // Detect MIME type for regular files only (not symlinks or directories)
126        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        // Read symlink target if this is a symlink
146        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
292/// Parse file info output into fields
293fn 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        // All format methods should return non-empty strings
335        assert!(!tool.format_input_plain(&params).is_empty());
336        assert!(!tool.format_input_ansi(&params).is_empty());
337        assert!(!tool.format_input_markdown(&params).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    // ===== Execution Tests =====
346
347    #[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    // ===== format_size Tests =====
365
366    #[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        // Test exact boundaries between units to ensure proper formatting
409        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    // ===== Coverage Gap Tests =====
429
430    #[tokio::test]
431    async fn test_file_info_directory() {
432        // Test that directory metadata is correctly identified and MIME type is N/A
433        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        // Symlinks are correctly detected using fs::symlink_metadata()
454        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        // Symlink is correctly identified
470        assert!(text.contains("Type: Symbolic Link"));
471
472        // Target path is shown
473        assert!(text.contains("Target:"));
474        assert!(text.contains("real.txt"));
475
476        // MIME type should be N/A for symlinks
477        assert!(text.contains("MIME Type: N/A"));
478
479        // Size is the symlink size, not target size (should NOT contain target content length)
480        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        // Test error handling when file doesn't exist
489        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        // Test that directory traversal attempts are rejected
506        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        // Test MIME type detection via content inspection (magic bytes)
523        let temp_dir = TempDir::new().unwrap();
524
525        // PNG magic bytes: 89 50 4E 47 0D 0A 1A 0A
526        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        // Test MIME type detection via file extension when content doesn't match
541        let temp_dir = TempDir::new().unwrap();
542
543        // JavaScript file with no magic bytes
544        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        // Different MIME implementations may return different values
555        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        // Test fallback to application/octet-stream for unknown file types
565        let temp_dir = TempDir::new().unwrap();
566
567        // Random bytes with unknown extension
568        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        // Test detection of read-only file permissions
586        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        // Set read-only
591        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        // Clean up: restore write permissions so temp dir can be deleted
604        let mut perms = fs::metadata(&file_path).unwrap().permissions();
605        #[allow(clippy::permissions_set_readonly_false)] // Test cleanup, temp file
606        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        // Test that writable files show Read-only: false
613        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        // Test the parse_file_info helper function
629        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        // Test parsing empty string
644        let fields = parse_file_info("");
645        assert_eq!(fields.len(), 0);
646    }
647
648    #[test]
649    fn test_parse_file_info_malformed() {
650        // Test parsing lines without colon separator
651        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        // Test that ANSI formatter uses appropriate colors for directories
659        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        // Should use blue color for directory
671        assert!(ansi.contains("\x1b[34m"));
672    }
673
674    #[tokio::test]
675    #[cfg(unix)]
676    async fn test_format_output_ansi_symlink_icon() {
677        // Symlinks are correctly detected and formatted with cyan color
678        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        // Symlink is correctly formatted with cyan color
694        assert!(
695            ansi.contains("\x1b[36m"),
696            "Symlinks should be formatted with cyan color"
697        );
698
699        // Should contain the symlink icon (󰌷)
700        assert!(ansi.contains("󰌷"), "Should show symlink icon");
701    }
702
703    #[tokio::test]
704    async fn test_format_output_ansi_readonly_colors() {
705        // Test that read-only status affects ANSI color coding
706        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        // Should have red color for read-only true
723        assert!(ansi.contains("\x1b[31m"));
724
725        // Clean up: restore write permissions so temp dir can be deleted
726        let mut perms = fs::metadata(&file_path).unwrap().permissions();
727        #[allow(clippy::permissions_set_readonly_false)] // Test cleanup, temp file
728        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        // Test that markdown formatter creates proper table structure
735        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        // Should contain markdown table elements
747        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        // Test plain formatter output structure
757        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        // Should have directory marker
769        assert!(plain.contains("[D]"));
770        // Should have separator line
771        assert!(plain.contains("─"));
772    }
773
774    #[tokio::test]
775    async fn test_empty_file() {
776        // Test metadata for empty (0 byte) file
777        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        // Test that large files display size in appropriate units
795        let temp_dir = TempDir::new().unwrap();
796
797        // Create a 2MB file
798        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        // Should display in MB
810        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        // Test MIME detection for common binary file types
817        let temp_dir = TempDir::new().unwrap();
818
819        // JPEG magic bytes: FF D8 FF
820        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        // Test error handling when file exists but can't read metadata due to permissions
836        // This is tricky to test as metadata reading typically doesn't fail even without
837        // read permissions, but we can try with a directory we create and lock down
838        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        // Remove all permissions from parent directory
845        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        // Should fail (either during validation or metadata read)
858        assert!(result.is_err());
859
860        // Clean up: restore permissions
861        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        // Test MIME type detection for files without extensions
869        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        // Should succeed and show some MIME type
879        assert!(result.as_text().contains("MIME Type:"));
880    }
881}