fast_fs/models/
cls_file_entry.rs

1// <FILE>crates/fast-fs/src/models/cls_file_entry.rs</FILE> - <DESC>FileEntry struct</DESC>
2// <VERS>VERSION: 0.5.0</VERS>
3// <WCTX>Parent directory entry support</WCTX>
4// <CLOG>Added parent_entry() factory method for ".." entries</CLOG>
5
6//! File entry representation
7use crate::nav::FileCategory;
8use std::fs;
9use std::path::{Path, PathBuf};
10use std::time::SystemTime;
11
12/// A file entry with cached metadata
13#[derive(Debug, Clone)]
14pub struct FileEntry {
15    /// Full path to the file
16    pub path: PathBuf,
17    /// File name (cached for sorting, lossy UTF-8)
18    pub name: String,
19    /// Is this a directory?
20    pub is_dir: bool,
21    /// Is this a hidden file?
22    pub is_hidden: bool,
23    /// File size in bytes (0 for directories)
24    pub size: u64,
25    /// Last modified time
26    pub modified: Option<SystemTime>,
27    /// Is this a symbolic link? (from DirEntry::file_type)
28    pub is_symlink: bool,
29    /// Is this file readonly? (from metadata.permissions)
30    pub is_readonly: bool,
31}
32
33impl FileEntry {
34    /// Create a new file entry from a path and metadata
35    pub fn new(
36        path: PathBuf,
37        is_dir: bool,
38        size: u64,
39        modified: Option<SystemTime>,
40        is_symlink: bool,
41        is_readonly: bool,
42    ) -> Self {
43        let name = path
44            .file_name()
45            .map(|n| n.to_string_lossy().to_string())
46            .unwrap_or_default();
47        let is_hidden = name.starts_with('.');
48        Self {
49            path,
50            name,
51            is_dir,
52            is_hidden,
53            size,
54            modified,
55            is_symlink,
56            is_readonly,
57        }
58    }
59
60    /// Create a parent directory entry ("..")
61    ///
62    /// This is used to display and navigate to the parent directory
63    /// in file browsers. The path should be the actual parent path.
64    pub fn parent_entry(parent_path: PathBuf) -> Self {
65        Self {
66            path: parent_path,
67            name: "..".to_string(),
68            is_dir: true,
69            is_hidden: false,
70            size: 0,
71            modified: None,
72            is_symlink: false,
73            is_readonly: false,
74        }
75    }
76
77    /// Check if this is a parent directory entry ("..")
78    pub fn is_parent_entry(&self) -> bool {
79        self.name == ".."
80    }
81
82    /// Get the file extension if any
83    pub fn extension(&self) -> Option<&str> {
84        self.path.extension().and_then(|e| e.to_str())
85    }
86
87    /// Resolve symlink target. Returns None if not a symlink or broken/loop.
88    /// **Cost:** One read_link syscall. Cache result if called repeatedly.
89    pub fn resolve_symlink(&self) -> Option<PathBuf> {
90        if !self.is_symlink {
91            return None;
92        }
93        fs::read_link(&self.path).ok()
94    }
95
96    /// Check if file is executable.
97    /// **Cost:** Unix: stat() for mode bits. Windows: extension check (cheap).
98    #[cfg(unix)]
99    pub fn is_executable(&self) -> bool {
100        use std::os::unix::fs::PermissionsExt;
101        fs::metadata(&self.path)
102            .map(|m| m.permissions().mode() & 0o111 != 0)
103            .unwrap_or(false)
104    }
105
106    /// Check if file is executable.
107    /// **Cost:** Windows: extension check (cheap).
108    #[cfg(windows)]
109    pub fn is_executable(&self) -> bool {
110        const EXECUTABLE_EXTENSIONS: &[&str] = &["exe", "bat", "cmd", "ps1", "com", "msi"];
111        self.extension()
112            .map(|ext| EXECUTABLE_EXTENSIONS.contains(&ext.to_lowercase().as_str()))
113            .unwrap_or(false)
114    }
115
116    /// Fallback for other platforms
117    #[cfg(not(any(unix, windows)))]
118    pub fn is_executable(&self) -> bool {
119        false
120    }
121
122    /// Detect broken symlink (target doesn't exist).
123    /// **Cost:** One stat() on target path.
124    pub fn is_symlink_broken(&self) -> bool {
125        if !self.is_symlink {
126            return false;
127        }
128        // A symlink is broken if we can read_link but the target doesn't exist
129        match fs::read_link(&self.path) {
130            Ok(target) => {
131                let target_path = resolve_relative_to(&self.path, &target);
132                !target_path.exists()
133            }
134            Err(_) => true, // Can't read link, consider it broken
135        }
136    }
137
138    /// Detect symlink loop (circular reference).
139    /// **Cost:** Path canonicalization.
140    pub fn is_symlink_loop(&self) -> bool {
141        if !self.is_symlink {
142            return false;
143        }
144        // A loop is detected when canonicalize fails with a specific error
145        // or when we detect circular references
146        match fs::canonicalize(&self.path) {
147            Ok(_) => false,
148            Err(e) => {
149                // On Unix, ELOOP error indicates a symlink loop
150                e.raw_os_error() == Some(40) // ELOOP on Linux
151                    || e.kind() == std::io::ErrorKind::NotFound
152                    || matches!(e.kind(), std::io::ErrorKind::Other)
153            }
154        }
155    }
156
157    /// Get the file category for UI icons and grouping.
158    ///
159    /// Resolution order:
160    /// 1. If symlink → Symlink
161    /// 2. If directory → Directory
162    /// 3. If executable (Unix: +x, Windows: exe/bat/etc) → Executable
163    /// 4. Otherwise → based on extension
164    ///
165    /// **Cost:** May call is_executable() which does a stat() on Unix.
166    pub fn category(&self) -> FileCategory {
167        if self.is_symlink {
168            FileCategory::Symlink
169        } else if self.is_dir {
170            FileCategory::Directory
171        } else if self.is_executable() {
172            FileCategory::Executable
173        } else {
174            self.extension()
175                .map(FileCategory::from_extension)
176                .unwrap_or(FileCategory::Unknown)
177        }
178    }
179}
180
181/// Resolve a relative path against a base path
182fn resolve_relative_to(base: &Path, target: &Path) -> PathBuf {
183    if target.is_absolute() {
184        target.to_path_buf()
185    } else {
186        base.parent()
187            .map(|p| p.join(target))
188            .unwrap_or_else(|| target.to_path_buf())
189    }
190}
191
192impl PartialEq for FileEntry {
193    fn eq(&self, other: &Self) -> bool {
194        self.path == other.path
195    }
196}
197impl Eq for FileEntry {}
198impl std::hash::Hash for FileEntry {
199    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
200        self.path.hash(state);
201    }
202}
203
204// <FILE>crates/fast-fs/src/models/cls_file_entry.rs</FILE> - <DESC>FileEntry struct</DESC>
205// <VERS>END OF VERSION: 0.5.0</VERS>