1use crate::store::{self, Meta, MetaSelection};
2use serde::Serialize;
3use std::borrow::Cow;
4use std::collections::BTreeMap;
5use std::fmt::Write as FmtWrite;
6use std::io::{self, IsTerminal, Write};
7use std::mem::MaybeUninit;
8use std::time::{SystemTime, UNIX_EPOCH};
9
10#[derive(Clone, Debug)]
15pub(crate) struct DecoratedEntry {
16 pub(crate) id: String,
17 pub(crate) size_bytes: String,
18 pub(crate) size_human: String,
19 pub(crate) date: String,
20 pub(crate) preview: String,
21 pub(crate) filename: Option<String>,
22 pub(crate) meta_vals: Vec<String>,
23 pub(crate) meta_inline: String,
24}
25
26pub(crate) fn decorate_entries(
27 items: &[Meta],
28 id_mode: &str,
29 date_mode: &str,
30 preview_chars: usize,
31 meta_sel: &MetaSelection,
32) -> Vec<DecoratedEntry> {
33 let now_secs = SystemTime::now()
34 .duration_since(UNIX_EPOCH)
35 .unwrap_or_default()
36 .as_secs() as i64;
37 items
38 .iter()
39 .enumerate()
40 .map(|(idx, item)| {
41 decorate_entry(item, idx, id_mode, date_mode, preview_chars, meta_sel, now_secs)
42 })
43 .collect()
44}
45
46fn decorate_entry(
47 item: &Meta,
48 idx: usize,
49 id_mode: &str,
50 date_mode: &str,
51 preview_chars: usize,
52 meta_sel: &MetaSelection,
53 now_secs: i64,
54) -> DecoratedEntry {
55 let filename = item.attrs.get("filename").cloned();
56 let meta_vals = if !meta_sel.display_tags.is_empty() {
57 meta_sel
58 .display_tags
59 .iter()
60 .map(|tag| {
61 item.attrs
62 .get(tag)
63 .map(|value| escape_attr_output(value).into_owned())
64 .unwrap_or_else(|| " ".to_owned())
65 })
66 .collect()
67 } else {
68 Vec::new()
69 };
70 let meta_inline = if meta_sel.show_all && !item.attrs.is_empty() {
71 item.attrs
72 .values()
73 .map(|value| escape_attr_output(value))
74 .collect::<Vec<Cow<str>>>()
75 .join(" ")
76 } else {
77 String::new()
78 };
79 let preview = if item.preview.is_empty() {
80 String::new()
81 } else {
82 preview_snippet(&item.preview, preview_chars)
83 };
84 DecoratedEntry {
85 id: display_id(item, idx, id_mode),
86 size_bytes: item.size.to_string(),
87 size_human: store::human_size(item.size),
88 date: format_date(&item.ts, date_mode, now_secs),
89 preview,
90 filename,
91 meta_vals,
92 meta_inline,
93 }
94}
95
96pub(crate) fn display_id(item: &Meta, idx: usize, mode: &str) -> String {
101 match mode {
102 "full" => item.display_id().to_owned(),
103 "pos" => (idx + 1).to_string(),
104 _ => item.short_id().to_owned(),
105 }
106}
107
108pub(crate) fn escape_attr_output(input: &str) -> Cow<'_, str> {
112 if !input.bytes().any(|b| matches!(b, b'\\' | b'\n' | b'\r' | b'\t')) {
114 return Cow::Borrowed(input);
115 }
116 let mut out = String::with_capacity(input.len());
117 for ch in input.chars() {
118 match ch {
119 '\\' => out.push_str("\\\\"),
120 '\n' => out.push_str("\\n"),
121 '\r' => out.push_str("\\r"),
122 '\t' => out.push_str("\\t"),
123 other => out.push(other),
124 }
125 }
126 Cow::Owned(out)
127}
128
129pub(crate) fn preview_snippet(preview: &str, chars: usize) -> String {
130 if chars == 0 {
131 return String::new();
132 }
133 let mut out = String::new();
134 let mut it = preview.chars();
135 for _ in 0..chars {
136 match it.next() {
137 Some(ch) => out.push(ch),
138 None => return out,
139 }
140 }
141 if it.next().is_some() && chars > 3 {
142 out.push_str("...");
143 }
144 out
145}
146
147pub(crate) fn is_writable_attr_key(key: &str) -> bool {
148 match key {
149 "id" | "ts" | "size" | "preview" => false,
150 _ => {
151 if key.is_empty() || key.starts_with('-') || key.ends_with('-') {
152 return false;
153 }
154 let mut prev_dash = false;
155 for ch in key.chars() {
156 let ok = ch.is_ascii_alphanumeric() || ch == '_' || ch == '-';
157 if !ok {
158 return false;
159 }
160 if ch == '-' {
161 if prev_dash {
162 return false;
163 }
164 prev_dash = true;
165 } else {
166 prev_dash = false;
167 }
168 }
169 true
170 }
171 }
172}
173
174pub(crate) fn attr_value(meta: &Meta, key: &str, with_preview: bool) -> Option<String> {
175 match key {
176 "id" => Some(meta.display_id().to_owned()),
177 "ts" => Some(meta.ts.clone()),
178 "size" => Some(meta.size.to_string()),
179 "preview" if with_preview || !meta.preview.is_empty() => {
180 (!meta.preview.is_empty()).then(|| meta.preview.clone())
181 }
182 _ => meta.attrs.get(key).cloned(),
183 }
184}
185
186pub(crate) fn color_enabled(value: &str) -> io::Result<bool> {
191 match value {
192 "true" => Ok(io::stdout().is_terminal()),
193 "false" => Ok(false),
194 _ => Err(io::Error::new(
195 io::ErrorKind::InvalidInput,
196 "--color must be true or false",
197 )),
198 }
199}
200
201pub(crate) fn push_colorized(buf: &mut String, s: &str, code: &str, enabled: bool) {
202 if enabled && !s.is_empty() {
203 let _ = write!(buf, "\x1b[{code}m{s}\x1b[0m");
204 } else {
205 buf.push_str(s);
206 }
207}
208
209pub(crate) fn write_colored<W: Write>(
210 out: &mut W,
211 s: &str,
212 code: &str,
213 enabled: bool,
214) -> io::Result<()> {
215 if enabled && !s.is_empty() {
216 write!(out, "\x1b[{code}m{s}\x1b[0m")
217 } else {
218 write!(out, "{s}")
219 }
220}
221
222pub(crate) fn pad_right(s: &str, width: usize) -> Cow<'_, str> {
223 let len = s.chars().count();
224 if len >= width {
225 Cow::Borrowed(s)
226 } else {
227 Cow::Owned(format!("{s}{}", " ".repeat(width - len)))
228 }
229}
230
231pub(crate) fn pad_left(s: &str, width: usize) -> Cow<'_, str> {
232 let len = s.chars().count();
233 if len >= width {
234 Cow::Borrowed(s)
235 } else {
236 Cow::Owned(format!("{}{}", " ".repeat(width - len), s))
237 }
238}
239
240pub(crate) fn terminal_width() -> Option<usize> {
241 if !io::stdout().is_terminal() {
242 return None;
243 }
244 #[cfg(unix)]
245 {
246 use std::os::fd::AsRawFd;
247
248 #[repr(C)]
249 struct WinSize {
250 ws_row: u16,
251 ws_col: u16,
252 ws_xpixel: u16,
253 ws_ypixel: u16,
254 }
255
256 unsafe extern "C" {
257 fn ioctl(fd: i32, request: u64, ...) -> i32;
258 }
259
260 const TIOCGWINSZ: u64 = 0x40087468;
261 let fd = io::stdout().as_raw_fd();
262 let mut ws = MaybeUninit::<WinSize>::uninit();
263 let rc = unsafe { ioctl(fd, TIOCGWINSZ, ws.as_mut_ptr()) };
265 if rc == 0 {
266 let ws = unsafe { ws.assume_init() };
268 if ws.ws_col > 0 {
269 return Some(ws.ws_col as usize);
270 }
271 }
272 }
273 None
274}
275
276pub(crate) fn trim_ansi_to_width(s: &str, width: usize) -> String {
277 if width == 0 {
278 return String::new();
279 }
280 let bytes = s.as_bytes();
281 let mut out = String::new();
282 let mut visible = 0usize;
283 let mut chars = s.char_indices().peekable();
284 while let Some((i, ch)) = chars.next() {
285 if ch == '\x1b' && bytes.get(i + 1) == Some(&b'[') {
286 let end = bytes[i + 2..]
287 .iter()
288 .position(|&b| (0x40..=0x7e).contains(&b))
289 .map(|p| i + 2 + p + 1)
290 .unwrap_or(bytes.len());
291 out.push_str(&s[i..end]);
292 while chars.peek().map(|(j, _)| *j < end).unwrap_or(false) {
293 chars.next();
294 }
295 continue;
296 }
297 if visible >= width {
298 break;
299 }
300 out.push(ch);
301 visible += 1;
302 }
303 if visible >= width {
304 out.push_str("\x1b[0m");
305 }
306 out
307}
308
309pub(crate) fn normalize_date_mode(mode: &str) -> io::Result<&str> {
314 match mode {
315 "absolute" => Ok("iso"),
316 "relative" => Ok("ago"),
317 "iso" | "ago" | "ls" => Ok(mode),
318 _ => Err(io::Error::new(
319 io::ErrorKind::InvalidInput,
320 "--date must be iso, ago, or ls",
321 )),
322 }
323}
324
325pub(crate) fn format_date(ts: &str, mode: &str, now_secs: i64) -> String {
326 match normalize_date_mode(mode).unwrap_or("iso") {
327 "ago" => format_relative(ts, now_secs).unwrap_or_else(|| ts.to_string()),
328 "ls" => format_ls_date(ts, now_secs).unwrap_or_else(|| ts.to_string()),
329 _ => ts.to_string(),
330 }
331}
332
333fn format_relative(ts: &str, now: i64) -> Option<String> {
334 let then = parse_ts_seconds(ts)?;
335 let delta = now.saturating_sub(then);
336 Some(if delta < 60 {
337 format!("{}s ago", delta)
338 } else if delta < 3600 {
339 format!("{}m ago", delta / 60)
340 } else if delta < 86_400 {
341 format!("{}h ago", delta / 3600)
342 } else {
343 format!("{}d ago", delta / 86_400)
344 })
345}
346
347fn format_ls_date(ts: &str, now_secs: i64) -> Option<String> {
348 let (year, month, day, hour, minute, _) = parse_ts_parts(ts)?;
349 let now_year = store::unix_to_utc(now_secs).year;
350 let mon = [
351 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
352 ];
353 if year == now_year {
354 Some(format!(
355 "{} {:>2} {:02}:{:02}",
356 mon[(month - 1) as usize],
357 day,
358 hour,
359 minute
360 ))
361 } else {
362 Some(format!(
363 "{} {:>2} {}",
364 mon[(month - 1) as usize],
365 day,
366 year
367 ))
368 }
369}
370
371fn parse_ts_seconds(ts: &str) -> Option<i64> {
372 let (year, month, day, hour, minute, second) = parse_ts_parts(ts)?;
373 Some(
374 civil_to_days(year, month, day) * 86_400
375 + hour as i64 * 3600
376 + minute as i64 * 60
377 + second as i64,
378 )
379}
380
381fn parse_ts_parts(ts: &str) -> Option<(i32, u32, u32, u32, u32, u32)> {
382 let date = ts.get(0..10)?;
383 let time = ts.get(11..19)?;
384 Some((
385 date.get(0..4)?.parse().ok()?,
386 date.get(5..7)?.parse().ok()?,
387 date.get(8..10)?.parse().ok()?,
388 time.get(0..2)?.parse().ok()?,
389 time.get(3..5)?.parse().ok()?,
390 time.get(6..8)?.parse().ok()?,
391 ))
392}
393
394fn civil_to_days(year: i32, month: u32, day: u32) -> i64 {
395 let mut y = year as i64;
396 let m = month as i64;
397 let d = day as i64;
398 y -= if m <= 2 { 1 } else { 0 };
399 let era = if y >= 0 { y } else { y - 399 } / 400;
400 let yoe = y - era * 400;
401 let doy = (153 * (m + if m > 2 { -3 } else { 9 }) + 2) / 5 + d - 1;
402 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
403 era * 146097 + doe - 719468
404}
405
406pub(crate) fn print_entries_json(items: &[Meta], date_mode: &str, chars: usize) {
411 #[derive(Serialize)]
412 struct LogJsonEntry {
413 id: String,
414 short_id: String,
415 stack_ref: String,
416 ts: String,
417 date: String,
418 size: i64,
419 size_human: String,
420 #[serde(flatten)]
421 attrs: BTreeMap<String, String>,
422 #[serde(skip_serializing_if = "Vec::is_empty")]
423 preview: Vec<String>,
424 }
425
426 let now_secs = SystemTime::now()
427 .duration_since(UNIX_EPOCH)
428 .unwrap_or_default()
429 .as_secs() as i64;
430 let out: Vec<LogJsonEntry> = items
431 .iter()
432 .enumerate()
433 .map(|(idx, item)| {
434 let preview = preview_snippet(&item.preview, chars);
435 LogJsonEntry {
436 id: item.display_id().to_owned(),
437 short_id: item.short_id().to_owned(),
438 stack_ref: (idx + 1).to_string(),
439 ts: item.ts.clone(),
440 date: format_date(&item.ts, date_mode, now_secs),
441 size: item.size,
442 size_human: store::human_size(item.size),
443 attrs: item.attrs.clone(),
444 preview: if preview.is_empty() {
445 Vec::new()
446 } else {
447 vec![preview]
448 },
449 }
450 })
451 .collect();
452
453 serde_json::to_writer_pretty(io::stdout(), &out).expect("write log json");
454 println!();
455}