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:
15
calcpad-cli/Cargo.toml
Normal file
15
calcpad-cli/Cargo.toml
Normal 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
328
calcpad-cli/src/main.rs
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user