omnigraph-cli 0.3.1

CLI for the Omnigraph graph database.
use color_eyre::eyre::Result;
use omnigraph_server::ReadOutputFormat;
use omnigraph_server::api::ReadOutput;
use omnigraph_server::config::TableCellLayout;
use serde_json::{Map, Value};

pub struct ReadRenderOptions {
    pub max_column_width: usize,
    pub cell_layout: TableCellLayout,
}

pub fn render_read(
    output: &ReadOutput,
    format: ReadOutputFormat,
    options: &ReadRenderOptions,
) -> Result<String> {
    match format {
        ReadOutputFormat::Json => Ok(serde_json::to_string_pretty(output)?),
        ReadOutputFormat::Jsonl => render_jsonl(output),
        ReadOutputFormat::Csv => render_csv(output),
        ReadOutputFormat::Kv => Ok(render_kv(output)),
        ReadOutputFormat::Table => Ok(render_table(output, options)),
    }
}

fn render_jsonl(output: &ReadOutput) -> Result<String> {
    let mut lines = Vec::new();
    lines.push(serde_json::to_string(&serde_json::json!({
        "kind": "metadata",
        "query_name": output.query_name,
        "target": output.target,
        "row_count": output.row_count,
    }))?);
    for row in rows(output) {
        lines.push(serde_json::to_string(&row)?);
    }
    Ok(lines.join("\n"))
}

fn render_csv(output: &ReadOutput) -> Result<String> {
    let rows = rows(output);
    let columns = columns(output, &rows);
    let mut lines = Vec::new();
    lines.push(
        columns
            .iter()
            .map(|column| csv_escape(column))
            .collect::<Vec<_>>()
            .join(","),
    );
    for row in rows {
        lines.push(
            columns
                .iter()
                .map(|column| csv_escape(&stringify_value(row.get(column).unwrap_or(&Value::Null))))
                .collect::<Vec<_>>()
                .join(","),
        );
    }
    Ok(lines.join("\n"))
}

fn render_kv(output: &ReadOutput) -> String {
    let mut lines = vec![header_line(output)];
    let rows = rows(output);
    if rows.is_empty() {
        lines.push("(no rows)".to_string());
        return lines.join("\n");
    }

    for (idx, row) in rows.iter().enumerate() {
        if idx > 0 {
            lines.push(String::new());
        }
        lines.push(format!("row {}", idx + 1));
        for column in columns(output, &rows) {
            lines.push(format!(
                "{}: {}",
                column,
                stringify_value(row.get(&column).unwrap_or(&Value::Null))
            ));
        }
    }
    lines.join("\n")
}

fn render_table(output: &ReadOutput, options: &ReadRenderOptions) -> String {
    let mut lines = vec![header_line(output)];
    let rows = rows(output);
    let columns = columns(output, &rows);

    if columns.is_empty() {
        lines.push("(no rows)".to_string());
        return lines.join("\n");
    }

    let widths = columns
        .iter()
        .map(|column| {
            let mut width = column.chars().count();
            for row in &rows {
                let rendered =
                    normalize_cell(&stringify_value(row.get(column).unwrap_or(&Value::Null)));
                let longest = rendered
                    .lines()
                    .map(|line| line.chars().count())
                    .max()
                    .unwrap_or(0);
                width = width.max(longest.min(options.max_column_width));
            }
            width.min(options.max_column_width.max(8))
        })
        .collect::<Vec<_>>();

    lines.push(render_table_line(&columns, &widths));
    lines.push(
        widths
            .iter()
            .map(|width| "-".repeat(*width))
            .collect::<Vec<_>>()
            .join("-+-"),
    );

    for row in rows {
        let cell_lines = columns
            .iter()
            .zip(widths.iter())
            .map(|(column, width)| {
                split_cell(
                    &normalize_cell(&stringify_value(row.get(column).unwrap_or(&Value::Null))),
                    *width,
                    options.cell_layout,
                )
            })
            .collect::<Vec<_>>();
        let line_count = cell_lines.iter().map(Vec::len).max().unwrap_or(1);
        for line_idx in 0..line_count {
            let rendered = cell_lines
                .iter()
                .zip(widths.iter())
                .map(|(segments, width)| {
                    let segment = segments.get(line_idx).cloned().unwrap_or_default();
                    pad_to_width(&segment, *width)
                })
                .collect::<Vec<_>>();
            lines.push(rendered.join(" | "));
        }
    }

    lines.join("\n")
}

fn render_table_line(columns: &[String], widths: &[usize]) -> String {
    columns
        .iter()
        .zip(widths.iter())
        .map(|(column, width)| pad_to_width(column, *width))
        .collect::<Vec<_>>()
        .join(" | ")
}

fn header_line(output: &ReadOutput) -> String {
    format!(
        "{} rows from {} via {}",
        output.row_count,
        output
            .target
            .snapshot
            .as_deref()
            .map(|id| format!("snapshot {}", id))
            .or_else(|| {
                output
                    .target
                    .branch
                    .as_deref()
                    .map(|branch| format!("branch {}", branch))
            })
            .unwrap_or_else(|| "target".to_string()),
        output.query_name
    )
}

fn rows(output: &ReadOutput) -> Vec<Map<String, Value>> {
    output
        .rows
        .as_array()
        .into_iter()
        .flatten()
        .map(|row| match row {
            Value::Object(map) => map.clone(),
            other => {
                let mut map = Map::new();
                map.insert("value".to_string(), other.clone());
                map
            }
        })
        .collect()
}

fn columns(output: &ReadOutput, rows: &[Map<String, Value>]) -> Vec<String> {
    if !output.columns.is_empty() {
        return output.columns.clone();
    }

    let mut columns = rows
        .iter()
        .flat_map(|row| row.keys().cloned())
        .collect::<Vec<_>>();
    columns.sort();
    columns.dedup();
    columns
}

fn stringify_value(value: &Value) -> String {
    match value {
        Value::Null => "null".to_string(),
        Value::String(text) => text.clone(),
        Value::Bool(boolean) => boolean.to_string(),
        Value::Number(number) => number.to_string(),
        other => serde_json::to_string(other).unwrap_or_else(|_| "<invalid json>".to_string()),
    }
}

fn normalize_cell(value: &str) -> String {
    value.replace('\n', "\\n")
}

fn split_cell(value: &str, width: usize, layout: TableCellLayout) -> Vec<String> {
    if value.is_empty() {
        return vec![String::new()];
    }
    if value.chars().count() <= width {
        return vec![value.to_string()];
    }
    match layout {
        TableCellLayout::Truncate => vec![truncate(value, width)],
        TableCellLayout::Wrap => wrap(value, width),
    }
}

fn truncate(value: &str, width: usize) -> String {
    if width <= 1 {
        return value.chars().take(width).collect();
    }
    let keep = width.saturating_sub(1);
    let mut out = value.chars().take(keep).collect::<String>();
    out.push('…');
    out
}

fn wrap(value: &str, width: usize) -> Vec<String> {
    let chars = value.chars().collect::<Vec<_>>();
    chars
        .chunks(width.max(1))
        .map(|chunk| chunk.iter().collect::<String>())
        .collect()
}

fn pad_to_width(value: &str, width: usize) -> String {
    let value_width = value.chars().count();
    if value_width >= width {
        value.to_string()
    } else {
        format!("{}{}", value, " ".repeat(width - value_width))
    }
}

fn csv_escape(value: &str) -> String {
    if value.contains(',') || value.contains('"') || value.contains('\n') || value.contains('\r') {
        format!("\"{}\"", value.replace('"', "\"\""))
    } else {
        value.to_string()
    }
}

#[cfg(test)]
mod tests {
    use omnigraph_server::api::{ReadOutput, ReadTargetOutput};

    use super::*;

    fn sample_output() -> ReadOutput {
        ReadOutput {
            query_name: "get_person".to_string(),
            target: ReadTargetOutput {
                branch: Some("main".to_string()),
                snapshot: None,
            },
            row_count: 1,
            columns: vec!["name".to_string(), "age".to_string()],
            rows: serde_json::json!([{ "name": "Alice", "age": 30 }]),
        }
    }

    #[test]
    fn csv_format_outputs_header_and_rows() {
        let rendered = render_read(
            &sample_output(),
            ReadOutputFormat::Csv,
            &ReadRenderOptions {
                max_column_width: 80,
                cell_layout: TableCellLayout::Truncate,
            },
        )
        .unwrap();

        assert!(rendered.lines().next().unwrap().contains("name,age"));
        assert!(rendered.contains("Alice,30"));
    }

    #[test]
    fn jsonl_format_emits_metadata_first() {
        let rendered = render_read(
            &sample_output(),
            ReadOutputFormat::Jsonl,
            &ReadRenderOptions {
                max_column_width: 80,
                cell_layout: TableCellLayout::Truncate,
            },
        )
        .unwrap();

        let first = rendered.lines().next().unwrap();
        assert!(first.contains("\"kind\":\"metadata\""));
        assert!(
            rendered
                .lines()
                .nth(1)
                .unwrap()
                .contains("\"name\":\"Alice\"")
        );
    }

    #[test]
    fn render_falls_back_to_discovered_columns_for_legacy_payloads() {
        let mut output = sample_output();
        output.columns.clear();

        let rendered = render_read(
            &output,
            ReadOutputFormat::Csv,
            &ReadRenderOptions {
                max_column_width: 80,
                cell_layout: TableCellLayout::Truncate,
            },
        )
        .unwrap();

        assert!(rendered.lines().next().unwrap().contains("age,name"));
    }

    #[test]
    fn csv_quotes_carriage_returns() {
        assert_eq!(csv_escape("hello\rworld"), "\"hello\rworld\"");
    }
}