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.
272/// Returns the complete output including trailing newlines, matching GNU pinky:
273/// - Nonexistent user: "Login name: ... In real life:  ???\n" (no trailing blank line)
274/// - Existing user: all info lines + trailing blank line (\n)
275pub fn format_long_entry(username: &str, config: &PinkyConfig) -> String {
276    let mut out = String::new();
277
278    let info = get_user_info(username);
279
280    let _ = write!(out, "Login name: {:<28}", username);
281
282    if info.is_none() {
283        // GNU prints "???\n" and returns immediately — no trailing blank line
284        let _ = writeln!(out, "In real life:  ???");
285        return out;
286    }
287
288    let info = info.unwrap();
289    let _ = writeln!(out, "In real life:  {}", info.fullname);
290
291    if !config.omit_home_shell {
292        let _ = write!(out, "Directory: {:<29}", info.home_dir);
293        let _ = writeln!(out, "Shell:  {}", info.shell);
294    }
295
296    // Project file
297    if !config.omit_project {
298        let project_path = PathBuf::from(&info.home_dir).join(".project");
299        if project_path.exists() {
300            let project = read_first_line(&project_path);
301            if !project.is_empty() {
302                let _ = writeln!(out, "Project: {}", project);
303            }
304        }
305    }
306
307    // Plan file
308    if !config.omit_plan {
309        let plan_path = PathBuf::from(&info.home_dir).join(".plan");
310        if plan_path.exists() {
311            let plan = read_file_contents(&plan_path);
312            if !plan.is_empty() {
313                let _ = writeln!(out, "Plan:");
314                let _ = write!(out, "{}", plan);
315                // Ensure plan ends with newline
316                if !plan.ends_with('\n') {
317                    let _ = writeln!(out);
318                }
319            }
320        }
321    }
322
323    // GNU adds a trailing blank line after each existing user's entry
324    let _ = writeln!(out);
325
326    out
327}
328
329/// Run the pinky command and return the formatted output.
330pub fn run_pinky(config: &PinkyConfig) -> String {
331    let mut output = String::new();
332
333    if config.long_format {
334        // Long format: show detailed info for each specified user
335        let users = if config.users.is_empty() {
336            // If no users specified in long mode, show logged-in users
337            let entries = who::read_utmpx_with_systemd_fallback_no_pid_check();
338            let mut names: Vec<String> = entries
339                .iter()
340                .filter(|e| e.ut_type == 7) // USER_PROCESS
341                .map(|e| e.ut_user.clone())
342                .collect();
343            names.sort();
344            names.dedup();
345            names
346        } else {
347            config.users.clone()
348        };
349
350        for user in users.iter() {
351            // format_long_entry returns complete output with proper trailing newlines
352            let _ = write!(output, "{}", format_long_entry(user, config));
353        }
354    } else {
355        // Short format (default)
356        let entries = who::read_utmpx_with_systemd_fallback_no_pid_check();
357
358        if !config.omit_heading {
359            let _ = writeln!(output, "{}", format_short_heading(config));
360        }
361
362        let mut user_entries: Vec<&who::UtmpxEntry> = entries
363            .iter()
364            .filter(|e| e.ut_type == 7) // USER_PROCESS
365            .filter(|e| {
366                if config.users.is_empty() {
367                    true
368                } else {
369                    config.users.iter().any(|u| u == &e.ut_user)
370                }
371            })
372            .collect();
373        // GNU pinky reads sessions from systemd-logind which returns newest first.
374        // Sort descending by login time to match that order.
375        user_entries.sort_by(|a, b| b.ut_tv_sec.cmp(&a.ut_tv_sec));
376
377        for entry in &user_entries {
378            let _ = writeln!(output, "{}", format_short_entry(entry, config));
379        }
380    }
381
382    // Remove trailing newline — fpinky's println! adds one back
383    if output.ends_with('\n') {
384        output.pop();
385    }
386
387    output
388}