Skip to main content

stash_cli/
display.rs

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// ---------------------------------------------------------------------------
11// Decorated entries (display-ready representations)
12// ---------------------------------------------------------------------------
13
14#[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
96// ---------------------------------------------------------------------------
97// ID / escape helpers
98// ---------------------------------------------------------------------------
99
100pub(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
108// Escapes a value for human-readable display output.
109// '=' is intentionally NOT escaped here (unlike escape_attr in store.rs)
110// because it has no special meaning outside the storage format.
111pub(crate) fn escape_attr_output(input: &str) -> Cow<'_, str> {
112    // Fast path: most attribute values have no special chars — borrow directly.
113    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
186// ---------------------------------------------------------------------------
187// Terminal / color / padding
188// ---------------------------------------------------------------------------
189
190pub(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        // SAFETY: ws points to writable memory for ioctl to populate.
264        let rc = unsafe { ioctl(fd, TIOCGWINSZ, ws.as_mut_ptr()) };
265        if rc == 0 {
266            // SAFETY: ioctl succeeded and initialized ws.
267            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
309// ---------------------------------------------------------------------------
310// Date formatting
311// ---------------------------------------------------------------------------
312
313pub(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
406// ---------------------------------------------------------------------------
407// Structured listing output
408// ---------------------------------------------------------------------------
409
410pub(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}