use std::io::Read;
use std::path::Path;
use anyhow::{Result, anyhow};
use clap::{Args, ValueEnum};
use crate::{CalculationResponse, Calculator};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum)]
pub enum OutputFormat {
#[default]
Text,
Json,
}
#[derive(Debug, Args)]
pub struct CalcCommand {
pub name: Option<String>,
#[arg(long, value_name = "JSON|FILE|-")]
pub input: Option<String>,
#[arg(long)]
pub schema: bool,
#[arg(long)]
pub license: bool,
#[arg(long, value_name = "TAG")]
pub tag: Vec<String>,
#[arg(long)]
pub tags: bool,
#[arg(long, value_enum, default_value_t = OutputFormat::Text)]
pub format: OutputFormat,
}
pub fn run(cmd: CalcCommand) -> Result<()> {
if cmd.tags {
return print_tags(cmd.format);
}
let name = match cmd.name.as_deref() {
None | Some("list") => return print_list(cmd.format, &cmd.tag),
Some(n) => n,
};
let calc = crate::get(name)
.ok_or_else(|| anyhow!("unknown calculator: {name} (try `clincalc list`)"))?;
if cmd.schema {
println!("{}", serde_json::to_string_pretty(&calc.input_schema())?);
return Ok(());
}
if cmd.license {
println!("{}", serde_json::to_string_pretty(&calc.license())?);
return Ok(());
}
match cmd.input.as_deref() {
None => {
let schema = calc.input_schema();
let template = calc.input_template();
if template.as_object().is_some_and(serde_json::Map::is_empty) {
let response = calc
.calculate(&serde_json::json!({}))
.map_err(|e| anyhow!("{e}"))?;
return emit(&response, cmd.format);
}
println!("{}", serde_json::to_string_pretty(&template)?);
if let Some(note) = oneof_alternatives_note(&schema) {
eprintln!("\n{note}");
}
eprintln!(
"\nReplace each placeholder with a value, then compute with one of:\n \
clincalc {name} --input <file.json>\n \
clincalc {name} --input '<json>'\n \
clincalc {name} --input - # read JSON from stdin\n\
See the full input contract with: clincalc {name} --schema"
);
Ok(())
}
Some(src) => {
let input = read_input(src)?;
let response = calc.calculate(&input).map_err(|e| anyhow!("{e}"))?;
emit(&response, cmd.format)
}
}
}
fn read_input(src: &str) -> Result<serde_json::Value> {
let raw = if src == "-" {
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf)?;
buf
} else if Path::new(src).is_file() {
std::fs::read_to_string(src)?
} else {
src.to_string()
};
serde_json::from_str(&raw).map_err(|e| {
anyhow!("invalid JSON input: {e}\nSee the expected shape with: clincalc <name>")
})
}
fn print_list(format: OutputFormat, required_tags: &[String]) -> Result<()> {
let passes = |c: &dyn Calculator| -> bool {
if required_tags.is_empty() {
return true;
}
let tags = c.tags();
required_tags.iter().all(|t| tags.contains(&t.as_str()))
};
match format {
OutputFormat::Json => {
let items: Vec<_> = crate::all()
.iter()
.filter(|c| passes(c.as_ref()))
.map(|c| {
let lic = c.license();
serde_json::json!({
"name": c.name(),
"title": c.title(),
"description": c.description(),
"license": lic.license,
"license_source": lic.source_url,
"tags": c.tags(),
})
})
.collect();
println!("{}", serde_json::to_string_pretty(&items)?);
}
OutputFormat::Text => {
for c in crate::all().iter().filter(|c| passes(c.as_ref())) {
println!(
"{:<12} {:<48} [{}]",
c.name(),
c.title(),
c.tags().join(", ")
);
}
}
}
Ok(())
}
fn print_tags(format: OutputFormat) -> Result<()> {
use std::collections::BTreeMap;
let mut counts: BTreeMap<&'static str, usize> = BTreeMap::new();
for c in crate::all() {
for t in c.tags() {
*counts.entry(*t).or_insert(0) += 1;
}
}
match format {
OutputFormat::Json => {
let items: Vec<_> = counts
.iter()
.map(|(t, n)| serde_json::json!({ "tag": t, "count": n }))
.collect();
println!("{}", serde_json::to_string_pretty(&items)?);
}
OutputFormat::Text => {
for (t, n) in &counts {
println!("{:<22} {:>3}", t, n);
}
}
}
Ok(())
}
fn emit(response: &CalculationResponse, format: OutputFormat) -> Result<()> {
match format {
OutputFormat::Json => println!("{}", serde_json::to_string_pretty(response)?),
OutputFormat::Text => println!("{}", render_text(response)),
}
Ok(())
}
fn render_text(r: &CalculationResponse) -> String {
let mut out = String::new();
out.push_str(&format!(
"{} = {}\n\n",
r.calculator,
value_to_string(&r.result)
));
out.push_str(&r.interpretation);
if !r.working.is_empty() {
out.push_str("\n\nWorking:");
for (k, v) in &r.working {
out.push_str(&format!("\n {k}: {}", value_to_string(v)));
}
}
out.push_str(&format!("\n\nReference: {}", r.reference));
out
}
fn value_to_string(v: &serde_json::Value) -> String {
match v {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
}
}
fn oneof_alternatives_note(schema: &serde_json::Value) -> Option<String> {
let alts = schema.get("oneOf")?.as_array()?;
let groups: Vec<Vec<String>> = alts
.iter()
.filter_map(|alt| {
alt.get("required").and_then(|r| r.as_array()).map(|r| {
r.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
})
.filter(|g: &Vec<String>| !g.is_empty())
.collect();
if groups.len() < 2 {
return None;
}
let mut out =
String::from("This calculator accepts more than one input shape. Pick exactly one:\n");
for (i, g) in groups.iter().enumerate() {
let marker = if i == 0 { "shown above" } else { "alternative" };
out.push_str(&format!(" {}: {} ({marker})\n", i + 1, g.join(" + ")));
}
Some(out.trim_end().to_string())
}
#[cfg(test)]
mod tests {
use super::oneof_alternatives_note;
use serde_json::json;
#[test]
fn oneof_note_lists_each_alternative() {
let schema = json!({
"type": "object",
"oneOf": [
{ "required": ["acr", "acr_unit"] },
{ "required": ["albumin", "creatinine"] }
]
});
let note = oneof_alternatives_note(&schema).unwrap();
assert!(note.contains("acr + acr_unit"));
assert!(note.contains("albumin + creatinine"));
assert!(note.contains("shown above"));
}
#[test]
fn no_oneof_yields_no_note() {
assert!(oneof_alternatives_note(&json!({"type": "object"})).is_none());
}
}