1use std::io::IsTerminal;
8
9use futures::executor::block_on;
10use gluesql::prelude::{Glue, MemoryStorage, Payload, Value as GlueValue};
11use serde_json::{json, Map, Value};
12
13use crate::body;
14use crate::doc::Doc;
15use crate::error::{usage, Result};
16use crate::project_config::{ProjectConfig, SectionKind, StatSpec};
17use crate::Ctx;
18
19fn split_tag(t: &str) -> (&str, Option<&str>) {
23 match t.find([':', '=']) {
24 Some(i) => (&t[..i], Some(&t[i + 1..])),
25 None => (t, None),
26 }
27}
28
29fn count_array_items(data: &Value) -> usize {
32 match data {
33 Value::Object(map) => map
34 .values()
35 .filter_map(|v| v.as_array())
36 .map(|arr| arr.len())
37 .sum(),
38 Value::Array(arr) => arr.len(),
39 _ => 0,
40 }
41}
42
43pub(crate) fn yaml_to_json(v: &serde_norway::Value) -> Value {
46 serde_json::to_value(v).unwrap_or(Value::Null)
47}
48
49pub(crate) fn section_json(d: &Doc, kind: SectionKind, heading: &str) -> Option<Value> {
53 match kind {
54 SectionKind::Checklist => {
55 let items = body::checklist_items(&d.body, heading);
56 if items.is_empty() {
57 return None;
58 }
59 let unchecked = items.iter().filter(|i| !i.checked).count();
60 Some(json!({ "kind": "checklist", "items": items.len(), "unchecked": unchecked }))
61 }
62 SectionKind::Log => {
63 let content = body::section(&d.body, heading);
64 let count = content.lines().filter(|l| l.starts_with("- ")).count();
65 if count == 0 {
66 return None;
67 }
68 Some(json!({ "kind": "log", "items": count }))
69 }
70 SectionKind::Structured | SectionKind::Prose => None,
71 }
72}
73
74pub(crate) fn structured_section_json(
77 d: &Doc,
78 structure: Option<&str>,
79 heading: &str,
80) -> Option<Value> {
81 if !body::has_section(&d.body, heading) {
82 return None;
83 }
84 let src = structure?;
85 let schema = crate::mdprism::parse_schema(src).ok()?;
86 let content = body::section(&d.body, heading);
87 let data = schema.extract(&content).ok()?;
88 let count = count_array_items(&data);
89 if count == 0 {
90 return None;
91 }
92 Some(json!({ "kind": "structured", "items": count }))
93}
94
95fn doc_json(pcfg: &ProjectConfig, d: &Doc) -> Value {
97 let id = d.id().unwrap_or("");
98 let tname = d.id().and_then(|i| pcfg.type_name_for_id(i));
99 let tags = d.frontmatter.tags().unwrap_or_default();
100
101 let mut fields = Map::new();
102 let mut sections = Map::new();
103 if let Some(tn) = tname {
104 let t = &pcfg.types[tn];
105 for fname in t.fields.keys() {
106 if let Some(v) = d.frontmatter.get(fname) {
107 fields.insert(fname.clone(), yaml_to_json(v));
108 }
109 }
110 for sec in &t.sections {
111 let s = if sec.kind == SectionKind::Structured {
112 structured_section_json(d, sec.structure.as_deref(), &sec.heading)
113 } else {
114 section_json(d, sec.kind, &sec.heading)
115 };
116 if let Some(s) = s {
117 sections.insert(sec.heading.clone(), s);
118 }
119 }
120 }
121
122 json!({
123 "id": id,
124 "num": if id.is_empty() { 0 } else { crate::refs::id_number(id) },
125 "type": tname,
126 "status": d.status(),
127 "title": d.title,
128 "created": d.frontmatter.get_str("created"),
129 "updated": d.frontmatter.get_str("updated"),
130 "tags": tags,
131 "fields": Value::Object(fields),
132 "sections": Value::Object(sections),
133 })
134}
135
136pub fn corpus_json(pcfg: &ProjectConfig, docs: &[&Doc]) -> Value {
139 Value::Array(docs.iter().map(|d| doc_json(pcfg, d)).collect())
140}
141
142fn sql_lit(s: &str) -> String {
144 format!("'{}'", s.replace('\'', "''"))
145}
146
147fn opt_lit(v: Option<&str>) -> String {
149 v.map(sql_lit).unwrap_or_else(|| "NULL".to_string())
150}
151
152pub(crate) fn json_scalar(v: &Value) -> String {
155 match v {
156 Value::String(s) => s.clone(),
157 Value::Null => String::new(),
158 Value::Bool(b) => b.to_string(),
159 Value::Number(n) => n.to_string(),
160 other => other.to_string(),
161 }
162}
163
164fn materialize(corpus: &Value) -> String {
167 let mut docs_rows: Vec<String> = Vec::new();
168 let mut tag_rows: Vec<String> = Vec::new();
169 let mut section_rows: Vec<String> = Vec::new();
170 let mut field_rows: Vec<String> = Vec::new();
171
172 for d in corpus.as_array().into_iter().flatten() {
173 let id = d["id"].as_str().unwrap_or("");
174 let num = d["num"].as_i64().unwrap_or(0);
175 docs_rows.push(format!(
176 "({}, {}, {}, {}, {}, {}, {})",
177 sql_lit(id),
178 num,
179 opt_lit(d["type"].as_str()),
180 opt_lit(d["status"].as_str()),
181 sql_lit(d["title"].as_str().unwrap_or("")),
182 opt_lit(d["created"].as_str()),
183 opt_lit(d["updated"].as_str()),
184 ));
185
186 for t in d["tags"].as_array().into_iter().flatten() {
187 if let Some(tag) = t.as_str() {
188 let (key, value) = split_tag(tag);
189 tag_rows.push(format!(
190 "({}, {}, {}, {})",
191 sql_lit(id),
192 sql_lit(tag),
193 sql_lit(key),
194 opt_lit(value),
195 ));
196 }
197 }
198
199 if let Some(obj) = d["sections"].as_object() {
200 for (heading, s) in obj {
201 section_rows.push(format!(
202 "({}, {}, {}, {}, {})",
203 sql_lit(id),
204 sql_lit(heading),
205 opt_lit(s["kind"].as_str()),
206 s["items"].as_i64().unwrap_or(0),
207 s["unchecked"].as_i64().unwrap_or(0),
208 ));
209 }
210 }
211
212 if let Some(obj) = d["fields"].as_object() {
213 for (key, v) in obj {
214 match v {
216 Value::Array(items) => {
217 for it in items {
218 field_rows.push(format!(
219 "({}, {}, {})",
220 sql_lit(id),
221 sql_lit(key),
222 sql_lit(&json_scalar(it)),
223 ));
224 }
225 }
226 _ => field_rows.push(format!(
227 "({}, {}, {})",
228 sql_lit(id),
229 sql_lit(key),
230 sql_lit(&json_scalar(v)),
231 )),
232 }
233 }
234 }
235 }
236
237 let mut sql = String::new();
238 sql.push_str(
239 "CREATE TABLE docs (id TEXT, num INTEGER, type TEXT, status TEXT, title TEXT, created TEXT, updated TEXT);\n",
240 );
241 sql.push_str("CREATE TABLE tags (doc_id TEXT, tag TEXT, key TEXT, value TEXT);\n");
242 sql.push_str(
243 "CREATE TABLE sections (doc_id TEXT, heading TEXT, kind TEXT, items INTEGER, unchecked INTEGER);\n",
244 );
245 sql.push_str("CREATE TABLE fields (doc_id TEXT, key TEXT, value TEXT);\n");
246
247 let mut insert = |table: &str, rows: &[String]| {
248 if !rows.is_empty() {
249 sql.push_str(&format!(
250 "INSERT INTO {table} VALUES {};\n",
251 rows.join(", ")
252 ));
253 }
254 };
255 insert("docs", &docs_rows);
256 insert("tags", &tag_rows);
257 insert("sections", §ion_rows);
258 insert("fields", &field_rows);
259 sql
260}
261
262pub(crate) fn cell(v: &GlueValue) -> String {
264 match v {
265 GlueValue::Str(s) => s.clone(),
266 GlueValue::Bool(b) => b.to_string(),
267 GlueValue::I8(n) => n.to_string(),
268 GlueValue::I16(n) => n.to_string(),
269 GlueValue::I32(n) => n.to_string(),
270 GlueValue::I64(n) => n.to_string(),
271 GlueValue::I128(n) => n.to_string(),
272 GlueValue::U8(n) => n.to_string(),
273 GlueValue::U16(n) => n.to_string(),
274 GlueValue::U32(n) => n.to_string(),
275 GlueValue::U64(n) => n.to_string(),
276 GlueValue::U128(n) => n.to_string(),
277 GlueValue::F32(x) => fmt_float(*x as f64),
278 GlueValue::F64(x) => fmt_float(*x),
279 GlueValue::Null => String::new(),
280 other => format!("{other:?}"),
281 }
282}
283
284fn fmt_float(x: f64) -> String {
287 if x.is_finite() && x.fract() == 0.0 {
288 format!("{}", x as i64)
289 } else {
290 format!("{x}")
291 }
292}
293
294fn esc(s: &str) -> String {
296 s.replace('|', "\\|").replace('\n', " ")
297}
298
299pub type CorpusDb = Glue<MemoryStorage>;
303
304pub fn build_db(corpus: &Value) -> std::result::Result<CorpusDb, String> {
306 let setup = materialize(corpus);
307 let mut glue = Glue::new(MemoryStorage::default());
308 block_on(glue.execute(&setup)).map_err(|e| format!("corpus build failed ({e})"))?;
309 Ok(glue)
310}
311
312fn run_sql(
316 db: &mut CorpusDb,
317 sql: &str,
318) -> std::result::Result<(Vec<String>, Vec<Vec<String>>), String> {
319 let payloads = block_on(db.execute(sql)).map_err(|e| format!("query failed ({e})"))?;
320 let last = payloads
321 .last()
322 .ok_or_else(|| "query produced no result set".to_string())?;
323 match last {
324 Payload::Select { labels, rows } => {
325 let rows = rows.iter().map(|r| r.iter().map(cell).collect()).collect();
326 Ok((labels.clone(), rows))
327 }
328 other => Err(format!(
329 "query must end in a SELECT (got {})",
330 payload_kind(other)
331 )),
332 }
333}
334
335fn payload_kind(p: &Payload) -> &'static str {
336 match p {
337 Payload::Select { .. } | Payload::SelectMap(_) => "a projection",
338 Payload::Insert(_) => "INSERT",
339 Payload::Update(_) => "UPDATE",
340 Payload::Delete(_) => "DELETE",
341 _ => "a non-select statement",
342 }
343}
344
345pub(crate) fn render_table(name: &str, labels: &[String], rows: &[Vec<String>]) -> String {
348 format!("## {name}\n\n{}", table_body(labels, rows))
349}
350
351pub(crate) fn table_body(labels: &[String], rows: &[Vec<String>]) -> String {
353 let mut out = String::new();
354 if labels.is_empty() || rows.is_empty() {
355 out.push_str("_(no rows)_\n");
356 return out;
357 }
358 let header: Vec<String> = labels.iter().map(|l| esc(l)).collect();
359 out.push_str(&format!("| {} |\n", header.join(" | ")));
360 out.push_str(&format!("| {} |\n", vec!["---"; labels.len()].join(" | ")));
361 for r in rows {
362 let cells: Vec<String> = (0..labels.len())
363 .map(|i| esc(r.get(i).map(String::as_str).unwrap_or("")))
364 .collect();
365 out.push_str(&format!("| {} |\n", cells.join(" | ")));
366 }
367 out
368}
369
370pub(crate) fn print_markdown(out: &str, plain: bool) {
373 let styled =
374 !plain && std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none();
375 if styled {
376 termimad::MadSkin::default().print_text(out);
377 } else {
378 println!("{out}");
379 }
380}
381
382fn expand_template(
386 tmpl: &str,
387 resolve: &dyn Fn(&str) -> Option<String>,
388) -> std::result::Result<String, String> {
389 let mut out = String::new();
390 let mut chars = tmpl.chars().peekable();
391 while let Some(c) = chars.next() {
392 match c {
393 '{' if chars.peek() == Some(&'{') => {
394 chars.next();
395 out.push('{');
396 }
397 '}' if chars.peek() == Some(&'}') => {
398 chars.next();
399 out.push('}');
400 }
401 '{' => {
402 let mut name = String::new();
403 loop {
404 match chars.next() {
405 Some('}') => break,
406 Some(ch) => name.push(ch),
407 None => return Err("unclosed '{' in template".to_string()),
408 }
409 }
410 let key = name.trim();
411 match resolve(key) {
412 Some(v) => out.push_str(&v),
413 None => return Err(format!("template references unknown column '{{{key}}}'")),
414 }
415 }
416 '}' => return Err("unmatched '}' in template (use '}}' for a literal)".to_string()),
417 _ => out.push(c),
418 }
419 }
420 Ok(out)
421}
422
423fn render_templated(
427 name: &str,
428 labels: &[String],
429 rows: &[Vec<String>],
430 tmpl: &str,
431) -> std::result::Result<String, String> {
432 let known = |k: &str| labels.iter().any(|l| l == k);
433 expand_template(tmpl, &|k| known(k).then(String::new))?;
435
436 let mut out = format!("## {name}\n\n");
437 if rows.is_empty() {
438 out.push_str("_(no rows)_\n");
439 return Ok(out);
440 }
441 for row in rows {
442 let cell_for = |k: &str| {
443 labels
444 .iter()
445 .position(|l| l == k)
446 .map(|i| row.get(i).cloned().unwrap_or_default())
447 };
448 out.push_str(&expand_template(tmpl, &cell_for)?);
449 out.push('\n');
450 }
451 Ok(out)
452}
453
454pub fn render_stat_on(db: &mut CorpusDb, spec: &StatSpec) -> std::result::Result<String, String> {
459 let (labels, rows) =
460 run_sql(db, &spec.sql).map_err(|e| format!("stats '{}': {e}", spec.name))?;
461 match &spec.template {
462 Some(tmpl) => render_templated(&spec.name, &labels, &rows, tmpl)
463 .map_err(|e| format!("stats '{}': {e}", spec.name)),
464 None => Ok(render_table(&spec.name, &labels, &rows)),
465 }
466}
467
468pub fn render_stat(spec: &StatSpec, corpus: &Value) -> std::result::Result<String, String> {
471 let mut db = build_db(corpus)?;
472 render_stat_on(&mut db, spec)
473}
474
475pub fn render_all(pcfg: &ProjectConfig, docs: &[&Doc]) -> std::result::Result<String, String> {
479 let corpus = corpus_json(pcfg, docs);
480 let mut db = build_db(&corpus)?;
481 let mut sections = Vec::new();
482 for spec in &pcfg.stats {
483 sections.push(render_stat_on(&mut db, spec)?.trim_end().to_string());
484 }
485 Ok(sections.join("\n\n"))
486}
487
488pub fn run(ctx: &Ctx, plain: bool) -> Result<()> {
489 let prj = ctx.open()?;
490 let (docs, _) = ctx.backend.load_docs(&prj);
491 let doc_refs: Vec<&Doc> = docs.iter().collect();
492 let out = render_all(&prj.pcfg, &doc_refs).map_err(usage)?;
493 if out.is_empty() {
494 return Ok(());
495 }
496 print_markdown(&out, plain);
497 Ok(())
498}