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('/') {
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
124fn 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
149fn 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
157fn read_file_contents(path: &PathBuf) -> String {
159 std::fs::read_to_string(path).unwrap_or_default()
160}
161
162pub 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
180pub fn format_short_entry(entry: &who::UtmpxEntry, config: &PinkyConfig) -> String {
182 let mut out = String::new();
183
184 let _ = write!(out, "{:<8}", entry.ut_user);
186
187 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 let display_name: String = fullname.chars().take(20).collect();
194 let _ = write!(out, " {:<20}", display_name);
195 }
196
197 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 if !config.omit_fullname_host_idle {
207 let idle = idle_str(&entry.ut_line);
208 let _ = write!(out, " {:>6}", idle);
209 }
210
211 let time_str = format_time_short(entry.ut_tv_sec);
213 let _ = write!(out, " {:<16}", time_str);
214
215 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
225pub 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 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 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 if !plan.ends_with('\n') {
270 let _ = writeln!(out);
271 }
272 }
273 }
274 }
275 }
276
277 if out.ends_with('\n') {
279 out.pop();
280 }
281
282 out
283}
284
285pub fn run_pinky(config: &PinkyConfig) -> String {
287 let mut output = String::new();
288
289 if config.long_format {
290 let users = if config.users.is_empty() {
292 let entries = who::read_utmpx();
294 let mut names: Vec<String> = entries
295 .iter()
296 .filter(|e| e.ut_type == 7) .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 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) .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 if output.ends_with('\n') {
339 output.pop();
340 }
341
342 output
343}