use crate::domain::errors::{AppError, Result};
use crate::domain::evaluator::Evaluator;
use crate::domain::expression::{self, Statement};
use crate::domain::format::{
AngleMode, FormatSettings, Notation, format_display, format_plain,
};
use crate::domain::history::{History, HistoryEntry, LineResult};
use crate::domain::quantity::Quantity;
use crate::domain::units;
use crate::domain::variables::VariableStore;
const RESERVED_NAMES: &[&str] = &["ans", "pi", "e"];
#[derive(Debug, Clone, PartialEq)]
pub struct SubmitOutcome {
pub value: Option<Quantity>,
pub error: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Preview {
Empty,
Value(Quantity),
Incomplete,
Invalid,
}
pub struct CalcService {
evaluator: Box<dyn Evaluator>,
variables: VariableStore,
history: History,
settings: FormatSettings,
}
impl CalcService {
pub fn new(
evaluator: Box<dyn Evaluator>,
settings: FormatSettings,
history: History,
variables: VariableStore,
) -> Self {
CalcService {
evaluator,
variables,
history,
settings,
}
}
pub fn settings(&self) -> &FormatSettings {
&self.settings
}
pub fn history(&self) -> &History {
&self.history
}
pub fn variables(&self) -> &VariableStore {
&self.variables
}
pub fn submit(&mut self, input: &str) -> SubmitOutcome {
let ans = self.history.last_value();
let (value, error) = self.evaluate_line(input, ans);
let outcome = SubmitOutcome {
value: value.clone(),
error: error.clone(),
};
self.history.push(HistoryEntry {
input: input.to_string(),
value,
error,
});
outcome
}
pub fn edit_entry(&mut self, index: usize, new_input: &str) {
self.history.set_input(index, new_input.to_string());
self.recompute(index);
}
pub fn delete_entry(&mut self, index: usize) {
self.history.remove(index);
self.recompute(index);
}
pub fn move_entry(&mut self, index: usize, delta: isize) -> usize {
let len = self.history.len();
if len == 0 {
return 0;
}
let target =
(index as isize + delta).clamp(0, len as isize - 1) as usize;
if target != index {
self.history.swap(index, target);
self.recompute(index.min(target));
}
target
}
pub fn insert_entry(&mut self, index: usize) {
let blank = HistoryEntry {
input: String::new(),
value: None,
error: None,
};
self.history.insert(index, blank);
self.recompute(index);
}
pub fn clear_history(&mut self) {
self.history.clear();
}
pub fn recompute_all(&mut self) {
self.recompute(0);
}
pub fn reset_variables(&mut self) {
self.variables.clear();
}
pub fn remove_variable(&mut self, name: &str) {
self.variables.remove(name);
}
pub fn cycle_notation(&mut self) {
self.settings.notation = self.settings.notation.next();
}
pub fn set_notation(&mut self, notation: Notation) {
self.settings.notation = notation;
}
pub fn set_angle_mode(&mut self, angle_mode: AngleMode) {
if self.settings.angle_mode != angle_mode {
self.settings.angle_mode = angle_mode;
self.recompute(0);
}
}
pub fn set_decimals(&mut self, decimals: usize) {
self.settings.decimals = decimals;
}
pub fn toggle_angle_mode(&mut self) {
self.settings.angle_mode = self.settings.angle_mode.toggled();
self.recompute(0);
}
pub fn toggle_decimal_separator(&mut self) {
self.settings.toggle_decimal_separator();
self.recompute(0);
}
pub fn toggle_trim_trailing_zeros(&mut self) {
self.settings.trim_trailing_zeros = !self.settings.trim_trailing_zeros;
}
pub fn set_thousands_separator(&mut self, separator: String) {
self.settings.thousands_separator = separator;
}
pub fn format_display(&self, value: &Quantity) -> String {
format_display(value, &self.settings)
}
pub fn format_plain(&self, value: &Quantity) -> String {
format_plain(value, &self.settings)
}
pub fn preview(&self, input: &str) -> Preview {
let code = expression::strip_comment(input).trim();
if code.is_empty() || code.starts_with(':') {
return Preview::Empty;
}
match self.preview_value(code) {
Some(value) => Preview::Value(value),
None if expression::looks_incomplete(code) => Preview::Incomplete,
None => Preview::Invalid,
}
}
fn preview_value(&self, input: &str) -> Option<Quantity> {
let ans = self.history.last_value();
match expression::classify(input) {
Statement::SaveAns(name) => {
if reject_name(&name).is_some() {
return None;
}
ans
}
Statement::Assign { name, expr } => {
if reject_name(&name).is_some() {
return None;
}
eval_expression(
self.evaluator.as_ref(),
&self.variables,
&self.settings,
&expr,
ans,
)
.ok()
}
Statement::Expression(expr) => eval_expression(
self.evaluator.as_ref(),
&self.variables,
&self.settings,
&expr,
ans,
)
.ok(),
}
}
fn recompute(&mut self, start: usize) {
let evaluator = self.evaluator.as_ref();
let variables = &mut self.variables;
let settings = &self.settings;
self.history.recompute_from(start, |input, ans| {
evaluate_line(evaluator, variables, settings, input, ans)
});
}
fn evaluate_line(
&mut self,
input: &str,
ans: Option<Quantity>,
) -> LineResult {
evaluate_line(
self.evaluator.as_ref(),
&mut self.variables,
&self.settings,
input,
ans,
)
}
}
fn evaluate_line(
evaluator: &dyn Evaluator,
variables: &mut VariableStore,
settings: &FormatSettings,
input: &str,
ans: Option<Quantity>,
) -> LineResult {
let code = expression::strip_comment(input);
if code.trim().is_empty() {
return (None, None);
}
match expression::classify(code) {
Statement::SaveAns(name) => save_ans(variables, &name, ans),
Statement::Assign { name, expr } => {
assign(evaluator, variables, settings, &name, &expr, ans)
}
Statement::Expression(expr) => {
match eval_expression(evaluator, variables, settings, &expr, ans) {
Ok(value) => (Some(value), None),
Err(error) => (None, Some(error.to_string())),
}
}
}
}
fn save_ans(
variables: &mut VariableStore,
name: &str,
ans: Option<Quantity>,
) -> LineResult {
if let Some(error) = reject_name(name) {
return (None, Some(error.to_string()));
}
match ans {
Some(value) => {
variables.set(name, value.clone());
(Some(value), None)
}
None => (None, Some(AppError::NoAnswerToSave.to_string())),
}
}
fn assign(
evaluator: &dyn Evaluator,
variables: &mut VariableStore,
settings: &FormatSettings,
name: &str,
expr: &str,
ans: Option<Quantity>,
) -> LineResult {
if let Some(error) = reject_name(name) {
return (None, Some(error.to_string()));
}
match eval_expression(evaluator, variables, settings, expr, ans) {
Ok(value) => {
variables.set(name, value.clone());
(Some(value), None)
}
Err(error) => (None, Some(error.to_string())),
}
}
fn eval_expression(
evaluator: &dyn Evaluator,
variables: &VariableStore,
settings: &FormatSettings,
expr: &str,
ans: Option<Quantity>,
) -> Result<Quantity> {
let trimmed = expr.trim();
if trimmed == "ans" {
return ans.ok_or(AppError::NoPreviousAnswer);
}
if let Some(quantity) = variables.get(trimmed) {
return Ok(quantity.clone());
}
if needs_units(expr, variables, ans.as_ref()) {
return eval_with_rink(variables, settings, expr, ans);
}
let value = eval_with_meval(evaluator, variables, settings, expr, ans)?;
Ok(Quantity::dimensionless(value))
}
fn needs_units(
expr: &str,
variables: &VariableStore,
ans: Option<&Quantity>,
) -> bool {
if split_conversion(expr).is_some() {
return true;
}
let leading_operator =
expr.trim_start().starts_with(['+', '-', '*', '/', '^']);
if ans.is_some_and(|a| !a.is_dimensionless())
&& (leading_operator || expression::references(expr, "ans"))
{
return true;
}
for (name, value) in variables.iter() {
if !value.is_dimensionless() && expression::references(expr, name) {
return true;
}
}
expr.split(|c: char| !c.is_ascii_alphabetic())
.filter(|token| !token.is_empty())
.any(|token| {
!matches!(token, "pi" | "e" | "ans")
&& variables.get(token).is_none()
&& units::is_unit(token)
})
}
fn split_conversion(expr: &str) -> Option<(&str, &str)> {
if let Some((source, target)) = expr.split_once("->") {
return Some((source, target));
}
expr.split_once(" to ")
}
fn eval_with_rink(
variables: &VariableStore,
settings: &FormatSettings,
expr: &str,
ans: Option<Quantity>,
) -> Result<Quantity> {
let prepared = substitute_for_units(variables, settings, expr, ans)?;
if let Some((_, target)) = split_conversion(expr) {
let target = target.trim();
if !target.is_empty() {
let (value, _) = units::eval(&prepared)?;
return Ok(Quantity::new(value, target.to_string()));
}
}
let (base_value, unit) = units::eval(&prepared)?;
let Some(unit) = unit else {
return Ok(Quantity::dimensionless(base_value));
};
let display_unit = simple_literal_unit(expr)
.unwrap_or_else(|| units::prettify_unit(&unit));
let value = base_value / units::scale_of(&display_unit)?;
Ok(Quantity::new(value, display_unit))
}
fn substitute_for_units(
variables: &VariableStore,
settings: &FormatSettings,
expr: &str,
ans: Option<Quantity>,
) -> Result<String> {
let mut prepared = prepare_units_expr(expr, settings.decimal_separator);
let leading_operator =
prepared.trim_start().starts_with(['+', '-', '*', '/', '^']);
if leading_operator && ans.is_some() {
prepared = format!("ans {prepared}");
}
if expression::references(&prepared, "ans") {
let value = ans.as_ref().ok_or(AppError::NoPreviousAnswer)?;
prepared = expression::substitute_identifier_with(
&prepared,
"ans",
&quantity_literal(value),
);
}
for (name, value) in variables.iter() {
if expression::references(&prepared, name) {
prepared = expression::substitute_identifier_with(
&prepared,
name,
&quantity_literal(value),
);
}
}
Ok(prepared)
}
fn simple_literal_unit(expr: &str) -> Option<String> {
let (head, last) = expr.trim().rsplit_once(char::is_whitespace)?;
let head = head.trim();
let number_chars = |c: char| {
c.is_ascii_digit() || matches!(c, '.' | 'e' | 'E' | '+' | '-')
};
if head.is_empty()
|| !head.chars().all(number_chars)
|| !head.chars().any(|c| c.is_ascii_digit())
|| !units::is_unit(last)
{
return None;
}
Some(last.to_string())
}
fn quantity_literal(quantity: &Quantity) -> String {
match quantity.unit_symbol() {
Some(symbol) => format!("({} {})", quantity.display_value(), symbol),
None => format!("({})", quantity.display_value()),
}
}
fn prepare_units_expr(expr: &str, decimal_separator: char) -> String {
let replaced = expr.replace("**", "^");
if decimal_separator == ',' {
replaced.replace(',', ".")
} else {
replaced
}
}
fn eval_with_meval(
evaluator: &dyn Evaluator,
variables: &VariableStore,
settings: &FormatSettings,
expr: &str,
ans: Option<Quantity>,
) -> Result<f64> {
let prepared = expression::preprocess(expr, settings.decimal_separator);
let ans_number = ans
.as_ref()
.filter(|a| a.is_dimensionless())
.map(Quantity::display_value);
let prepared = expression::prepend_ans(&prepared, ans_number);
let mut prepared = expression::substitute_ans(&prepared, ans_number);
for (name, value) in variables.iter() {
if expression::references(&prepared, name) && value.is_dimensionless() {
prepared = expression::substitute_identifier(
&prepared,
name,
value.display_value(),
);
}
}
evaluator.eval(&prepared, settings.angle_mode)
}
fn reject_name(name: &str) -> Option<AppError> {
if !expression::is_valid_var_name(name) {
return Some(AppError::InvalidVariableName(name.to_string()));
}
if RESERVED_NAMES.contains(&name) {
return Some(AppError::ReservedName(name.to_string()));
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::evaluator::MevalEvaluator;
use crate::domain::format::{AngleMode, Notation};
fn settings() -> FormatSettings {
FormatSettings {
notation: Notation::Decimal,
decimals: 3,
angle_mode: AngleMode::Rad,
decimal_separator: '.',
thousands_separator: " ".to_string(),
trim_trailing_zeros: false,
}
}
fn service() -> CalcService {
CalcService::new(
Box::new(MevalEvaluator::new()),
settings(),
History::new(100),
VariableStore::new(),
)
}
fn value_at(service: &CalcService, index: usize) -> Option<f64> {
service.history().entries()[index]
.value
.as_ref()
.map(Quantity::display_value)
}
fn last(service: &CalcService) -> Option<f64> {
service.history().last_value().map(|q| q.display_value())
}
fn var(service: &CalcService, name: &str) -> Option<f64> {
service.variables().get(name).map(Quantity::display_value)
}
fn outval(outcome: &SubmitOutcome) -> Option<f64> {
outcome.value.as_ref().map(Quantity::display_value)
}
fn val(value: f64) -> Preview {
Preview::Value(Quantity::dimensionless(value))
}
#[test]
fn submit_evaluates_and_records_history() {
let mut service = service();
let outcome = service.submit("2+3");
assert_eq!(outval(&outcome), Some(5.0));
assert_eq!(last(&service), Some(5.0));
}
#[test]
fn ans_continues_from_the_previous_line() {
let mut service = service();
service.submit("10");
service.submit("+5");
assert_eq!(value_at(&service, 1), Some(15.0));
service.submit("ans*2");
assert_eq!(value_at(&service, 2), Some(30.0));
}
#[test]
fn editing_a_line_recomputes_the_chain_below() {
let mut service = service();
service.submit("10");
service.submit("ans+5");
service.submit("ans*2");
assert_eq!(value_at(&service, 2), Some(30.0));
service.edit_entry(0, "20");
assert_eq!(value_at(&service, 1), Some(25.0));
assert_eq!(value_at(&service, 2), Some(50.0));
}
#[test]
fn deleting_a_line_recomputes_the_chain_below() {
let mut service = service();
service.submit("10");
service.submit("ans+5");
service.submit("ans+100");
service.delete_entry(1);
assert_eq!(value_at(&service, 1), Some(110.0));
}
#[test]
fn save_ans_and_assignment_define_variables() {
let mut service = service();
service.submit("7");
service.submit("=x");
assert_eq!(var(&service, "x"), Some(7.0));
service.submit("y = x + 3");
assert_eq!(var(&service, "y"), Some(10.0));
service.submit("y*2");
assert_eq!(last(&service), Some(20.0));
}
#[test]
fn reserved_and_invalid_names_are_rejected() {
let mut service = service();
service.submit("5");
let outcome = service.submit("=pi");
assert!(outcome.error.is_some());
let outcome = service.submit("1abc = 3");
assert!(outcome.error.is_some());
}
#[test]
fn an_errored_line_is_recorded_without_a_value() {
let mut service = service();
let outcome = service.submit("2+");
assert!(outcome.error.is_some());
assert_eq!(last(&service), None);
}
#[test]
fn toggling_angle_mode_recomputes_history() {
let mut service = service();
service.submit("sin(90)");
assert!((value_at(&service, 0).unwrap() - 1.0).abs() > 0.1);
service.toggle_angle_mode();
assert!((value_at(&service, 0).unwrap() - 1.0).abs() < 1e-9);
}
#[test]
fn toggling_decimal_separator_reparses_history_input() {
let mut service = service();
service.submit("1,5");
assert_eq!(value_at(&service, 0), Some(15.0));
service.toggle_decimal_separator();
assert_eq!(value_at(&service, 0), Some(1.5));
}
#[test]
fn variable_used_before_assignment_recomputes_after_edit() {
let mut service = service();
service.submit("a = 2");
service.submit("a * 10");
assert_eq!(value_at(&service, 1), Some(20.0));
service.edit_entry(0, "a = 5");
assert_eq!(value_at(&service, 1), Some(50.0));
}
#[test]
fn preview_reports_value_incomplete_and_invalid() {
let mut service = service();
service.submit("10");
assert_eq!(service.preview("2+3"), val(5.0));
assert_eq!(service.preview("ans+5"), val(15.0));
assert_eq!(service.preview("2+"), Preview::Incomplete);
assert_eq!(service.preview("2+3)"), Preview::Invalid);
assert_eq!(service.preview(""), Preview::Empty);
assert_eq!(service.preview(":d4"), Preview::Empty);
}
#[test]
fn preview_handles_assignments_without_mutating_state() {
let mut service = service();
service.submit("7");
assert_eq!(service.preview("x = ans + 3"), val(10.0));
assert!(service.variables().get("x").is_none());
assert_eq!(service.history().len(), 1);
assert_eq!(service.preview("pi = 3"), Preview::Invalid);
}
#[test]
fn inline_comments_are_ignored_but_kept_in_history() {
let mut service = service();
let outcome = service.submit("2+3 # the sum");
assert_eq!(outval(&outcome), Some(5.0));
assert_eq!(service.history().entries()[0].input, "2+3 # the sum");
service.submit("x = 5 # a note");
assert_eq!(var(&service, "x"), Some(5.0));
}
#[test]
fn a_comment_only_line_is_a_note_that_passes_ans_through() {
let mut service = service();
service.submit("5");
let outcome = service.submit("# just a note");
assert_eq!(outcome.value, None);
assert_eq!(outcome.error, None);
assert_eq!(service.history().entries()[1].input, "# just a note");
service.submit("ans + 1");
assert_eq!(value_at(&service, 2), Some(6.0));
}
#[test]
fn moving_an_entry_recomputes_the_ans_chain() {
let mut service = service();
service.submit("10");
service.submit("ans + 5"); service.submit("ans * 2"); let new_index = service.move_entry(2, -1);
assert_eq!(new_index, 1);
assert_eq!(value_at(&service, 1), Some(20.0)); assert_eq!(value_at(&service, 2), Some(25.0)); }
#[test]
fn inserting_a_blank_entry_shifts_and_recomputes() {
let mut service = service();
service.submit("10");
service.submit("ans + 5"); service.insert_entry(1);
assert_eq!(service.history().len(), 3);
assert_eq!(service.history().entries()[1].input, "");
assert_eq!(value_at(&service, 2), Some(15.0));
}
#[test]
fn preview_ignores_comments() {
let mut service = service();
service.submit("2");
assert_eq!(service.preview("# note"), Preview::Empty);
assert_eq!(service.preview("ans+3 # sum"), val(5.0));
}
#[test]
fn converts_a_quantity_with_the_arrow() {
let mut service = service();
let outcome = service.submit("123 MPa -> bar");
let quantity = outcome.value.unwrap();
assert_eq!(quantity.unit_symbol(), Some("bar"));
assert!((quantity.display_value() - 1230.0).abs() < 1e-6);
}
#[test]
fn stores_a_quantity_variable_and_converts_it() {
let mut service = service();
service.submit("x = 50 kN");
assert_eq!(
service.variables().get("x").unwrap().unit_symbol(),
Some("kN")
);
let outcome = service.submit("x -> N");
let quantity = outcome.value.unwrap();
assert_eq!(quantity.unit_symbol(), Some("N"));
assert!((quantity.display_value() - 50_000.0).abs() < 1e-9);
}
#[test]
fn ans_carries_its_unit_into_a_conversion() {
let mut service = service();
service.submit("2 bar");
let outcome = service.submit("ans -> Pa");
let quantity = outcome.value.unwrap();
assert_eq!(quantity.unit_symbol(), Some("Pa"));
assert!((quantity.display_value() - 200_000.0).abs() < 1e-6);
}
#[test]
fn compound_and_volume_conversions_work() {
let mut service = service();
let litre = service.submit("1 l -> dm^3");
assert!((outval(&litre).unwrap() - 1.0).abs() < 1e-9);
let speed = service.submit("100 km/h -> m/s");
assert!((outval(&speed).unwrap() - 27.7777778).abs() < 1e-6);
assert_eq!(speed.value.unwrap().unit_symbol(), Some("m/s"));
}
#[test]
fn adding_compatible_units_auto_picks_a_unit() {
let mut service = service();
let outcome = service.submit("1 m + 50 cm");
assert!((outval(&outcome).unwrap() - 1.5).abs() < 1e-9);
assert_eq!(outcome.value.unwrap().unit_symbol(), Some("m"));
}
#[test]
fn addition_with_units_picks_a_single_unit() {
let mut service = service();
let outcome = service.submit("20 kN + 300 N");
let quantity = outcome.value.unwrap();
assert!((quantity.display_value() - 20.3).abs() < 1e-9);
assert_eq!(quantity.unit_symbol(), Some("kN"));
}
#[test]
fn multiplying_quantities_yields_a_derived_unit() {
let mut service = service();
let outcome = service.submit("1 m * 2 m");
let quantity = outcome.value.unwrap();
assert!((quantity.display_value() - 2.0).abs() < 1e-9);
assert_eq!(quantity.unit_symbol(), Some("m^2"));
}
#[test]
fn dividing_quantities_pins_the_conversion_target() {
let mut service = service();
let outcome = service.submit("2 kN / 4 m^2 -> kN/m^2");
let quantity = outcome.value.unwrap();
assert!((quantity.display_value() - 0.5).abs() < 1e-9);
assert_eq!(quantity.unit_symbol(), Some("kN/m^2"));
}
#[test]
fn unit_arithmetic_with_a_unit_variable_routes_to_rink() {
let mut service = service();
service.submit("f = 20 kN");
let outcome = service.submit("f + 300 N");
let quantity = outcome.value.unwrap();
assert!((quantity.display_value() - 20.3).abs() < 1e-9);
}
#[test]
fn an_incompatible_conversion_errors() {
let mut service = service();
let outcome = service.submit("5 N -> bar");
assert!(outcome.error.is_some());
}
#[test]
fn pure_math_is_unaffected_by_the_units_router() {
let mut service = service();
assert!((outval(&service.submit("e^0")).unwrap() - 1.0).abs() < 1e-9);
service.toggle_angle_mode();
let outcome = service.submit("sin(90)");
assert!((outval(&outcome).unwrap() - 1.0).abs() < 1e-9);
}
#[test]
fn every_failure_mode_keeps_its_message() {
let cases: &[(&[&str], &str)] = &[
(&["ans"], "no previous answer"),
(&["ans -> m"], "no previous answer"),
(&["=x"], "no previous answer to save"),
(&["1", "=1bad"], "invalid variable name: '1bad'"),
(&["1", "=pi"], "'pi' is reserved and cannot be a variable"),
(
&["2+"],
"cannot evaluate: Parse error: Missing argument at the end of \
expression.",
),
(
&["5 N -> bar"],
"Conformance error: 5 newton (force) != 100 kilopascal \
(pressure)",
),
];
for (lines, expected) in cases {
let mut calc = service();
let mut outcome = None;
for line in *lines {
outcome = Some(calc.submit(line));
}
let error = outcome.expect("a submitted line").error;
assert_eq!(error.as_deref(), Some(*expected), "for {lines:?}");
}
}
#[test]
fn a_rink_message_is_not_prefixed_like_a_meval_one() {
let rink = service().submit("1 foounit -> m").error.expect("an error");
assert!(rink.starts_with("No such unit"), "{rink}");
let meval = service().submit("2+").error.expect("an error");
assert!(meval.starts_with("cannot evaluate: "), "{meval}");
}
}