Skip to main content

coreutils_rs/pinky/
core.rs

1/// pinky — lightweight finger information lookup
2///
3/// A simplified version of the finger command that displays information
4/// about currently logged-in users using utmpx records and passwd entries.
5use std::ffi::CStr;
6use std::fmt::Write as FmtWrite;
7use std::path::PathBuf;
8
9use crate::who;
10
11/// Configuration for the pinky command, derived from CLI flags.
12#[derive(Clone, Debug)]
13pub struct PinkyConfig {
14    /// Use long format output (-l).
15    pub long_format: bool,
16    /// Omit home directory and shell in long format (-b).
17    pub omit_home_shell: bool,
18    /// Omit project file in long format (-h).
19    pub omit_project: bool,
20    /// Omit plan file in long format (-p).
21    pub omit_plan: bool,
22    /// Short format (default) (-s).
23    pub short_format: bool,
24    /// Omit column heading in short format (-f).
25    pub omit_heading: bool,
26    /// Omit full name in short format (-w).
27    pub omit_fullname: bool,
28    /// Omit full name and remote host in short format (-i).
29    pub omit_fullname_host: bool,
30    /// Omit full name, remote host, and idle time in short format (-q).
31    pub omit_fullname_host_idle: bool,
32    /// Specific users to look up (positional args).
33    pub users: Vec<String>,
34}
35
36impl Default for PinkyConfig {
37    fn default() -> Self {
38        Self {
39            long_format: false,
40            omit_home_shell: false,
41            omit_project: false,
42            omit_plan: false,
43            short_format: true,
44            omit_heading: false,
45            omit_fullname: false,
46            omit_fullname_host: false,
47            omit_fullname_host_idle: false,
48            users: Vec::new(),
49        }
50    }
51}
52
53/// Passwd entry information for a user.
54#[derive(Clone, Debug)]
55pub struct UserInfo {
56    pub login: String,
57    pub fullname: String,
58    pub home_dir: String,
59    pub shell: String,
60}
61
62/// Look up a user's passwd entry by login name.
63pub fn get_user_info(username: &str) -> Option<UserInfo> {
64    let c_name = std::ffi::CString::new(username).ok()?;
65    unsafe {
66        let pw = libc::getpwnam(c_name.as_ptr());
67        if pw.is_null() {
68            return None;
69        }
70        let pw = &*pw;
71
72        let login = CStr::from_ptr(pw.pw_name).to_string_lossy().into_owned();
73        let gecos = if pw.pw_gecos.is_null() {
74            String::new()
75        } else {
76            CStr::from_ptr(pw.pw_gecos).to_string_lossy().into_owned()
77        };
78        // GECOS field may have multiple comma-separated values; first is the full name
79        let fullname = gecos.split(',').next().unwrap_or("").to_string();
80        let home_dir = CStr::from_ptr(pw.pw_dir).to_string_lossy().into_owned();
81        let shell = CStr::from_ptr(pw.pw_shell).to_string_lossy().into_owned();
82
83        Some(UserInfo {
84            login,
85            fullname,
86            home_dir,
87            shell,
88        })
89    }
90}
91
92/// Compute idle time string for a terminal.
93/// Returns "." if active within the last minute, or "HH:MM" otherwise.
94fn idle_str(line: &str) -> String {
95    if line.is_empty() {
96        return "?????".to_string();
97    }
98    // Extract the actual device path from lines like "sshd pts/0"
99    let dev_path = if line.starts_with('/') {
100        line.to_string()
101    } else if let Some(idx) = line.find("pts/") {
102        format!("/dev/{}", &line[idx..])
103    } else if let Some(idx) = line.find("tty") {
104        format!("/dev/{}", &line[idx..])
105    } else {
106        format!("/dev/{}", line)
107    };
108
109    let mut stat_buf: libc::stat = unsafe { std::mem::zeroed() };
110    let c_path = std::ffi::CString::new(dev_path).unwrap_or_default();
111    let rc = unsafe { libc::stat(c_path.as_ptr(), &mut stat_buf) };
112    if rc != 0 {
113        return "?????".to_string();
114    }
115
116    let now = unsafe { libc::time(std::ptr::null_mut()) };
117    let atime = stat_buf.st_atime;
118    let idle_secs = now - atime;
119
120    if idle_secs < 60 {
121        // GNU pinky shows blank (not ".") for recently active terminals
122        String::new()
123    } else {
124        let hours = idle_secs / 3600;
125        let mins = (idle_secs % 3600) / 60;
126        format!("{:02}:{:02}", hours, mins)
127    }
128}
129
130/// Format a Unix timestamp as "YYYY-MM-DD HH:MM" (ISO short format, matches GNU coreutils 9.7+).
131fn format_time_short(tv_sec: i64) -> String {
132    if tv_sec == 0 {
133        return String::new();
134    }
135    let t = tv_sec as libc::time_t;
136    let tm = unsafe {
137        let mut tm: libc::tm = std::mem::zeroed();
138        libc::localtime_r(&t, &mut tm);
139        tm
140    };
141    format!(
142        "{:04}-{:02}-{:02} {:02}:{:02}",
143        tm.tm_year + 1900,
144        tm.tm_mon + 1,
145        tm.tm_mday,
146        tm.tm_hour,
147        tm.tm_min
148    )
149}
150
151/// Read a file's first line, returning it or an empty string.
152fn read_first_line(path: &PathBuf) -> String {
153    match std::fs::read_to_string(path) {
154        Ok(contents) => contents.lines().next().unwrap_or("").to_string(),
155        Err(_) => String::new(),
156    }
157}
158
159/// Read a file's full content, returning it or an empty string.
160fn read_file_contents(path: &PathBuf) -> String {
161    std::fs::read_to_string(path).unwrap_or_default()
162}
163
164/// Format the short-format heading line (matches GNU pinky column widths).
165/// GNU format: %-8s "Login" | " %-19s" "Name" | " %-9s" " TTY" | " %-6s" "Idle" | " %-16s" "When" | " %s" "Where"
166pub fn format_short_heading(config: &PinkyConfig) -> String {
167    let mut out = String::new();
168    let _ = write!(out, "{:<8}", "Login");
169    if !config.omit_fullname && !config.omit_fullname_host && !config.omit_fullname_host_idle {
170        let _ = write!(out, " {:<19}", "Name");
171    }
172    // GNU uses " %-9s" with " TTY" (note leading space in argument = 4 chars padded to 9)
173    let _ = write!(out, " {:<9}", " TTY");
174    if !config.omit_fullname_host_idle {
175        let _ = write!(out, " {:<6}", "Idle");
176    }
177    let _ = write!(out, " {:<16}", "When");
178    if !config.omit_fullname_host && !config.omit_fullname_host_idle {
179        let _ = write!(out, " {}", "Where");
180    }
181    out
182}
183
184/// Determine the message status character for a terminal line (pinky format).
185/// ' ' means writable (mesg y), '*' means not writable (mesg n), '?' means unknown.
186fn pinky_mesg_status(line: &str) -> char {
187    // Extract the device part: for "sshd pts/0", extract "pts/0"
188    let dev_part = if let Some(space_idx) = line.find(' ') {
189        &line[space_idx + 1..]
190    } else {
191        line
192    };
193
194    if dev_part.is_empty() {
195        return '?';
196    }
197
198    let dev_path = if dev_part.starts_with('/') {
199        dev_part.to_string()
200    } else {
201        format!("/dev/{}", dev_part)
202    };
203
204    let mut stat_buf: libc::stat = unsafe { std::mem::zeroed() };
205    let c_path = std::ffi::CString::new(dev_path).unwrap_or_default();
206    let rc = unsafe { libc::stat(c_path.as_ptr(), &mut stat_buf) };
207    if rc != 0 {
208        return '?';
209    }
210    if stat_buf.st_mode & libc::S_IWGRP != 0 {
211        ' '
212    } else {
213        '*'
214    }
215}
216
217/// Format a single entry in short format (matches GNU pinky format exactly).
218pub fn format_short_entry(entry: &who::UtmpxEntry, config: &PinkyConfig) -> String {
219    let mut out = String::new();
220
221    // Login name: %-8s
222    let user = &entry.ut_user;
223    if user.len() < 8 {
224        let _ = write!(out, "{:<8}", user);
225    } else {
226        let _ = write!(out, "{}", user);
227    }
228
229    // Full name: " %-19.19s"
230    if !config.omit_fullname && !config.omit_fullname_host && !config.omit_fullname_host_idle {
231        let fullname = get_user_info(&entry.ut_user)
232            .map(|u| u.fullname)
233            .unwrap_or_default();
234        // Truncate full name to 19 chars for alignment (GNU uses %-19.19s)
235        let display_name: String = fullname.chars().take(19).collect();
236        let _ = write!(out, " {:<19}", display_name);
237    }
238
239    // Mesg status: space + mesg_char (GNU: fputc(' '), fputc(mesg))
240    let mesg = pinky_mesg_status(&entry.ut_line);
241    let _ = write!(out, " {}", mesg);
242
243    // TTY line: %-8s (may overflow for long lines like "sshd pts/0")
244    let line = &entry.ut_line;
245    if line.len() < 8 {
246        let _ = write!(out, "{:<8}", line);
247    } else {
248        let _ = write!(out, "{}", line);
249    }
250
251    // Idle time: " %-6s"
252    if !config.omit_fullname_host_idle {
253        let idle = idle_str(&entry.ut_line);
254        let _ = write!(out, " {:<6}", idle);
255    }
256
257    // When (login time): " %s"
258    let time_str = format_time_short(entry.ut_tv_sec);
259    let _ = write!(out, " {}", time_str);
260
261    // Where (remote host)
262    if !config.omit_fullname_host && !config.omit_fullname_host_idle {
263        if !entry.ut_host.is_empty() {
264            let _ = write!(out, " {}", entry.ut_host);
265        }
266    }
267
268    out
269}
270
271/// Format output in long format for a specific user.
272pub fn format_long_entry(username: &str, config: &PinkyConfig) -> String {
273    let mut out = String::new();
274
275    let info = get_user_info(username);
276
277    let _ = write!(out, "Login name: {:<28}", username);
278    if let Some(ref info) = info {
279        let _ = write!(out, "In real life:  {}", info.fullname);
280    }
281    let _ = writeln!(out);
282
283    if !config.omit_home_shell {
284        if let Some(ref info) = info {
285            let _ = write!(out, "Directory: {:<29}", info.home_dir);
286            let _ = writeln!(out, "Shell:  {}", info.shell);
287        } else {
288            let _ = writeln!(out, "Directory: ???");
289        }
290    }
291
292    // Project file
293    if !config.omit_project {
294        if let Some(ref info) = info {
295            let project_path = PathBuf::from(&info.home_dir).join(".project");
296            if project_path.exists() {
297                let project = read_first_line(&project_path);
298                if !project.is_empty() {
299                    let _ = writeln!(out, "Project: {}", project);
300                }
301            }
302        }
303    }
304
305    // Plan file
306    if !config.omit_plan {
307        if let Some(ref info) = info {
308            let plan_path = PathBuf::from(&info.home_dir).join(".plan");
309            if plan_path.exists() {
310                let plan = read_file_contents(&plan_path);
311                if !plan.is_empty() {
312                    let _ = writeln!(out, "Plan:");
313                    let _ = write!(out, "{}", plan);
314                    // Ensure plan ends with newline
315                    if !plan.ends_with('\n') {
316                        let _ = writeln!(out);
317                    }
318                }
319            }
320        }
321    }
322
323    // Remove trailing newline for consistency - caller adds blank line separator
324    if out.ends_with('\n') {
325        out.pop();
326    }
327
328    out
329}
330
331/// Run the pinky command and return the formatted output.
332pub fn run_pinky(config: &PinkyConfig) -> String {
333    let mut output = String::new();
334
335    if config.long_format {
336        // Long format: show detailed info for each specified user
337        let users = if config.users.is_empty() {
338            // If no users specified in long mode, show logged-in users
339            let entries = who::read_utmpx_with_systemd_fallback_no_pid_check();
340            let mut names: Vec<String> = entries
341                .iter()
342                .filter(|e| e.ut_type == 7) // USER_PROCESS
343                .map(|e| e.ut_user.clone())
344                .collect();
345            names.sort();
346            names.dedup();
347            names
348        } else {
349            config.users.clone()
350        };
351
352        for user in users.iter() {
353            // Write entry then blank line (GNU pinky separates entries with blank lines)
354            let _ = writeln!(output, "{}", format_long_entry(user, config));
355        }
356    } else {
357        // Short format (default)
358        let entries = who::read_utmpx_with_systemd_fallback_no_pid_check();
359
360        if !config.omit_heading {
361            let _ = writeln!(output, "{}", format_short_heading(config));
362        }
363
364        let mut user_entries: Vec<&who::UtmpxEntry> = entries
365            .iter()
366            .filter(|e| e.ut_type == 7) // USER_PROCESS
367            .filter(|e| {
368                if config.users.is_empty() {
369                    true
370                } else {
371                    config.users.iter().any(|u| u == &e.ut_user)
372                }
373            })
374            .collect();
375        // GNU pinky reads sessions from systemd-logind which returns newest first.
376        // Sort descending by login time to match that order.
377        user_entries.sort_by(|a, b| b.ut_tv_sec.cmp(&a.ut_tv_sec));
378
379        for entry in &user_entries {
380            let _ = writeln!(output, "{}", format_short_entry(entry, config));
381        }
382    }
383
384    // Short format: remove trailing newline (fpinky uses println! which adds one)
385    // Long format: keep trailing newline so fpinky's println! creates the blank separator
386    if !config.long_format && output.ends_with('\n') {
387        output.pop();
388    }
389
390    output
391}