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    let dev_path = if line.starts_with('/') {
99        line.to_string()
100    } else {
101        format!("/dev/{}", line)
102    };
103
104    let mut stat_buf: libc::stat = unsafe { std::mem::zeroed() };
105    let c_path = std::ffi::CString::new(dev_path).unwrap_or_default();
106    let rc = unsafe { libc::stat(c_path.as_ptr(), &mut stat_buf) };
107    if rc != 0 {
108        return "?".to_string();
109    }
110
111    let now = unsafe { libc::time(std::ptr::null_mut()) };
112    let atime = stat_buf.st_atime;
113    let idle_secs = now - atime;
114
115    if idle_secs < 60 {
116        ".".to_string()
117    } else {
118        let hours = idle_secs / 3600;
119        let mins = (idle_secs % 3600) / 60;
120        format!("{:02}:{:02}", hours, mins)
121    }
122}
123
124/// Format a Unix timestamp as "Mon DD HH:MM" (short format).
125fn format_time_short(tv_sec: i64) -> String {
126    if tv_sec == 0 {
127        return String::new();
128    }
129    let t = tv_sec as libc::time_t;
130    let tm = unsafe {
131        let mut tm: libc::tm = std::mem::zeroed();
132        libc::localtime_r(&t, &mut tm);
133        tm
134    };
135    let months = [
136        "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
137    ];
138    let mon = if tm.tm_mon >= 0 && tm.tm_mon <= 11 {
139        months[tm.tm_mon as usize]
140    } else {
141        "???"
142    };
143    format!(
144        "{} {:2} {:02}:{:02}",
145        mon, tm.tm_mday, tm.tm_hour, tm.tm_min
146    )
147}
148
149/// Read a file's first line, returning it or an empty string.
150fn read_first_line(path: &PathBuf) -> String {
151    match std::fs::read_to_string(path) {
152        Ok(contents) => contents.lines().next().unwrap_or("").to_string(),
153        Err(_) => String::new(),
154    }
155}
156
157/// Read a file's full content, returning it or an empty string.
158fn read_file_contents(path: &PathBuf) -> String {
159    std::fs::read_to_string(path).unwrap_or_default()
160}
161
162/// Format the short-format heading line.
163pub fn format_short_heading(config: &PinkyConfig) -> String {
164    let mut out = String::new();
165    let _ = write!(out, "{:<8}", "Login");
166    if !config.omit_fullname && !config.omit_fullname_host && !config.omit_fullname_host_idle {
167        let _ = write!(out, " {:<20}", "Name");
168    }
169    let _ = write!(out, " {:<8}", "TTY");
170    if !config.omit_fullname_host_idle {
171        let _ = write!(out, " {:>6}", "Idle");
172    }
173    let _ = write!(out, " {:<16}", "When");
174    if !config.omit_fullname_host && !config.omit_fullname_host_idle {
175        let _ = write!(out, " {}", "Where");
176    }
177    out
178}
179
180/// Format a single entry in short format.
181pub fn format_short_entry(entry: &who::UtmpxEntry, config: &PinkyConfig) -> String {
182    let mut out = String::new();
183
184    // Login name
185    let _ = write!(out, "{:<8}", entry.ut_user);
186
187    // Full name
188    if !config.omit_fullname && !config.omit_fullname_host && !config.omit_fullname_host_idle {
189        let fullname = get_user_info(&entry.ut_user)
190            .map(|u| u.fullname)
191            .unwrap_or_default();
192        // Truncate full name to 20 chars for alignment
193        let display_name: String = fullname.chars().take(20).collect();
194        let _ = write!(out, " {:<20}", display_name);
195    }
196
197    // Tty
198    let tty = entry
199        .ut_line
200        .strip_prefix("pts/")
201        .map(|s| format!("pts/{}", s))
202        .unwrap_or_else(|| entry.ut_line.clone());
203    let _ = write!(out, " {:<8}", tty);
204
205    // Idle time
206    if !config.omit_fullname_host_idle {
207        let idle = idle_str(&entry.ut_line);
208        let _ = write!(out, " {:>6}", idle);
209    }
210
211    // When (login time)
212    let time_str = format_time_short(entry.ut_tv_sec);
213    let _ = write!(out, " {:<16}", time_str);
214
215    // Where (remote host)
216    if !config.omit_fullname_host && !config.omit_fullname_host_idle {
217        if !entry.ut_host.is_empty() {
218            let _ = write!(out, " {}", entry.ut_host);
219        }
220    }
221
222    out
223}
224
225/// Format output in long format for a specific user.
226pub fn format_long_entry(username: &str, config: &PinkyConfig) -> String {
227    let mut out = String::new();
228
229    let info = get_user_info(username);
230
231    let _ = write!(out, "Login name: {:<28}", username);
232    if let Some(ref info) = info {
233        let _ = write!(out, "In real life:  {}", info.fullname);
234    }
235    let _ = writeln!(out);
236
237    if !config.omit_home_shell {
238        if let Some(ref info) = info {
239            let _ = write!(out, "Directory: {:<29}", info.home_dir);
240            let _ = writeln!(out, "Shell:  {}", info.shell);
241        } else {
242            let _ = writeln!(out, "Directory: ???");
243        }
244    }
245
246    // Project file
247    if !config.omit_project {
248        if let Some(ref info) = info {
249            let project_path = PathBuf::from(&info.home_dir).join(".project");
250            if project_path.exists() {
251                let project = read_first_line(&project_path);
252                if !project.is_empty() {
253                    let _ = writeln!(out, "Project: {}", project);
254                }
255            }
256        }
257    }
258
259    // Plan file
260    if !config.omit_plan {
261        if let Some(ref info) = info {
262            let plan_path = PathBuf::from(&info.home_dir).join(".plan");
263            if plan_path.exists() {
264                let plan = read_file_contents(&plan_path);
265                if !plan.is_empty() {
266                    let _ = writeln!(out, "Plan:");
267                    let _ = write!(out, "{}", plan);
268                    // Ensure plan ends with newline
269                    if !plan.ends_with('\n') {
270                        let _ = writeln!(out);
271                    }
272                }
273            }
274        }
275    }
276
277    // Remove trailing newline for consistency
278    if out.ends_with('\n') {
279        out.pop();
280    }
281
282    out
283}
284
285/// Run the pinky command and return the formatted output.
286pub fn run_pinky(config: &PinkyConfig) -> String {
287    let mut output = String::new();
288
289    if config.long_format {
290        // Long format: show detailed info for each specified user
291        let users = if config.users.is_empty() {
292            // If no users specified in long mode, show logged-in users
293            let entries = who::read_utmpx();
294            let mut names: Vec<String> = entries
295                .iter()
296                .filter(|e| e.ut_type == 7) // USER_PROCESS
297                .map(|e| e.ut_user.clone())
298                .collect();
299            names.sort();
300            names.dedup();
301            names
302        } else {
303            config.users.clone()
304        };
305
306        for (i, user) in users.iter().enumerate() {
307            if i > 0 {
308                let _ = writeln!(output);
309            }
310            let _ = write!(output, "{}", format_long_entry(user, config));
311        }
312    } else {
313        // Short format (default)
314        let entries = who::read_utmpx();
315
316        if !config.omit_heading {
317            let _ = writeln!(output, "{}", format_short_heading(config));
318        }
319
320        let user_entries: Vec<&who::UtmpxEntry> = entries
321            .iter()
322            .filter(|e| e.ut_type == 7) // USER_PROCESS
323            .filter(|e| {
324                if config.users.is_empty() {
325                    true
326                } else {
327                    config.users.iter().any(|u| u == &e.ut_user)
328                }
329            })
330            .collect();
331
332        for entry in &user_entries {
333            let _ = writeln!(output, "{}", format_short_entry(entry, config));
334        }
335    }
336
337    // Remove trailing newline for consistency
338    if output.ends_with('\n') {
339        output.pop();
340    }
341
342    output
343}