Skip to main content

contextdb_engine/
cli_render.rs

1//! Public rendering helpers used by both the CLI binary and the test suite.
2
3use crate::Database;
4use contextdb_core::Value;
5use contextdb_core::table_meta::{ColumnType, TableMeta};
6use std::fmt::Write;
7
8/// Render a column type as a DDL token.
9pub fn render_column_type(col_type: &ColumnType) -> String {
10    match col_type {
11        ColumnType::Integer => "INTEGER".to_string(),
12        ColumnType::Real => "REAL".to_string(),
13        ColumnType::Text => "TEXT".to_string(),
14        ColumnType::Boolean => "BOOLEAN".to_string(),
15        ColumnType::Json => "JSON".to_string(),
16        ColumnType::Uuid => "UUID".to_string(),
17        ColumnType::Vector(dim) => format!("VECTOR({dim})"),
18        ColumnType::Timestamp => "TIMESTAMP".to_string(),
19        ColumnType::TxId => "TXID".to_string(),
20    }
21}
22
23/// Render a table's `.schema` DDL. Auto-indexes (`kind == IndexKind::Auto`)
24/// are suppressed from output to keep `.schema` focused on user-authored
25/// DDL. Pass `render_table_meta_verbose` to include them.
26pub fn render_table_meta(table: &str, meta: &TableMeta) -> String {
27    render_table_meta_inner(table, meta, false)
28}
29
30/// Render a table's `.schema` DDL INCLUDING auto-indexes. Used by
31/// `.schema --verbose` / `EXPLAIN SCHEMA t` for agents that need to see the
32/// full picture.
33pub fn render_table_meta_verbose(table: &str, meta: &TableMeta) -> String {
34    render_table_meta_inner(table, meta, true)
35}
36
37fn render_table_meta_inner(table: &str, meta: &TableMeta, verbose: bool) -> String {
38    let mut buf = String::new();
39    writeln!(&mut buf, "CREATE TABLE {table} (").unwrap();
40    let mut first = true;
41    for col in &meta.columns {
42        if !first {
43            buf.push_str(",\n");
44        }
45        first = false;
46        let mut ty = render_column_type(&col.column_type);
47        if !col.nullable && !col.primary_key {
48            ty.push_str(" NOT NULL");
49        }
50        if col.primary_key {
51            ty.push_str(" PRIMARY KEY");
52        }
53        if col.immutable {
54            ty.push_str(" IMMUTABLE");
55        }
56        write!(&mut buf, "  {} {}", col.name, ty).unwrap();
57    }
58    buf.push_str("\n)");
59    if meta.immutable {
60        buf.push_str(" IMMUTABLE");
61    }
62    if let Some(sm) = &meta.state_machine {
63        let mut entries: Vec<_> = sm.transitions.iter().collect();
64        entries.sort_by(|a, b| a.0.cmp(b.0));
65        let transitions: Vec<String> = entries
66            .into_iter()
67            .map(|(from, tos)| format!("{from} -> [{}]", tos.join(", ")))
68            .collect();
69        write!(
70            &mut buf,
71            " STATE MACHINE ({}: {})",
72            sm.column,
73            transitions.join(", ")
74        )
75        .unwrap();
76    }
77    if !meta.dag_edge_types.is_empty() {
78        let edge_types = meta
79            .dag_edge_types
80            .iter()
81            .map(|edge_type| format!("'{edge_type}'"))
82            .collect::<Vec<_>>()
83            .join(", ");
84        write!(&mut buf, " DAG({edge_types})").unwrap();
85    }
86    buf.push_str(";\n");
87    for decl in &meta.indexes {
88        if !verbose && decl.kind == contextdb_core::IndexKind::Auto {
89            continue;
90        }
91        let cols: Vec<String> = decl
92            .columns
93            .iter()
94            .map(|(c, dir)| {
95                let dir_str = match dir {
96                    contextdb_core::SortDirection::Asc => "ASC",
97                    contextdb_core::SortDirection::Desc => "DESC",
98                };
99                format!("{c} {dir_str}")
100            })
101            .collect();
102        writeln!(
103            &mut buf,
104            "CREATE INDEX {} ON {} ({});",
105            decl.name,
106            table,
107            cols.join(", ")
108        )
109        .unwrap();
110    }
111    buf
112}
113
114/// Render the `.explain <sql>` REPL output. Runs the SQL to populate the
115/// trace, then formats the physical plan + index-usage summary.
116pub fn render_explain(
117    db: &Database,
118    sql: &str,
119    params: &std::collections::HashMap<String, Value>,
120) -> contextdb_core::Result<String> {
121    let result = db.execute(sql, params)?;
122    let mut out = String::new();
123    out.push_str(result.trace.physical_plan);
124    if let Some(idx) = &result.trace.index_used {
125        out.push_str(&format!(" {{ index: {idx} }}"));
126    }
127    out.push('\n');
128    if !result.trace.predicates_pushed.is_empty() {
129        out.push_str("  predicates_pushed: [");
130        for (i, p) in result.trace.predicates_pushed.iter().enumerate() {
131            if i > 0 {
132                out.push_str(", ");
133            }
134            out.push_str(p.as_ref());
135        }
136        out.push_str("]\n");
137    }
138    if !result.trace.indexes_considered.is_empty() {
139        out.push_str("  indexes_considered: [");
140        for (i, c) in result.trace.indexes_considered.iter().enumerate() {
141            if i > 0 {
142                out.push_str(", ");
143            }
144            out.push_str(&format!("{}: {}", c.name, c.rejected_reason));
145        }
146        out.push_str("]\n");
147    }
148    if result.trace.sort_elided {
149        out.push_str("  sort_elided: true\n");
150    }
151    Ok(out)
152}
153
154/// Render a single `Value` as the CLI displays it in SELECT output.
155pub fn value_to_string(v: &Value) -> String {
156    match v {
157        Value::Null => "NULL".to_string(),
158        Value::Bool(b) => b.to_string(),
159        Value::Int64(n) => n.to_string(),
160        Value::Float64(f) => f.to_string(),
161        Value::Text(s) => s.clone(),
162        Value::Uuid(u) => u.to_string(),
163        Value::Timestamp(ts) => ts.to_string(),
164        Value::Json(j) => j.to_string(),
165        Value::Vector(vs) => format!("{vs:?}"),
166        Value::TxId(tx) => tx.0.to_string(),
167    }
168}
169
170/// Render the `.sync status` output buffer. Includes the live committed-TxId.
171pub fn render_sync_status(db: &Database) -> String {
172    format!("Committed TxId: {}\n", db.committed_watermark().0)
173}