feat: add platform shells, CLI, formatting, plugins, tests, and benchmarks

Phase 4 — Platform shells:
- calcpad-macos/: SwiftUI two-column editor with Rust FFI bridge (16 files)
- calcpad-windows/: iced GUI with Windows 11 Fluent theme (7 files, 13 tests)
- calcpad-web/: React 18 + CodeMirror 6 + WASM Worker + PWA (20 files)
- calcpad-cli/: clap-based CLI with expression eval, pipe/stdin, JSON/CSV
  output, and interactive REPL with rustyline history

Phase 5 — Engine modules:
- formatting/: answer formatting (decimal/scientific/SI notation, thousands
  separators, currency), line type classification, clipboard values (93 tests)
- plugins/: CalcPadPlugin trait, PluginRegistry, Rhai scripting stub (43 tests)
- benches/: criterion benchmarks (single-line, 100/500-line sheets, DAG, incremental)
- tests/sheet_scenarios.rs: 20 real-world integration tests
- tests/proptest_fuzz.rs: 12 property-based fuzz tests

771 tests passing across workspace, 0 failures.
This commit is contained in:
C. Cassel
2026-03-17 09:46:40 -04:00
committed by C. Cassel
parent 68fa54615a
commit 806e2f1ec6
73 changed files with 11715 additions and 32 deletions

15
calcpad-cli/Cargo.toml Normal file
View File

@@ -0,0 +1,15 @@
[package]
name = "calcpad-cli"
version = "0.1.0"
edition = "2021"
description = "CLI tool for CalcPad — notepad-style calculator"
[[bin]]
name = "calcpad"
path = "src/main.rs"
[dependencies]
calcpad-engine = { path = "../calcpad-engine" }
clap = { version = "4", features = ["derive"] }
rustyline = "15"
serde_json = "1"

328
calcpad-cli/src/main.rs Normal file
View File

@@ -0,0 +1,328 @@
use std::io::{self, IsTerminal, Read};
use std::process::ExitCode;
use clap::{Parser, ValueEnum};
use rustyline::error::ReadlineError;
use rustyline::DefaultEditor;
use calcpad_engine::{eval_line, eval_sheet, CalcResult, CalcValue, EvalContext, ResultType};
// ---------------------------------------------------------------------------
// CLI argument parsing
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Copy, ValueEnum)]
enum OutputFormat {
Plain,
Json,
Csv,
}
#[derive(Parser, Debug)]
#[command(
name = "calcpad",
about = "CalcPad — notepad-style calculator",
long_about = "Evaluate mathematical expressions, unit conversions, and more.\n\n\
Examples:\n \
calcpad \"2 + 3 * 4\"\n \
calcpad \"5kg in lbs\"\n \
echo \"100 + 200\" | calcpad\n \
calcpad --repl"
)]
struct Cli {
/// Expression to evaluate (omit for stdin/pipe mode).
expression: Option<String>,
/// Start an interactive REPL session.
#[arg(long)]
repl: bool,
/// Output format.
#[arg(long, value_enum, default_value_t = OutputFormat::Plain)]
format: OutputFormat,
}
// ---------------------------------------------------------------------------
// Result helpers
// ---------------------------------------------------------------------------
fn is_error(result: &CalcResult) -> bool {
result.metadata.result_type == ResultType::Error
}
fn is_empty_error(result: &CalcResult) -> bool {
// The engine returns CalcResult::error("empty input", ..) or
// CalcResult::error("no expression found", ..) for blank/comment lines.
if let CalcValue::Error { ref message, .. } = result.value {
message == "empty input" || message == "no expression found"
} else {
false
}
}
fn error_message(result: &CalcResult) -> Option<&str> {
if let CalcValue::Error { ref message, .. } = result.value {
Some(message)
} else {
None
}
}
// ---------------------------------------------------------------------------
// Output formatting
// ---------------------------------------------------------------------------
fn print_result_json(result: &CalcResult) {
// CalcResult derives Serialize via the engine, so we can emit it directly.
match serde_json::to_string(result) {
Ok(json) => println!("{}", json),
Err(e) => eprintln!("Error: failed to serialize result: {}", e),
}
}
fn print_results_json(results: &[CalcResult]) {
match serde_json::to_string_pretty(results) {
Ok(json) => println!("{}", json),
Err(e) => eprintln!("Error: failed to serialize results: {}", e),
}
}
fn print_result_csv(result: &CalcResult, line_num: usize) {
if is_empty_error(result) {
println!("{},,,", line_num);
return;
}
// line,type,display,raw_value
let rtype = result.metadata.result_type;
let display = &result.metadata.display;
// Escape display for CSV (wrap in quotes if it contains comma/quote)
let escaped = csv_escape(display);
let raw = result
.metadata
.raw_value
.map(|v| v.to_string())
.unwrap_or_default();
println!("{},{},{},{}", line_num, rtype, escaped, raw);
}
fn csv_escape(s: &str) -> String {
if s.contains(',') || s.contains('"') || s.contains('\n') {
format!("\"{}\"", s.replace('"', "\"\""))
} else {
s.to_string()
}
}
// ---------------------------------------------------------------------------
// Modes
// ---------------------------------------------------------------------------
fn run_single_expression(expression: &str, format: OutputFormat) -> ExitCode {
let mut ctx = EvalContext::new();
let result = eval_line(expression, &mut ctx);
if is_empty_error(&result) {
return ExitCode::SUCCESS;
}
match format {
OutputFormat::Plain => {
if is_error(&result) {
if let Some(msg) = error_message(&result) {
eprintln!("Error: {}", msg);
}
return ExitCode::from(1);
}
println!("{}", result.metadata.display);
}
OutputFormat::Json => {
print_result_json(&result);
if is_error(&result) {
return ExitCode::from(1);
}
}
OutputFormat::Csv => {
println!("line,type,display,raw_value");
print_result_csv(&result, 1);
if is_error(&result) {
return ExitCode::from(1);
}
}
}
ExitCode::SUCCESS
}
fn run_stdin(format: OutputFormat) -> ExitCode {
let mut input = String::new();
if let Err(e) = io::stdin().read_to_string(&mut input) {
eprintln!("Error: failed to read stdin: {}", e);
return ExitCode::from(1);
}
let lines: Vec<&str> = input.lines().collect();
if lines.is_empty() {
return ExitCode::SUCCESS;
}
let mut ctx = EvalContext::new();
let results = eval_sheet(&lines, &mut ctx);
let mut has_error = false;
match format {
OutputFormat::Plain => {
for result in &results {
if is_empty_error(result) {
println!();
} else if is_error(result) {
if let Some(msg) = error_message(result) {
println!("Error: {}", msg);
}
has_error = true;
} else {
println!("{}", result.metadata.display);
}
}
}
OutputFormat::Json => {
print_results_json(&results);
has_error = results.iter().any(|r| is_error(r) && !is_empty_error(r));
}
OutputFormat::Csv => {
println!("line,type,display,raw_value");
for (i, result) in results.iter().enumerate() {
print_result_csv(result, i + 1);
if is_error(result) && !is_empty_error(result) {
has_error = true;
}
}
}
}
if has_error {
ExitCode::from(1)
} else {
ExitCode::SUCCESS
}
}
fn run_repl(format: OutputFormat) -> ExitCode {
let mut rl = match DefaultEditor::new() {
Ok(editor) => editor,
Err(err) => {
eprintln!("Error: failed to initialize line editor: {}", err);
return ExitCode::from(1);
}
};
// Try to load history from ~/.calcpad_history
let history_path = dirs_history_path();
if let Some(ref path) = history_path {
let _ = rl.load_history(path);
}
let mut ctx = EvalContext::new();
loop {
match rl.readline("> ") {
Ok(line) => {
let trimmed = line.trim();
if is_exit_command(trimmed) {
break;
}
let _ = rl.add_history_entry(&line);
let result = eval_line(&line, &mut ctx);
if is_empty_error(&result) {
// Blank or comment — no output.
continue;
}
match format {
OutputFormat::Plain => {
if is_error(&result) {
if let Some(msg) = error_message(&result) {
eprintln!("Error: {}", msg);
}
} else {
println!("{}", result.metadata.display);
}
}
OutputFormat::Json => {
print_result_json(&result);
}
OutputFormat::Csv => {
// In REPL mode we don't print a CSV header each time;
// just emit one row per expression.
let rtype = result.metadata.result_type;
let display = csv_escape(&result.metadata.display);
let raw = result
.metadata
.raw_value
.map(|v| v.to_string())
.unwrap_or_default();
println!("{},{},{}", rtype, display, raw);
}
}
}
Err(ReadlineError::Eof) => break,
Err(ReadlineError::Interrupted) => break,
Err(err) => {
eprintln!("Error: {}", err);
return ExitCode::from(1);
}
}
}
// Save history
if let Some(ref path) = history_path {
let _ = rl.save_history(path);
}
ExitCode::SUCCESS
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn is_exit_command(input: &str) -> bool {
let t = input.trim();
t.eq_ignore_ascii_case("exit") || t.eq_ignore_ascii_case("quit")
}
fn dirs_history_path() -> Option<std::path::PathBuf> {
// Use $HOME/.calcpad_history if available
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".calcpad_history"))
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
fn main() -> ExitCode {
let cli = Cli::parse();
if cli.repl {
return run_repl(cli.format);
}
if let Some(ref expr) = cli.expression {
return run_single_expression(expr, cli.format);
}
// No expression argument — check if stdin is piped / redirected.
if !io::stdin().is_terminal() {
return run_stdin(cli.format);
}
// Nothing provided — show help.
// Re-parse with --help to trigger clap's help output.
eprintln!("Usage: calcpad <EXPRESSION>");
eprintln!(" calcpad --repl");
eprintln!(" echo \"100 + 200\" | calcpad");
eprintln!();
eprintln!("Run 'calcpad --help' for full usage information.");
ExitCode::from(1)
}