tlc: Phase C.1 — Info dialog full MC parity (Location/Device/Filesystem/Symlink target)
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)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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<PathBuf>,
|
||||
}
|
||||
|
||||
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<Self, io::Error> {
|
||||
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<String> {
|
||||
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"));
|
||||
|
||||
Reference in New Issue
Block a user