1use std::ffi::CStr;
6use std::fmt::Write as FmtWrite;
7use std::path::PathBuf;
8
9use crate::who;
10
11#[derive(Clone, Debug)]
13pub struct PinkyConfig {
14 pub long_format: bool,
16 pub omit_home_shell: bool,
18 pub omit_project: bool,
20 pub omit_plan: bool,
22 pub short_format: bool,
24 pub omit_heading: bool,
26 pub omit_fullname: bool,
28 pub omit_fullname_host: bool,
30 pub omit_fullname_host_idle: bool,
32 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#[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
62pub 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 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
92fn idle_str(line: &str) -> String {
95 if line.is_empty() {
96 return "?????".to_string();
97 }
98 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 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
130fn 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
151fn 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
159fn read_file_contents(path: &PathBuf) -> String {
161 std::fs::read_to_string(path).unwrap_or_default()
162}
163
164pub 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 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
184fn pinky_mesg_status(line: &str) -> char {
187 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
217pub fn format_short_entry(entry: &who::UtmpxEntry, config: &PinkyConfig) -> String {
219 let mut out = String::new();
220
221 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 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 let display_name: String = fullname.chars().take(19).collect();
236 let _ = write!(out, " {:<19}", display_name);
237 }
238
239 let mesg = pinky_mesg_status(&entry.ut_line);
241 let _ = write!(out, " {}", mesg);
242
243 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 if !config.omit_fullname_host_idle {
253 let idle = idle_str(&entry.ut_line);
254 let _ = write!(out, " {:<6}", idle);
255 }
256
257 let time_str = format_time_short(entry.ut_tv_sec);
259 let _ = write!(out, " {}", time_str);
260
261 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
271pub 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 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 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 if !plan.ends_with('\n') {
316 let _ = writeln!(out);
317 }
318 }
319 }
320 }
321 }
322
323 if out.ends_with('\n') {
325 out.pop();
326 }
327
328 out
329}
330
331pub fn run_pinky(config: &PinkyConfig) -> String {
333 let mut output = String::new();
334
335 if config.long_format {
336 let users = if config.users.is_empty() {
338 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) .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 let _ = writeln!(output, "{}", format_long_entry(user, config));
355 }
356 } else {
357 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) .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 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 if !config.long_format && output.ends_with('\n') {
387 output.pop();
388 }
389
390 output
391}