use crate::repl::{parse_value, value_completions};
use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write as _;
use std::io::{self, BufRead, Write};
use tess::ast::{DecisionDecl, FieldDecl, TypeRef};
use tess::compiler::{normalize_name, render_range_constraint};
use tess::engine::{DecisionStatus, Evaluation, Input, Query, evaluate_query};
use tess::value::Value;
use tess::{CompiledProgram, Diagnostic};
pub(crate) enum Outcome {
Completed {
input: Input,
evaluation: Evaluation,
},
Cancelled,
}
#[derive(Debug)]
pub(crate) enum QuestionnaireError {
Io(io::Error),
Usage(String),
Language(Diagnostic),
}
impl From<io::Error> for QuestionnaireError {
fn from(error: io::Error) -> Self {
Self::Io(error)
}
}
pub(crate) fn run(
program: &CompiledProgram,
query: Option<Query>,
preferred_decision: Option<&str>,
) -> Result<Outcome, QuestionnaireError> {
let stdin = io::stdin();
let stderr = io::stderr();
run_with_io(
program,
query,
preferred_decision,
&mut stdin.lock(),
&mut stderr.lock(),
)
}
fn run_with_io<R: BufRead, W: Write>(
program: &CompiledProgram,
query: Option<Query>,
preferred_decision: Option<&str>,
reader: &mut R,
writer: &mut W,
) -> Result<Outcome, QuestionnaireError> {
let Some(query) = select_query(program, query, preferred_decision, reader, writer)? else {
return Ok(Outcome::Cancelled);
};
let mut input = empty_input(program, &query)?;
let mut skipped = BTreeSet::new();
writeln!(writer, "interactive ยท {}", query.render())?;
writeln!(
writer,
"Enter known facts. Press Enter or `?` to leave one unknown; type `:quit` to stop."
)?;
loop {
let evaluation =
evaluate_query(program, &input, &query).map_err(QuestionnaireError::Language)?;
let DecisionStatus::Unknown { missing } = &evaluation.result.status else {
return Ok(Outcome::Completed { input, evaluation });
};
let missing = missing.iter().cloned().collect::<BTreeSet<_>>();
let Some(question) = next_missing_question(program, &query, &missing, &skipped)? else {
return Ok(Outcome::Completed { input, evaluation });
};
match collect_question(program, &mut input, &question, reader, writer)? {
Collected::Answered => {}
Collected::Skip => {
skipped.insert(question.normalized_path());
}
Collected::Eof => {
return Ok(Outcome::Completed { input, evaluation });
}
Collected::Cancel => return Ok(Outcome::Cancelled),
}
}
}
fn select_query<R: BufRead, W: Write>(
program: &CompiledProgram,
query: Option<Query>,
preferred_decision: Option<&str>,
reader: &mut R,
writer: &mut W,
) -> Result<Option<Query>, QuestionnaireError> {
if let Some(query) = query {
validate_query_shape(program, &query)?;
return Ok(Some(query));
}
let decisions = runnable_decisions(program);
if decisions.len() > 1 {
let preferred = preferred_decision.and_then(|preferred| {
decisions
.iter()
.copied()
.find(|decision| normalize_name(&decision.name.value) == normalize_name(preferred))
});
if let Some(decision) = preferred {
return Ok(Some(query_for_decision(decision)));
}
}
match decisions.as_slice() {
[] => Err(QuestionnaireError::Usage(
"no public decision is available for interactive input".to_owned(),
)),
[decision] => Ok(Some(query_for_decision(decision))),
_ => {
writeln!(writer, "Select a decision:")?;
for (index, decision) in decisions.iter().enumerate() {
writeln!(
writer,
" {}. {}",
index + 1,
query_for_decision(decision).render()
)?;
}
loop {
write!(writer, "> ")?;
writer.flush()?;
let Some(line) = read_line(reader)? else {
return Ok(None);
};
let answer = line.trim();
if answer == ":quit" {
return Ok(None);
}
if let Ok(index) = answer.parse::<usize>() {
if let Some(decision) =
index.checked_sub(1).and_then(|index| decisions.get(index))
{
return Ok(Some(query_for_decision(decision)));
}
}
if let Some(decision) = decisions
.iter()
.find(|decision| normalize_name(&decision.name.value) == normalize_name(answer))
{
return Ok(Some(query_for_decision(decision)));
}
writeln!(
writer,
"invalid choice; enter a number from 1 to {} or a decision name",
decisions.len()
)?;
}
}
}
}
fn runnable_decisions(program: &CompiledProgram) -> Vec<&DecisionDecl> {
program
.decisions()
.values()
.filter(|decision| {
!decision.name.value.starts_with("@pkg_")
&& decision
.parameters
.iter()
.all(|parameter| program.entity(¶meter.ty.value).is_some())
})
.collect()
}
fn query_for_decision(decision: &DecisionDecl) -> Query {
Query {
decision: decision.name.value.clone(),
arguments: decision
.parameters
.iter()
.map(|parameter| parameter.name.value.clone())
.collect(),
}
}
fn validate_query_shape(
program: &CompiledProgram,
query: &Query,
) -> Result<(), QuestionnaireError> {
let decision = program.decision(&query.decision).ok_or_else(|| {
QuestionnaireError::Usage(format!("decision `{}` was not found", query.decision))
})?;
if decision.parameters.len() != query.arguments.len() {
return Err(QuestionnaireError::Usage(format!(
"decision `{}` expects {} argument(s), but {} were supplied",
query.decision,
decision.parameters.len(),
query.arguments.len()
)));
}
if let Some(parameter) = decision
.parameters
.iter()
.find(|parameter| program.entity(¶meter.ty.value).is_none())
{
return Err(QuestionnaireError::Usage(format!(
"interactive input requires record parameters; `{}` has type `{}`",
parameter.name.value, parameter.ty.value
)));
}
Ok(())
}
fn empty_input(program: &CompiledProgram, query: &Query) -> Result<Input, QuestionnaireError> {
validate_query_shape(program, query)?;
let decision = program
.decision(&query.decision)
.expect("query shape validation found the decision");
let mut input = Input::new();
for (argument, parameter) in query.arguments.iter().zip(&decision.parameters) {
input.insert(argument, ¶meter.ty.value, BTreeMap::new());
}
Ok(input)
}
#[derive(Clone)]
struct Question {
binding: String,
field: FieldDecl,
}
impl Question {
fn normalized_path(&self) -> String {
format!(
"{}.{}",
normalize_name(&self.binding),
normalize_name(&self.field.name.value)
)
}
fn display_path(&self) -> String {
format!("{}.{}", self.binding, self.field.name.value)
}
}
fn next_missing_question(
program: &CompiledProgram,
query: &Query,
missing: &BTreeSet<String>,
skipped: &BTreeSet<String>,
) -> Result<Option<Question>, QuestionnaireError> {
Ok(questions(program, query)?.into_iter().find(|question| {
let path = question.normalized_path();
missing.contains(&path) && !skipped.contains(&path)
}))
}
fn questions(
program: &CompiledProgram,
query: &Query,
) -> Result<Vec<Question>, QuestionnaireError> {
let decision = program.decision(&query.decision).ok_or_else(|| {
QuestionnaireError::Usage(format!("decision `{}` was not found", query.decision))
})?;
let mut questions = Vec::new();
for (argument, parameter) in query.arguments.iter().zip(&decision.parameters) {
let record = program.entity(¶meter.ty.value).ok_or_else(|| {
QuestionnaireError::Usage(format!("`{}` is not a record type", parameter.ty.value))
})?;
questions.extend(record.fields.iter().cloned().map(|field| Question {
binding: argument.clone(),
field,
}));
}
Ok(questions)
}
enum Answer {
Value(Value),
Skip,
Eof,
Cancel,
}
enum Collected {
Answered,
Skip,
Eof,
Cancel,
}
fn collect_question<R: BufRead, W: Write>(
program: &CompiledProgram,
input: &mut Input,
question: &Question,
reader: &mut R,
writer: &mut W,
) -> Result<Collected, QuestionnaireError> {
loop {
match ask_question(program, question, reader, writer)? {
Answer::Value(value) => match set_value(program, input, question, value) {
Ok(()) => return Ok(Collected::Answered),
Err(diagnostic) => writeln!(writer, "invalid value: {}", diagnostic.message)?,
},
Answer::Skip => return Ok(Collected::Skip),
Answer::Eof => return Ok(Collected::Eof),
Answer::Cancel => return Ok(Collected::Cancel),
}
}
}
fn ask_question<R: BufRead, W: Write>(
program: &CompiledProgram,
question: &Question,
reader: &mut R,
writer: &mut W,
) -> Result<Answer, QuestionnaireError> {
let choices = finite_choices(program, &question.field);
loop {
writeln!(
writer,
"\n{} ({}){}",
question.display_path(),
field_type(&question.field),
if question.field.optional {
" [optional]"
} else {
""
}
)?;
for (index, choice) in choices.iter().enumerate() {
writeln!(writer, " {}. {choice}", index + 1)?;
}
write!(writer, "> ")?;
writer.flush()?;
let Some(line) = read_line(reader)? else {
return Ok(Answer::Eof);
};
let answer = line.trim();
if answer.is_empty() || answer == "?" {
return Ok(Answer::Skip);
}
if answer == ":quit" {
return Ok(Answer::Cancel);
}
let selected = answer
.parse::<usize>()
.ok()
.and_then(|index| index.checked_sub(1))
.and_then(|index| choices.get(index))
.map_or(answer, String::as_str);
let selected = interactive_literal(&question.field.ty, selected);
match parse_value(program, &question.field.ty, &selected) {
Ok(value) => return Ok(Answer::Value(value)),
Err(message) => writeln!(writer, "invalid value: {message}")?,
}
}
}
fn finite_choices(program: &CompiledProgram, field: &FieldDecl) -> Vec<String> {
if field.domain.is_some()
|| matches!(field.ty, TypeRef::Bool)
|| matches!(&field.ty, TypeRef::Named(name) if program.enum_decl(name).is_some())
{
value_completions(program, field)
} else {
Vec::new()
}
}
fn interactive_literal(ty: &TypeRef, value: &str) -> String {
match ty {
TypeRef::Bool => match value.to_ascii_lowercase().as_str() {
"y" | "yes" => "O".to_owned(),
"n" | "no" => "X".to_owned(),
_ => value.to_owned(),
},
TypeRef::String | TypeRef::Date if !value.starts_with('"') => {
serde_json::to_string(value).expect("serializing a string cannot fail")
}
_ => value.to_owned(),
}
}
fn set_value(
program: &CompiledProgram,
input: &mut Input,
question: &Question,
value: Value,
) -> Result<(), Diagnostic> {
let binding = normalize_name(&question.binding);
let field = normalize_name(&question.field.name.value);
let previous = input
.bindings
.get_mut(&binding)
.expect("interactive input created every query binding")
.fields
.insert(field.clone(), value);
if let Some(diagnostic) = input.validate(program).into_iter().next() {
let fields = &mut input
.bindings
.get_mut(&binding)
.expect("interactive input created every query binding")
.fields;
if let Some(previous) = previous {
fields.insert(field, previous);
} else {
fields.remove(&field);
}
return Err(diagnostic);
}
Ok(())
}
fn field_type(field: &FieldDecl) -> String {
let mut label = match &field.ty {
TypeRef::Bool => "Bool".to_owned(),
TypeRef::Int => "Int".to_owned(),
TypeRef::Decimal => "Decimal".to_owned(),
TypeRef::String => "String".to_owned(),
TypeRef::Date => "Date".to_owned(),
TypeRef::Duration => "Duration".to_owned(),
TypeRef::Named(name) => name.clone(),
TypeRef::Unknown => "unknown".to_owned(),
};
if let Some(range) = &field.range {
let _ = write!(label, " {}", render_range_constraint(range));
}
label
}
fn read_line(reader: &mut impl BufRead) -> Result<Option<String>, io::Error> {
let mut line = String::new();
if reader.read_line(&mut line)? == 0 {
Ok(None)
} else {
Ok(Some(line))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
use tess::{SourceFile, compile_source};
fn program(source: &str) -> CompiledProgram {
let output = compile_source(SourceFile::new("questionnaire.tes", source));
assert!(!output.has_errors(), "{:?}", output.diagnostics);
output.program.unwrap()
}
#[test]
fn asks_only_fields_that_can_still_change_the_result() {
let program = program(
r"mod guided
enum Result:
Yes
No
record Request:
active: Bool
detail: Bool?
unused: Bool
rule yes:
active & detail: outcome = Yes
rule no:
active X: outcome = No
",
);
let mut reader = Cursor::new(b"X\n".to_vec());
let mut writer = Vec::new();
let outcome = run_with_io(&program, None, None, &mut reader, &mut writer).unwrap();
let Outcome::Completed { evaluation, .. } = outcome else {
panic!("questionnaire was cancelled");
};
assert_eq!(
evaluation.result.status.single_value().unwrap().to_string(),
"Result.No"
);
let transcript = String::from_utf8(writer).unwrap();
assert!(transcript.contains("Request.active"), "{transcript}");
assert!(!transcript.contains("Request.detail"), "{transcript}");
assert!(!transcript.contains("Request.unused"), "{transcript}");
}
#[test]
fn accepts_a_direct_judgment_or_an_explicit_drill_down_route() {
let program = program(
r"mod drill_down
enum Result:
Yes
No
enum Route:
DirectYes
DirectNo
Details
record Request:
route: Route
observed: Bool
audience: Int [0, 100]
rule direct_yes:
route = DirectYes: outcome = Yes
rule direct_no:
route = DirectNo: outcome = No
rule detailed_yes:
route = Details & observed & audience >= 2: outcome = Yes
rule detailed_no:
route = Details & (observed X | audience < 2): outcome = No
",
);
let mut direct_reader = Cursor::new(b"1\n".to_vec());
let mut direct_writer = Vec::new();
let outcome =
run_with_io(&program, None, None, &mut direct_reader, &mut direct_writer).unwrap();
let Outcome::Completed { evaluation, .. } = outcome else {
panic!("questionnaire was cancelled");
};
assert!(evaluation.result.status.is_resolved());
let transcript = String::from_utf8(direct_writer).unwrap();
assert!(transcript.contains("Request.route"), "{transcript}");
assert!(!transcript.contains("Request.observed"), "{transcript}");
assert!(!transcript.contains("Request.audience"), "{transcript}");
let mut detailed_reader = Cursor::new(b"3\nO\n2\n".to_vec());
let mut detailed_writer = Vec::new();
let outcome = run_with_io(
&program,
None,
None,
&mut detailed_reader,
&mut detailed_writer,
)
.unwrap();
let Outcome::Completed { evaluation, .. } = outcome else {
panic!("questionnaire was cancelled");
};
assert!(evaluation.result.status.is_resolved());
let transcript = String::from_utf8(detailed_writer).unwrap();
assert!(transcript.contains("Request.route"), "{transcript}");
assert!(transcript.contains("Request.observed"), "{transcript}");
assert!(transcript.contains("Request.audience"), "{transcript}");
}
#[test]
fn skip_and_eof_return_the_current_unknown_evaluation() {
let program = program(
r"mod partial
enum Result:
Yes
record Request:
active: Bool
rule yes:
active: outcome = Yes
",
);
for answer in [b"\n".as_slice(), b"?\n".as_slice(), b"".as_slice()] {
let mut reader = Cursor::new(answer.to_vec());
let mut writer = Vec::new();
let outcome = run_with_io(&program, None, None, &mut reader, &mut writer).unwrap();
let Outcome::Completed { evaluation, .. } = outcome else {
panic!("questionnaire was cancelled");
};
assert!(matches!(
evaluation.result.status,
DecisionStatus::Unknown { ref missing }
if missing == &["Request.active".to_owned()]
));
let transcript = String::from_utf8(writer).unwrap();
assert_eq!(transcript.matches("Request.active (Bool)").count(), 1);
}
}
#[test]
fn quit_cancels_without_an_evaluation_result() {
let program = program(
r"mod cancel
enum Result:
Yes
record Request:
active: Bool
rule yes:
active: outcome = Yes
",
);
let mut reader = Cursor::new(b":quit\n".to_vec());
let mut writer = Vec::new();
let outcome = run_with_io(&program, None, None, &mut reader, &mut writer).unwrap();
assert!(matches!(outcome, Outcome::Cancelled));
}
#[test]
fn invalid_range_value_is_rejected_and_retried() {
let program = program(
r"mod retry
enum Result:
High
Low
record Request:
score: Int [0, 10]
rule high:
score >= 5: outcome = High
rule low:
score < 5: outcome = Low
",
);
let mut reader = Cursor::new(b"11\n7\n".to_vec());
let mut writer = Vec::new();
let outcome = run_with_io(&program, None, None, &mut reader, &mut writer).unwrap();
let Outcome::Completed { evaluation, .. } = outcome else {
panic!("questionnaire was cancelled");
};
assert_eq!(
evaluation.result.status.single_value().unwrap().to_string(),
"Result.High"
);
let transcript = String::from_utf8(writer).unwrap();
assert!(transcript.contains("outside the range"), "{transcript}");
assert_eq!(transcript.matches("Request.score (Int [0, 10])").count(), 2);
}
#[test]
fn package_private_decisions_are_not_interactive_endpoints() {
let mut program = program(
r"mod visibility
record Request:
active: Bool
rule visible_rule:
active: visible = O
rule hidden_rule:
active: hidden = O
",
);
program
.decisions
.get_mut("hidden")
.expect("hidden decision")
.name
.value = "@pkg_shared.hidden".to_owned();
let decisions = runnable_decisions(&program)
.into_iter()
.map(|decision| decision.name.value.as_str())
.collect::<Vec<_>>();
assert_eq!(decisions, ["visible"]);
}
#[test]
fn multiple_record_parameters_are_collected_in_declaration_order() {
let program = program(
r"mod multiple
enum Result:
Yes
No
record Left:
enabled: Bool
record Right:
enabled: Bool
rule yes(left Left, right Right):
left.enabled & right.enabled: outcome(left, right) = Yes
rule no(left Left, right Right):
left.enabled X | right.enabled X: outcome(left, right) = No
",
);
let mut reader = Cursor::new(b"O\nX\n".to_vec());
let mut writer = Vec::new();
let outcome = run_with_io(&program, None, None, &mut reader, &mut writer).unwrap();
let Outcome::Completed { input, evaluation } = outcome else {
panic!("questionnaire was cancelled");
};
assert_eq!(
evaluation.result.status.single_value().unwrap().to_string(),
"Result.No"
);
assert_eq!(input.bindings["left"].fields["enabled"], Value::Bool(true));
assert_eq!(
input.bindings["right"].fields["enabled"],
Value::Bool(false)
);
let transcript = String::from_utf8(writer).unwrap();
assert!(
transcript.find("left.enabled").unwrap() < transcript.find("right.enabled").unwrap(),
"{transcript}"
);
}
#[test]
fn zero_argument_decision_finishes_without_a_field_prompt() {
let program = program(
r"mod constant
enum Result:
Yes
rule yes:
: outcome = Yes
",
);
let mut reader = Cursor::new(Vec::new());
let mut writer = Vec::new();
let outcome = run_with_io(&program, None, None, &mut reader, &mut writer).unwrap();
let Outcome::Completed { evaluation, .. } = outcome else {
panic!("questionnaire was cancelled");
};
assert!(evaluation.result.status.is_resolved());
let transcript = String::from_utf8(writer).unwrap();
assert!(transcript.contains("interactive ยท outcome"), "{transcript}");
assert!(!transcript.contains("\n> "), "{transcript}");
}
#[test]
fn program_without_a_decision_reports_a_usage_error() {
let program = program(
r"mod no_decision
record Request:
active: Bool
",
);
let mut reader = Cursor::new(Vec::new());
let mut writer = Vec::new();
let Err(error) = run_with_io(&program, None, None, &mut reader, &mut writer) else {
panic!("questionnaire unexpectedly found a decision");
};
assert!(matches!(
error,
QuestionnaireError::Usage(message)
if message.contains("no public decision")
));
}
#[test]
fn numbered_finite_domain_value_is_validated_and_applied() {
let program = program(
r"mod domain
enum Result:
Standard
Elevated
record Request:
rate: Decimal {0.10, 0.20}
rule elevated:
rate = 0.20: outcome = Elevated
rule standard:
rate != 0.20: outcome = Standard
",
);
let mut reader = Cursor::new(b"2\n".to_vec());
let mut writer = Vec::new();
let outcome = run_with_io(&program, None, None, &mut reader, &mut writer).unwrap();
let Outcome::Completed { evaluation, .. } = outcome else {
panic!("questionnaire was cancelled");
};
assert_eq!(
evaluation.result.status.single_value().unwrap().to_string(),
"Result.Elevated"
);
let transcript = String::from_utf8(writer).unwrap();
assert!(transcript.contains("1. 0.10"), "{transcript}");
assert!(transcript.contains("2. 0.20"), "{transcript}");
}
#[test]
fn selects_a_decision_and_accepts_numbered_enum_values() {
let program = program(
r"mod choices
enum Kind:
A
B
enum Result:
Yes
record Request:
kind: Kind
rule first_a:
kind = A: first = Yes
rule second_b:
kind = B: second = Yes
",
);
let mut reader = Cursor::new(b"second\n2\n".to_vec());
let mut writer = Vec::new();
let outcome = run_with_io(&program, None, None, &mut reader, &mut writer).unwrap();
let Outcome::Completed { evaluation, .. } = outcome else {
panic!("questionnaire was cancelled");
};
assert!(evaluation.query.starts_with("second("));
assert!(evaluation.result.status.is_resolved());
let transcript = String::from_utf8(writer).unwrap();
assert!(transcript.contains("Select a decision"), "{transcript}");
assert!(transcript.contains("2. B"), "{transcript}");
let mut reader = Cursor::new(b"2\n".to_vec());
let mut writer = Vec::new();
let outcome =
run_with_io(&program, None, Some("second"), &mut reader, &mut writer).unwrap();
let Outcome::Completed { evaluation, .. } = outcome else {
panic!("questionnaire was cancelled");
};
assert!(evaluation.query.starts_with("second("));
let transcript = String::from_utf8(writer).unwrap();
assert!(!transcript.contains("Select a decision"), "{transcript}");
}
}