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>