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, VectorQuantization};
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 !matches!(col.quantization, VectorQuantization::F32) {
48            ty.push_str(&format!(
49                " WITH (quantization = '{}')",
50                col.quantization.as_str()
51            ));
52        }
53        if !col.nullable && !col.primary_key {
54            ty.push_str(" NOT NULL");
55        }
56        if col.primary_key {
57            ty.push_str(" PRIMARY KEY");
58        }
59        if col.immutable {
60            ty.push_str(" IMMUTABLE");
61        }
62        if let Some(policy) = &col.rank_policy {
63            ty.push_str(&format!(
64                " RANK_POLICY (JOIN {} ON {}, FORMULA '{}', SORT_KEY {})",
65                policy.joined_table,
66                policy.joined_column,
67                policy.formula.replace('\'', "''"),
68                policy.sort_key
69            ));
70        }
71        write!(&mut buf, "  {} {}", col.name, ty).unwrap();
72    }
73    buf.push_str("\n)");
74    if meta.immutable {
75        buf.push_str(" IMMUTABLE");
76    }
77    if let Some(sm) = &meta.state_machine {
78        let mut entries: Vec<_> = sm.transitions.iter().collect();
79        entries.sort_by(|a, b| a.0.cmp(b.0));
80        let transitions: Vec<String> = entries
81            .into_iter()
82            .map(|(from, tos)| format!("{from} -> [{}]", tos.join(", ")))
83            .collect();
84        write!(
85            &mut buf,
86            " STATE MACHINE ({}: {})",
87            sm.column,
88            transitions.join(", ")
89        )
90        .unwrap();
91    }
92    if !meta.dag_edge_types.is_empty() {
93        let edge_types = meta
94            .dag_edge_types
95            .iter()
96            .map(|edge_type| format!("'{edge_type}'"))
97            .collect::<Vec<_>>()
98            .join(", ");
99        write!(&mut buf, " DAG({edge_types})").unwrap();
100    }
101    buf.push_str(";\n");
102    for decl in &meta.indexes {
103        if !verbose && decl.kind == contextdb_core::IndexKind::Auto {
104            continue;
105        }
106        let cols: Vec<String> = decl
107            .columns
108            .iter()
109            .map(|(c, dir)| {
110                let dir_str = match dir {
111                    contextdb_core::SortDirection::Asc => "ASC",
112                    contextdb_core::SortDirection::Desc => "DESC",
113                };
114                format!("{c} {dir_str}")
115            })
116            .collect();
117        writeln!(
118            &mut buf,
119            "CREATE INDEX {} ON {} ({});",
120            decl.name,
121            table,
122            cols.join(", ")
123        )
124        .unwrap();
125    }
126    buf
127}
128
129/// Render the `.explain <sql>` REPL output. Runs the SQL to populate the
130/// trace, then formats the physical plan + index-usage summary.
131pub fn render_explain(
132    db: &Database,
133    sql: &str,
134    params: &std::collections::HashMap<String, Value>,
135) -> contextdb_core::Result<String> {
136    let result = db.execute(sql, params)?;
137    let mut out = String::new();
138    out.push_str(result.trace.physical_plan);
139    if let Some(idx) = &result.trace.index_used {
140        out.push_str(&format!(" {{ index: {idx} }}"));
141    }
142    out.push('\n');
143    if !result.trace.predicates_pushed.is_empty() {
144        out.push_str("  predicates_pushed: [");
145        for (i, p) in result.trace.predicates_pushed.iter().enumerate() {
146            if i > 0 {
147                out.push_str(", ");
148            }
149            out.push_str(p.as_ref());
150        }
151        out.push_str("]\n");
152    }
153    if !result.trace.indexes_considered.is_empty() {
154        out.push_str("  indexes_considered: [");
155        for (i, c) in result.trace.indexes_considered.iter().enumerate() {
156            if i > 0 {
157                out.push_str(", ");
158            }
159            out.push_str(&format!("{}: {}", c.name, c.rejected_reason));
160        }
161        out.push_str("]\n");
162    }
163    if result.trace.sort_elided {
164        out.push_str("  sort_elided: true\n");
165    }
166    Ok(out)
167}
168
169/// Render a single `Value` as the CLI displays it in SELECT output.
170pub fn value_to_string(v: &Value) -> String {
171    match v {
172        Value::Null => "NULL".to_string(),
173        Value::Bool(b) => b.to_string(),
174        Value::Int64(n) => n.to_string(),
175        Value::Float64(f) => f.to_string(),
176        Value::Text(s) => s.clone(),
177        Value::Uuid(u) => u.to_string(),
178        Value::Timestamp(ts) => ts.to_string(),
179        Value::Json(j) => j.to_string(),
180        Value::Vector(vs) => format!("{vs:?}"),
181        Value::TxId(tx) => tx.0.to_string(),
182    }
183}
184
185/// Render the `.sync status` output buffer. Includes the live committed-TxId.
186pub fn render_sync_status(db: &Database) -> String {
187    format!("Committed TxId: {}\n", db.committed_watermark().0)
188}