use std::io::IsTerminal;
use futures::executor::block_on;
use gluesql::prelude::{Glue, MemoryStorage, Payload, Value as GlueValue};
use serde_json::{json, Map, Value};
use crate::body;
use crate::doc::Doc;
use crate::error::{usage, Result};
use crate::project_config::{ProjectConfig, SectionKind, StatSpec};
use crate::Ctx;
fn split_tag(t: &str) -> (&str, Option<&str>) {
match t.find([':', '=']) {
Some(i) => (&t[..i], Some(&t[i + 1..])),
None => (t, None),
}
}
fn count_array_items(data: &Value) -> usize {
match data {
Value::Object(map) => map
.values()
.filter_map(|v| v.as_array())
.map(|arr| arr.len())
.sum(),
Value::Array(arr) => arr.len(),
_ => 0,
}
}
pub(crate) fn yaml_to_json(v: &serde_norway::Value) -> Value {
serde_json::to_value(v).unwrap_or(Value::Null)
}
pub(crate) fn section_json(d: &Doc, kind: SectionKind, heading: &str) -> Option<Value> {
match kind {
SectionKind::Checklist => {
let items = body::checklist_items(&d.body, heading);
if items.is_empty() {
return None;
}
let unchecked = items.iter().filter(|i| !i.checked).count();
Some(json!({ "kind": "checklist", "items": items.len(), "unchecked": unchecked }))
}
SectionKind::Log => {
let content = body::section(&d.body, heading);
let count = content.lines().filter(|l| l.starts_with("- ")).count();
if count == 0 {
return None;
}
Some(json!({ "kind": "log", "items": count }))
}
SectionKind::Structured | SectionKind::Prose => None,
}
}
pub(crate) fn structured_section_json(
d: &Doc,
structure: Option<&str>,
heading: &str,
) -> Option<Value> {
if !body::has_section(&d.body, heading) {
return None;
}
let src = structure?;
let schema = crate::mdprism::parse_schema(src).ok()?;
let content = body::section(&d.body, heading);
let data = schema.extract(&content).ok()?;
let count = count_array_items(&data);
if count == 0 {
return None;
}
Some(json!({ "kind": "structured", "items": count }))
}
fn doc_json(pcfg: &ProjectConfig, d: &Doc) -> Value {
let id = d.id().unwrap_or("");
let tname = d.id().and_then(|i| pcfg.type_name_for_id(i));
let tags = d.frontmatter.tags().unwrap_or_default();
let mut fields = Map::new();
let mut sections = Map::new();
if let Some(tn) = tname {
let t = &pcfg.types[tn];
for fname in t.fields.keys() {
if let Some(v) = d.frontmatter.get(fname) {
fields.insert(fname.clone(), yaml_to_json(v));
}
}
for sec in &t.sections {
let s = if sec.kind == SectionKind::Structured {
structured_section_json(d, sec.structure.as_deref(), &sec.heading)
} else {
section_json(d, sec.kind, &sec.heading)
};
if let Some(s) = s {
sections.insert(sec.heading.clone(), s);
}
}
}
json!({
"id": id,
"num": if id.is_empty() { 0 } else { crate::refs::id_number(id) },
"type": tname,
"status": d.status(),
"title": d.title,
"created": d.frontmatter.get_str("created"),
"updated": d.frontmatter.get_str("updated"),
"tags": tags,
"fields": Value::Object(fields),
"sections": Value::Object(sections),
})
}
pub fn corpus_json(pcfg: &ProjectConfig, docs: &[&Doc]) -> Value {
Value::Array(docs.iter().map(|d| doc_json(pcfg, d)).collect())
}
fn sql_lit(s: &str) -> String {
format!("'{}'", s.replace('\'', "''"))
}
fn opt_lit(v: Option<&str>) -> String {
v.map(sql_lit).unwrap_or_else(|| "NULL".to_string())
}
pub(crate) fn json_scalar(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
Value::Null => String::new(),
Value::Bool(b) => b.to_string(),
Value::Number(n) => n.to_string(),
other => other.to_string(),
}
}
fn materialize(corpus: &Value) -> String {
let mut docs_rows: Vec<String> = Vec::new();
let mut tag_rows: Vec<String> = Vec::new();
let mut section_rows: Vec<String> = Vec::new();
let mut field_rows: Vec<String> = Vec::new();
for d in corpus.as_array().into_iter().flatten() {
let id = d["id"].as_str().unwrap_or("");
let num = d["num"].as_i64().unwrap_or(0);
docs_rows.push(format!(
"({}, {}, {}, {}, {}, {}, {})",
sql_lit(id),
num,
opt_lit(d["type"].as_str()),
opt_lit(d["status"].as_str()),
sql_lit(d["title"].as_str().unwrap_or("")),
opt_lit(d["created"].as_str()),
opt_lit(d["updated"].as_str()),
));
for t in d["tags"].as_array().into_iter().flatten() {
if let Some(tag) = t.as_str() {
let (key, value) = split_tag(tag);
tag_rows.push(format!(
"({}, {}, {}, {})",
sql_lit(id),
sql_lit(tag),
sql_lit(key),
opt_lit(value),
));
}
}
if let Some(obj) = d["sections"].as_object() {
for (heading, s) in obj {
section_rows.push(format!(
"({}, {}, {}, {}, {})",
sql_lit(id),
sql_lit(heading),
opt_lit(s["kind"].as_str()),
s["items"].as_i64().unwrap_or(0),
s["unchecked"].as_i64().unwrap_or(0),
));
}
}
if let Some(obj) = d["fields"].as_object() {
for (key, v) in obj {
match v {
Value::Array(items) => {
for it in items {
field_rows.push(format!(
"({}, {}, {})",
sql_lit(id),
sql_lit(key),
sql_lit(&json_scalar(it)),
));
}
}
_ => field_rows.push(format!(
"({}, {}, {})",
sql_lit(id),
sql_lit(key),
sql_lit(&json_scalar(v)),
)),
}
}
}
}
let mut sql = String::new();
sql.push_str(
"CREATE TABLE docs (id TEXT, num INTEGER, type TEXT, status TEXT, title TEXT, created TEXT, updated TEXT);\n",
);
sql.push_str("CREATE TABLE tags (doc_id TEXT, tag TEXT, key TEXT, value TEXT);\n");
sql.push_str(
"CREATE TABLE sections (doc_id TEXT, heading TEXT, kind TEXT, items INTEGER, unchecked INTEGER);\n",
);
sql.push_str("CREATE TABLE fields (doc_id TEXT, key TEXT, value TEXT);\n");
let mut insert = |table: &str, rows: &[String]| {
if !rows.is_empty() {
sql.push_str(&format!(
"INSERT INTO {table} VALUES {};\n",
rows.join(", ")
));
}
};
insert("docs", &docs_rows);
insert("tags", &tag_rows);
insert("sections", §ion_rows);
insert("fields", &field_rows);
sql
}
pub(crate) fn cell(v: &GlueValue) -> String {
match v {
GlueValue::Str(s) => s.clone(),
GlueValue::Bool(b) => b.to_string(),
GlueValue::I8(n) => n.to_string(),
GlueValue::I16(n) => n.to_string(),
GlueValue::I32(n) => n.to_string(),
GlueValue::I64(n) => n.to_string(),
GlueValue::I128(n) => n.to_string(),
GlueValue::U8(n) => n.to_string(),
GlueValue::U16(n) => n.to_string(),
GlueValue::U32(n) => n.to_string(),
GlueValue::U64(n) => n.to_string(),
GlueValue::U128(n) => n.to_string(),
GlueValue::F32(x) => fmt_float(*x as f64),
GlueValue::F64(x) => fmt_float(*x),
GlueValue::Null => String::new(),
other => format!("{other:?}"),
}
}
fn fmt_float(x: f64) -> String {
if x.is_finite() && x.fract() == 0.0 {
format!("{}", x as i64)
} else {
format!("{x}")
}
}
fn esc(s: &str) -> String {
s.replace('|', "\\|").replace('\n', " ")
}
pub type CorpusDb = Glue<MemoryStorage>;
pub fn build_db(corpus: &Value) -> std::result::Result<CorpusDb, String> {
let setup = materialize(corpus);
let mut glue = Glue::new(MemoryStorage::default());
block_on(glue.execute(&setup)).map_err(|e| format!("corpus build failed ({e})"))?;
Ok(glue)
}
fn run_sql(
db: &mut CorpusDb,
sql: &str,
) -> std::result::Result<(Vec<String>, Vec<Vec<String>>), String> {
let payloads = block_on(db.execute(sql)).map_err(|e| format!("query failed ({e})"))?;
let last = payloads
.last()
.ok_or_else(|| "query produced no result set".to_string())?;
match last {
Payload::Select { labels, rows } => {
let rows = rows.iter().map(|r| r.iter().map(cell).collect()).collect();
Ok((labels.clone(), rows))
}
other => Err(format!(
"query must end in a SELECT (got {})",
payload_kind(other)
)),
}
}
fn payload_kind(p: &Payload) -> &'static str {
match p {
Payload::Select { .. } | Payload::SelectMap(_) => "a projection",
Payload::Insert(_) => "INSERT",
Payload::Update(_) => "UPDATE",
Payload::Delete(_) => "DELETE",
_ => "a non-select statement",
}
}
pub(crate) fn render_table(name: &str, labels: &[String], rows: &[Vec<String>]) -> String {
format!("## {name}\n\n{}", table_body(labels, rows))
}
pub(crate) fn table_body(labels: &[String], rows: &[Vec<String>]) -> String {
let mut out = String::new();
if labels.is_empty() || rows.is_empty() {
out.push_str("_(no rows)_\n");
return out;
}
let header: Vec<String> = labels.iter().map(|l| esc(l)).collect();
out.push_str(&format!("| {} |\n", header.join(" | ")));
out.push_str(&format!("| {} |\n", vec!["---"; labels.len()].join(" | ")));
for r in rows {
let cells: Vec<String> = (0..labels.len())
.map(|i| esc(r.get(i).map(String::as_str).unwrap_or("")))
.collect();
out.push_str(&format!("| {} |\n", cells.join(" | ")));
}
out
}
pub(crate) fn print_markdown(out: &str, plain: bool) {
let styled =
!plain && std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none();
if styled {
termimad::MadSkin::default().print_text(out);
} else {
println!("{out}");
}
}
fn expand_template(
tmpl: &str,
resolve: &dyn Fn(&str) -> Option<String>,
) -> std::result::Result<String, String> {
let mut out = String::new();
let mut chars = tmpl.chars().peekable();
while let Some(c) = chars.next() {
match c {
'{' if chars.peek() == Some(&'{') => {
chars.next();
out.push('{');
}
'}' if chars.peek() == Some(&'}') => {
chars.next();
out.push('}');
}
'{' => {
let mut name = String::new();
loop {
match chars.next() {
Some('}') => break,
Some(ch) => name.push(ch),
None => return Err("unclosed '{' in template".to_string()),
}
}
let key = name.trim();
match resolve(key) {
Some(v) => out.push_str(&v),
None => return Err(format!("template references unknown column '{{{key}}}'")),
}
}
'}' => return Err("unmatched '}' in template (use '}}' for a literal)".to_string()),
_ => out.push(c),
}
}
Ok(out)
}
fn render_templated(
name: &str,
labels: &[String],
rows: &[Vec<String>],
tmpl: &str,
) -> std::result::Result<String, String> {
let known = |k: &str| labels.iter().any(|l| l == k);
expand_template(tmpl, &|k| known(k).then(String::new))?;
let mut out = format!("## {name}\n\n");
if rows.is_empty() {
out.push_str("_(no rows)_\n");
return Ok(out);
}
for row in rows {
let cell_for = |k: &str| {
labels
.iter()
.position(|l| l == k)
.map(|i| row.get(i).cloned().unwrap_or_default())
};
out.push_str(&expand_template(tmpl, &cell_for)?);
out.push('\n');
}
Ok(out)
}
pub fn render_stat_on(db: &mut CorpusDb, spec: &StatSpec) -> std::result::Result<String, String> {
let (labels, rows) =
run_sql(db, &spec.sql).map_err(|e| format!("stats '{}': {e}", spec.name))?;
match &spec.template {
Some(tmpl) => render_templated(&spec.name, &labels, &rows, tmpl)
.map_err(|e| format!("stats '{}': {e}", spec.name)),
None => Ok(render_table(&spec.name, &labels, &rows)),
}
}
pub fn render_stat(spec: &StatSpec, corpus: &Value) -> std::result::Result<String, String> {
let mut db = build_db(corpus)?;
render_stat_on(&mut db, spec)
}
pub fn render_all(pcfg: &ProjectConfig, docs: &[&Doc]) -> std::result::Result<String, String> {
let corpus = corpus_json(pcfg, docs);
let mut db = build_db(&corpus)?;
let mut sections = Vec::new();
for spec in &pcfg.stats {
sections.push(render_stat_on(&mut db, spec)?.trim_end().to_string());
}
Ok(sections.join("\n\n"))
}
pub fn run(ctx: &Ctx, plain: bool) -> Result<()> {
let prj = ctx.open()?;
let (docs, _) = ctx.backend.load_docs(&prj);
let doc_refs: Vec<&Doc> = docs.iter().collect();
let out = render_all(&prj.pcfg, &doc_refs).map_err(usage)?;
if out.is_empty() {
return Ok(());
}
print_markdown(&out, plain);
Ok(())
}