From 60fb9ce1b840783060af2956aa4c2e770584f9dc Mon Sep 17 00:00:00 2001 From: Sisyphus Date: Sun, 26 Jul 2026 00:36:19 +0900 Subject: [PATCH] =?UTF-8?q?tlc:=20Phase=20C.1=20=E2=80=94=20Info=20dialog?= =?UTF-8?q?=20full=20MC=20parity=20(Location/Device/Filesystem/Symlink=20t?= =?UTF-8?q?arget)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase C.1 — F11 File info dialog (ops/info.rs): - New FileInfo fields: device_major, device_minor, inode, filesystem, fs_total_bytes, fs_free_bytes, mount_point, symlink_target - New 'Location: dev:MAJOR:MINOR inode:N' field (MC's required display) - New 'Filesystem: TYPE' field (e.g. ext4, btrfs) - New 'Device: MOUNT (X free of Y)' field showing disk usage - New 'Link target: PATH' field shown for symlinks - read_dev_ino: extracts dev_t (major:minor) and inode from MetadataExt - read_filesystem: looks up /proc/mounts to find mount point and fs type - mount_point_of: matches metadata.dev/ino against /proc/mounts entries - fs_type: parses fs type from /proc/mounts - disk_total / disk_free: rustix::fs::statvfs for filesystem stats - Non-Unix fallback returns empty fields Tests: 1486 passing (was 1486). Updated two test scaffolds that built FileInfo manually to include the new fields. Refs: MC-PARITY-AUDIT.md §5.14 (GAP-IF-1..6) --- .../tui/tlc/source/src/filemanager/info.rs | 8 + local/recipes/tui/tlc/source/src/ops/info.rs | 187 +++++++++++++++--- 2 files changed, 167 insertions(+), 28 deletions(-) diff --git a/local/recipes/tui/tlc/source/src/filemanager/info.rs b/local/recipes/tui/tlc/source/src/filemanager/info.rs index 3afb74103a..c60aa0a799 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/info.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/info.rs @@ -183,6 +183,14 @@ mod tests { is_readable: true, is_writable: true, is_executable: true, + device_major: 0, + device_minor: 0, + inode: 0, + filesystem: String::new(), + fs_total_bytes: 0, + fs_free_bytes: 0, + mount_point: String::new(), + symlink_target: None, }; let _ = s; let dlg = InfoDialog::new(PathBuf::from("/x"), info) diff --git a/local/recipes/tui/tlc/source/src/ops/info.rs b/local/recipes/tui/tlc/source/src/ops/info.rs index d4a100541a..7e41b1430e 100644 --- a/local/recipes/tui/tlc/source/src/ops/info.rs +++ b/local/recipes/tui/tlc/source/src/ops/info.rs @@ -19,55 +19,58 @@ use crate::fs::{FileType, Stat, StatError}; /// directly. #[derive(Debug, Clone)] pub struct FileInfo { - /// File name (last component of the path). pub name: String, - /// Full path the user asked about. pub path: PathBuf, - /// Size in bytes. 0 for directories on filesystems that report 0. pub size: u64, - /// File type classification. pub file_type: FileType, - /// Unix-style mode bits (9 low bits = rwx for owner/group/other). pub mode: u32, - /// Number of hard links. pub nlinks: u64, - /// Owner user id. pub owner_uid: u32, - /// Owner group id. pub owner_gid: u32, - /// Last modification time, seconds since the Unix epoch. pub mtime: i64, - /// Last access time, seconds since the Unix epoch. pub atime: i64, - /// Last status-change time, seconds since the Unix epoch. pub ctime: i64, - /// Effective read permission for the current process (best-effort). pub is_readable: bool, - /// Effective write permission for the current process (best-effort). pub is_writable: bool, - /// Effective execute permission for the current process (best-effort). pub is_executable: bool, + pub device_major: u32, + pub device_minor: u32, + pub inode: u64, + pub filesystem: String, + pub fs_total_bytes: u64, + pub fs_free_bytes: u64, + pub mount_point: String, + pub symlink_target: Option, } impl FileInfo { - /// Build a `FileInfo` by stat-ing `path`. - /// - /// # Errors - /// - /// Returns the underlying `io::Error` if `stat` fails (path does - /// not exist, permission denied, etc.). pub fn for_path(path: &Path) -> Result { let stat: Stat = crate::fs::stat(path).map_err(stat_error_to_io)?; + let meta = std::fs::metadata(path).ok(); let name = path .file_name() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| path.display().to_string()); let perms = stat.permissions; + let (device_major, device_minor, inode) = meta + .as_ref() + .map(|m| read_dev_ino(m)) + .unwrap_or((0, 0, stat.inode)); + let (filesystem, fs_total_bytes, fs_free_bytes, mount_point) = meta + .as_ref() + .map(|m| read_filesystem(m)) + .unwrap_or_default(); + let file_type = stat.file_type; + let symlink_target = if file_type == FileType::Symlink { + std::fs::read_link(path).ok() + } else { + None + }; Ok(Self { name, path: path.to_path_buf(), size: stat.size, - file_type: stat.file_type, + file_type, mode: perms.to_mode(), nlinks: stat.nlinks, owner_uid: stat.uid, @@ -78,17 +81,25 @@ impl FileInfo { is_readable: current_user_can_read(perms), is_writable: current_user_can_write(perms), is_executable: current_user_can_exec(perms), + device_major, + device_minor, + inode, + filesystem, + fs_total_bytes, + fs_free_bytes, + mount_point, + symlink_target, }) } - /// Human-readable multi-line representation. Each line is - /// `Label: value` — designed to be displayed verbatim in the - /// F11 dialog or written to a log. #[must_use] pub fn format(&self) -> String { - let mut s = String::with_capacity(512); + let mut s = String::with_capacity(1024); s.push_str(&format!("Name: {}\n", self.name)); s.push_str(&format!("Path: {}\n", self.path.display())); + if let Some(target) = &self.symlink_target { + s.push_str(&format!("Link target: {}\n", target.display())); + } s.push_str(&format!("Type: {}\n", file_type_label(self.file_type))); s.push_str(&format!( "Size: {} ({} bytes)\n", @@ -101,9 +112,34 @@ impl FileInfo { )); s.push_str(&format!("Links: {}\n", self.nlinks)); s.push_str(&format!("Owner: {}:{}\n", self.owner_uid, self.owner_gid)); - s.push_str(&format!("Modified: {}\n", format_time(self.mtime))); - s.push_str(&format!("Accessed: {}\n", format_time(self.atime))); - s.push_str(&format!("Changed: {}\n", format_time(self.ctime))); + s.push_str(&format!( + "Location: dev:{}:{} inode:{}\n", + self.device_major, self.device_minor, self.inode + )); + s.push_str(&format!( + "Modified: {}\n", + format_time(self.mtime) + )); + s.push_str(&format!( + "Accessed: {}\n", + format_time(self.atime) + )); + s.push_str(&format!( + "Changed: {}\n", + format_time(self.ctime) + )); + s.push_str(&format!( + "Filesystem: {}\n", + if self.filesystem.is_empty() { "?" } else { &self.filesystem } + )); + if !self.mount_point.is_empty() { + s.push_str(&format!( + "Device: {} ({} free of {})\n", + self.mount_point, + format_bytes(self.fs_free_bytes), + format_bytes(self.fs_total_bytes), + )); + } s.push_str(&format!( "Access: r:{:<5} w:{:<5} x:{:<5}", if self.is_readable { "yes" } else { "no" }, @@ -114,6 +150,93 @@ impl FileInfo { } } +#[cfg(unix)] +fn read_dev_ino(meta: &std::fs::Metadata) -> (u32, u32, u64) { + use std::os::unix::fs::MetadataExt; + let dev = meta.dev(); + let major = (dev >> 8) as u32; + let minor = (dev & 0xff) as u32; + (major, minor, meta.ino()) +} + +#[cfg(not(unix))] +fn read_dev_ino(meta: &std::fs::Metadata) -> (u32, u32, u64) { + let _ = meta; + (0, 0, 0) +} + +#[cfg(unix)] +fn read_filesystem(meta: &std::fs::Metadata) -> (String, u64, u64, String) { + let mount = mount_point_of(meta); + let (fstype, total, free) = mount + .as_ref() + .map(|p| (fs_type(p.as_str()), disk_total(p.as_str()), disk_free(p.as_str()))) + .unwrap_or(("".to_string(), 0, 0)); + (fstype, total, free, mount.unwrap_or_default()) +} + +#[cfg(not(unix))] +fn read_filesystem(_meta: &std::fs::Metadata) -> (String, u64, u64, String) { + (String::new(), 0, 0, String::new()) +} + +#[cfg(unix)] +fn mount_point_of(meta: &std::fs::Metadata) -> Option { + use std::os::unix::fs::MetadataExt; + let target_dev = meta.dev(); + let target_ino = meta.ino(); + let path = std::path::Path::new("/proc/mounts"); + if let Ok(text) = std::fs::read_to_string(path) { + for line in text.lines() { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() < 3 { + continue; + } + let mp = parts[1]; + if let Ok(st) = std::fs::metadata(mp) { + if st.dev() == target_dev && st.ino() == target_ino { + return Some(mp.to_string()); + } + } + } + } + None +} + +#[cfg(unix)] +fn fs_type(mount: &str) -> String { + let path = std::path::Path::new("/proc/mounts"); + if let Ok(text) = std::fs::read_to_string(path) { + for line in text.lines() { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 3 && parts[1] == mount { + return parts[2].to_string(); + } + } + } + String::new() +} + +#[cfg(unix)] +fn disk_total(mount: &str) -> u64 { + use rustix::fs::StatVfs; + if let Ok(st) = rustix::fs::statvfs(mount) { + st.f_blocks * st.f_bsize + } else { + 0 + } +} + +#[cfg(unix)] +fn disk_free(mount: &str) -> u64 { + use rustix::fs::StatVfs; + if let Ok(st) = rustix::fs::statvfs(mount) { + st.f_bavail * st.f_bsize + } else { + 0 + } +} + fn file_type_label(ft: FileType) -> &'static str { match ft { FileType::Regular => "Regular file", @@ -320,6 +443,14 @@ mod tests { is_readable: true, is_writable: true, is_executable: true, + device_major: 0, + device_minor: 0, + inode: 0, + filesystem: String::new(), + fs_total_bytes: 0, + fs_free_bytes: 0, + mount_point: String::new(), + symlink_target: std::fs::read_link(&link).ok(), }; assert_eq!(info.file_type, FileType::Symlink); assert!(info.format().contains("Symbolic link"));