Skip to main content

yuki_cli/
output.rs

1use crate::error::YukiError;
2use comfy_table::{Cell, Color, Table, presets::UTF8_FULL_CONDENSED};
3use serde_json::{Map, Value, json};
4
5/// Pagination and field-selection options shared by all list commands.
6#[derive(Default)]
7pub struct ListOptions<'a> {
8    pub limit: Option<usize>,
9    pub offset: Option<usize>,
10    pub fields: Option<&'a str>,
11}
12
13/// Apply offset then limit to a row slice in place.
14pub fn apply_pagination(rows: &mut Vec<Vec<String>>, opts: &ListOptions<'_>) {
15    if let Some(off) = opts.offset {
16        if off >= rows.len() {
17            rows.clear();
18            return;
19        }
20        rows.drain(..off);
21    }
22    if let Some(lim) = opts.limit {
23        rows.truncate(lim);
24    }
25}
26
27/// Restrict `headers` and `rows` to the comma-separated columns named in `opts.fields`.
28///
29/// Column names match case-insensitively and keep the order the caller asked for.
30/// An unrecognised name is an error: emitting the full row set for `--fields bogus`
31/// makes an unsupported query indistinguishable from a satisfied one.
32pub fn select_fields(
33    headers: &mut Vec<String>,
34    rows: &mut [Vec<String>],
35    opts: &ListOptions<'_>,
36) -> Result<(), YukiError> {
37    let Some(spec) = opts.fields else {
38        return Ok(());
39    };
40
41    let mut indices = Vec::new();
42    let mut selected = Vec::new();
43    for requested in spec.split(',').map(str::trim).filter(|s| !s.is_empty()) {
44        let position = headers
45            .iter()
46            .position(|h| h.eq_ignore_ascii_case(requested))
47            .ok_or_else(|| {
48                YukiError::Config(format!(
49                    "unknown field: {requested} (available: {})",
50                    headers.join(", ")
51                ))
52            })?;
53        indices.push(position);
54        selected.push(headers[position].clone());
55    }
56    if indices.is_empty() {
57        return Err(YukiError::Config(
58            "--fields was given no column names".to_string(),
59        ));
60    }
61
62    for row in rows.iter_mut() {
63        *row = indices
64            .iter()
65            .map(|&i| row.get(i).cloned().unwrap_or_default())
66            .collect();
67    }
68    *headers = selected;
69    Ok(())
70}
71
72pub enum OutputFormat {
73    Table,
74    Json,
75}
76
77impl OutputFormat {
78    pub fn from_flag(flag: Option<&str>, is_tty: bool) -> Self {
79        match flag {
80            Some("json") => Self::Json,
81            // "text" and "table" both map to table output
82            Some("text") | Some("table") => Self::Table,
83            // Explicit "auto" or no flag: defer to TTY detection
84            Some("auto") | None => {
85                if is_tty {
86                    Self::Table
87                } else {
88                    Self::Json
89                }
90            }
91            // Any other unrecognized value: treat as auto
92            Some(_) => {
93                if is_tty {
94                    Self::Table
95                } else {
96                    Self::Json
97                }
98            }
99        }
100    }
101}
102
103/// Format rows as a clispec v0.2 items envelope: `{"items": [...], "total": N}`.
104pub fn format_json(headers: &[String], rows: &[Vec<String>]) -> String {
105    let items: Vec<Value> = rows
106        .iter()
107        .map(|row| {
108            let mut map = Map::new();
109            for (i, header) in headers.iter().enumerate() {
110                let val = row.get(i).cloned().unwrap_or_default();
111                map.insert(header.clone(), Value::String(val));
112            }
113            Value::Object(map)
114        })
115        .collect();
116    let total = items.len();
117    serde_json::to_string_pretty(&json!({
118        "items": items,
119        "total": total
120    }))
121    .unwrap_or_else(|_| r#"{"items":[],"total":0}"#.into())
122}
123
124pub fn format_table(headers: &[String], rows: &[Vec<String>]) -> String {
125    let mut table = Table::new();
126    table.load_preset(UTF8_FULL_CONDENSED);
127    let header_cells: Vec<Cell> = headers
128        .iter()
129        .map(|h| {
130            Cell::new(h)
131                .fg(Color::White)
132                .add_attribute(comfy_table::Attribute::Bold)
133        })
134        .collect();
135    table.set_header(header_cells);
136    for row in rows {
137        table.add_row(row);
138    }
139    table.to_string()
140}
141
142/// Format a structured error as the clispec v0.2 envelope.
143///
144/// The spec requires the last line of stderr to be:
145/// `{"error": {"kind": "<kind>", "message": "<message>"}}`
146pub fn format_error_json(message: &str, kind: &str) -> String {
147    serde_json::to_string(&json!({
148        "error": {
149            "kind": kind,
150            "message": message
151        }
152    }))
153    .unwrap_or_else(|_| format!(r#"{{"error":{{"kind":"{kind}","message":"{message}"}}}}"#))
154}
155
156pub fn is_tty() -> bool {
157    std::io::IsTerminal::is_terminal(&std::io::stdout())
158}