use crate::repl::{parse_value, value_completions};
use rustyline::completion::{Completer, Pair};
use rustyline::error::ReadlineError;
use rustyline::highlight::Highlighter;
use rustyline::history::DefaultHistory;
use rustyline::validate::Validator;
use rustyline::{
Behavior, Cmd, CompletionType, ConditionalEventHandler, Config, Context, Editor, Event,
EventContext, EventHandler, Helper, KeyCode, KeyEvent, Modifiers, Movement, RepeatCount,
};
use std::borrow::Cow;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write as _;
use std::io::{self, BufRead, IsTerminal, Write};
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::{Arc, RwLock};
use tess::ast::{DecisionDecl, FieldDecl, TypeRef};
use tess::compiler::{normalize_name, render_range_constraint};
use tess::engine::{DecisionStatus, Evaluation, Input, Query, RuleOutcome, 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();
if stdin.is_terminal() && stderr.is_terminal() {
run_terminal(program, query, preferred_decision, &mut stderr.lock())
} else {
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 session = QuestionnaireSession::new(program, query)?;
writeln!(writer, "interactive · {}", session.query.render())?;
writeln!(
writer,
"Dynamic path: shared facts first, branch details only when needed."
)?;
writeln!(
writer,
"Enter/`?` defer · :back/:forward navigate · :tree inspect · :done finish · :help"
)?;
loop {
let evaluation = session.evaluate()?;
let Some(ranked) = session.current_or_next(&evaluation) else {
return Ok(session.completed(evaluation));
};
render_question_card(&session, &evaluation, &ranked, writer, false)?;
match read_fallback_action(&session, &ranked.question, reader, writer)? {
Action::Value(value) => {
if let Err(diagnostic) = session.answer(&ranked.question, value) {
writeln!(writer, "invalid value: {}", diagnostic.message)?;
}
}
Action::Keep => session.advance_without_change(),
Action::Skip => session.skip(&ranked.question),
Action::Back => {
if !session.back() {
writeln!(writer, "already at the first visited fact")?;
}
}
Action::Forward => {
if !session.forward() {
writeln!(writer, "already at the current decision frontier")?;
}
}
Action::Tree => render_tree(&session, &evaluation, writer)?,
Action::Review => render_review(&session, writer)?,
Action::Goto(path) => {
if !session.goto(&path) {
if let Some(requirement) = session.goto_requirement(&path) {
writeln!(writer, "{requirement}")?;
} else {
writeln!(
writer,
"unknown fact `{path}`; use :review to list fact paths"
)?;
}
}
}
Action::Clear => session.clear(&ranked.question),
Action::Help => render_help(writer)?,
Action::Done | Action::Eof => return Ok(session.completed(evaluation)),
Action::Cancel => return Ok(Outcome::Cancelled),
Action::Invalid(message) => writeln!(writer, "invalid input: {message}")?,
}
}
}
const SIGNAL_NONE: u8 = 0;
const SIGNAL_BACK: u8 = 1;
const SIGNAL_FORWARD: u8 = 2;
const SIGNAL_TREE: u8 = 3;
const SIGNAL_HELP: u8 = 4;
const QUESTION_COMMANDS: &[&str] = &[
":back", ":forward", ":tree", ":review", ":goto ", ":clear", ":done", ":help", ":quit",
];
#[derive(Default)]
struct CompletionData {
choices: Vec<String>,
fields: Vec<String>,
}
struct QuestionnaireHelper {
data: Arc<RwLock<CompletionData>>,
}
impl Helper for QuestionnaireHelper {}
impl Validator for QuestionnaireHelper {}
impl Highlighter for QuestionnaireHelper {
fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> {
Cow::Borrowed(hint)
}
}
impl rustyline::hint::Hinter for QuestionnaireHelper {
type Hint = String;
}
impl Completer for QuestionnaireHelper {
type Candidate = Pair;
fn complete(
&self,
line: &str,
pos: usize,
_context: &Context<'_>,
) -> rustyline::Result<(usize, Vec<Self::Candidate>)> {
let before = &line[..pos];
let data = self
.data
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(prefix) = before.strip_prefix(":goto ") {
return Ok((":goto ".len(), completion_pairs(prefix, &data.fields)));
}
if before.starts_with(':') {
let commands = QUESTION_COMMANDS
.iter()
.map(|command| (*command).to_owned())
.collect::<Vec<_>>();
return Ok((0, completion_pairs(before, &commands)));
}
Ok((0, completion_pairs(before, &data.choices)))
}
}
fn completion_pairs(prefix: &str, candidates: &[String]) -> Vec<Pair> {
candidates
.iter()
.filter(|candidate| candidate.starts_with(prefix))
.map(|candidate| Pair {
display: candidate.clone(),
replacement: candidate.clone(),
})
.collect()
}
struct ChoiceCycleHandler {
data: Arc<RwLock<CompletionData>>,
direction: i8,
}
impl ConditionalEventHandler for ChoiceCycleHandler {
fn handle(
&self,
_event: &Event,
_count: RepeatCount,
_positive: bool,
context: &EventContext<'_>,
) -> Option<Cmd> {
if context.line().starts_with(':') {
return None;
}
let data = self
.data
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if data.choices.is_empty() {
return None;
}
let choice = cycle_choice(&data.choices, context.line(), self.direction)?;
Some(Cmd::Replace(Movement::WholeBuffer, Some(choice.to_owned())))
}
}
fn cycle_choice<'a>(choices: &'a [String], current: &str, direction: i8) -> Option<&'a str> {
if choices.is_empty() {
return None;
}
let current = choices.iter().position(|choice| choice == current);
let index = match (current, direction.is_positive()) {
(Some(index), true) => (index + 1) % choices.len(),
(Some(0) | None, false) => choices.len() - 1,
(Some(index), false) => index - 1,
(None, true) => 0,
};
choices.get(index).map(String::as_str)
}
struct SignalHandler {
signal: Arc<AtomicU8>,
value: u8,
}
impl ConditionalEventHandler for SignalHandler {
fn handle(
&self,
_event: &Event,
_count: RepeatCount,
_positive: bool,
_context: &EventContext<'_>,
) -> Option<Cmd> {
self.signal.store(self.value, Ordering::Release);
Some(Cmd::Interrupt)
}
}
type QuestionnaireEditor = Editor<QuestionnaireHelper, DefaultHistory>;
type TerminalEditorState = (
QuestionnaireEditor,
Arc<RwLock<CompletionData>>,
Arc<AtomicU8>,
);
fn terminal_editor() -> Result<TerminalEditorState, QuestionnaireError> {
let config = Config::builder()
.behavior(Behavior::PreferTerm)
.completion_type(CompletionType::Circular)
.history_ignore_space(true)
.auto_add_history(true)
.build();
let mut editor = QuestionnaireEditor::with_config(config).map_err(readline_failure)?;
let data = Arc::new(RwLock::new(CompletionData::default()));
let signal = Arc::new(AtomicU8::new(SIGNAL_NONE));
editor.set_helper(Some(QuestionnaireHelper { data: data.clone() }));
editor.bind_sequence(
KeyEvent(KeyCode::Down, Modifiers::NONE),
EventHandler::Conditional(Box::new(ChoiceCycleHandler {
data: data.clone(),
direction: 1,
})),
);
editor.bind_sequence(
KeyEvent(KeyCode::Up, Modifiers::NONE),
EventHandler::Conditional(Box::new(ChoiceCycleHandler {
data: data.clone(),
direction: -1,
})),
);
for key in [
KeyEvent(KeyCode::Left, Modifiers::ALT),
KeyEvent(KeyCode::PageUp, Modifiers::NONE),
] {
editor.bind_sequence(
key,
EventHandler::Conditional(Box::new(SignalHandler {
signal: signal.clone(),
value: SIGNAL_BACK,
})),
);
}
for key in [
KeyEvent(KeyCode::Right, Modifiers::ALT),
KeyEvent(KeyCode::PageDown, Modifiers::NONE),
] {
editor.bind_sequence(
key,
EventHandler::Conditional(Box::new(SignalHandler {
signal: signal.clone(),
value: SIGNAL_FORWARD,
})),
);
}
editor.bind_sequence(
KeyEvent::ctrl('T'),
EventHandler::Conditional(Box::new(SignalHandler {
signal: signal.clone(),
value: SIGNAL_TREE,
})),
);
editor.bind_sequence(
KeyEvent(KeyCode::F(1), Modifiers::NONE),
EventHandler::Conditional(Box::new(SignalHandler {
signal: signal.clone(),
value: SIGNAL_HELP,
})),
);
Ok((editor, data, signal))
}
fn run_terminal(
program: &CompiledProgram,
query: Option<Query>,
preferred_decision: Option<&str>,
writer: &mut impl Write,
) -> Result<Outcome, QuestionnaireError> {
let (mut editor, completion_data, signal) = terminal_editor()?;
let Some(query) = select_query_terminal(
program,
query,
preferred_decision,
&mut editor,
&completion_data,
&signal,
writer,
)?
else {
return Ok(Outcome::Cancelled);
};
let mut session = QuestionnaireSession::new(program, query)?;
writeln!(writer, "interactive · {}", session.query.render())?;
writeln!(
writer,
"Dynamic DAG: shared facts first; choices preview the next branch. F1 shows all controls."
)?;
loop {
let evaluation = session.evaluate()?;
if let Some(ranked) = session.current_or_next(&evaluation) {
render_question_card(&session, &evaluation, &ranked, writer, true)?;
writer.flush()?;
let navigable = session.navigable_questions();
configure_completion(
&completion_data,
finite_choices(program, &ranked.question.field),
&navigable,
);
signal.store(SIGNAL_NONE, Ordering::Release);
let initial = session
.current_value(&ranked.question)
.map(input_literal)
.unwrap_or_default();
let action = match editor.readline_with_initial("> ", (&initial, "")) {
Ok(line) => parse_action(&session, &ranked.question, line.trim()),
Err(ReadlineError::Interrupted) => signal_action(&signal),
Err(ReadlineError::Eof) => Action::Eof,
Err(error) => return Err(readline_failure(error)),
};
match action {
Action::Value(value) => {
if let Err(diagnostic) = session.answer(&ranked.question, value) {
writeln!(writer, "invalid value: {}", diagnostic.message)?;
}
}
Action::Keep => session.advance_without_change(),
Action::Skip => session.skip(&ranked.question),
Action::Back => {
if !session.back() {
writeln!(writer, "already at the first visited fact")?;
}
}
Action::Forward => {
if !session.forward() {
writeln!(writer, "already at the current decision frontier")?;
}
}
Action::Tree => render_tree(&session, &evaluation, writer)?,
Action::Review => render_review(&session, writer)?,
Action::Goto(path) => {
if !session.goto(&path) {
writeln!(
writer,
"{}",
session.goto_requirement(&path).unwrap_or_else(|| {
format!("unknown or ambiguous fact `{path}`")
})
)?;
}
}
Action::Clear => session.clear(&ranked.question),
Action::Help => render_help(writer)?,
Action::Done | Action::Eof => return Ok(session.completed(evaluation)),
Action::Cancel => return Ok(Outcome::Cancelled),
Action::Invalid(message) => writeln!(writer, "invalid input: {message}")?,
}
continue;
}
render_completion(&session, &evaluation, writer)?;
writer.flush()?;
let navigable = session.navigable_questions();
configure_completion(&completion_data, Vec::new(), &navigable);
signal.store(SIGNAL_NONE, Ordering::Release);
let action = match editor.readline("finish> ") {
Ok(line) => parse_completion_action(line.trim()),
Err(ReadlineError::Interrupted) => signal_action(&signal),
Err(ReadlineError::Eof) => Action::Done,
Err(error) => return Err(readline_failure(error)),
};
match action {
Action::Done | Action::Keep | Action::Eof | Action::Skip => {
return Ok(session.completed(evaluation));
}
Action::Back => {
if !session.back() {
writeln!(writer, "no visited fact to revisit")?;
}
}
Action::Forward => {
if !session.forward() {
writeln!(writer, "already at the result")?;
}
}
Action::Tree => render_tree(&session, &evaluation, writer)?,
Action::Review => render_review(&session, writer)?,
Action::Goto(path) => {
if !session.goto(&path) {
writeln!(
writer,
"{}",
session
.goto_requirement(&path)
.unwrap_or_else(|| { format!("unknown or ambiguous fact `{path}`") })
)?;
}
}
Action::Help => render_help(writer)?,
Action::Cancel => return Ok(Outcome::Cancelled),
Action::Value(_) | Action::Clear => {
writeln!(
writer,
"press Enter to finish or Alt-Left/:back to edit a fact"
)?;
}
Action::Invalid(message) => writeln!(writer, "invalid input: {message}")?,
}
}
}
fn configure_completion(
data: &Arc<RwLock<CompletionData>>,
choices: Vec<String>,
catalog: &[Question],
) {
let mut data = data
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
data.choices = choices;
data.fields = catalog.iter().map(Question::display_path).collect();
}
fn signal_action(signal: &AtomicU8) -> Action {
match signal.swap(SIGNAL_NONE, Ordering::AcqRel) {
SIGNAL_BACK => Action::Back,
SIGNAL_FORWARD => Action::Forward,
SIGNAL_TREE => Action::Tree,
SIGNAL_HELP => Action::Help,
_ => Action::Cancel,
}
}
fn parse_completion_action(answer: &str) -> Action {
match answer {
"" | ":done" | ":finish" => Action::Done,
":back" | ":prev" => Action::Back,
":forward" | ":next" => Action::Forward,
":tree" | ":why" => Action::Tree,
":review" => Action::Review,
":help" => Action::Help,
":quit" | ":cancel" => Action::Cancel,
_ if answer.starts_with(":goto ") => {
Action::Goto(answer[":goto ".len()..].trim().to_owned())
}
_ => Action::Invalid("press Enter to finish or use :back to edit".to_owned()),
}
}
fn render_completion(
session: &QuestionnaireSession<'_>,
evaluation: &Evaluation,
writer: &mut impl Write,
) -> io::Result<()> {
let progress = session.progress(evaluation);
writeln!(
writer,
"\n{} · {}",
status_mark(&evaluation.result.status),
session.query.render()
)?;
match &evaluation.result.status {
DecisionStatus::Resolved { values } => {
let rendered = values
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ");
writeln!(writer, "resolved: {rendered}")?;
}
DecisionStatus::Unknown { .. } => {
let remaining = progress.needed + progress.deferred;
let inaccessible = session.inaccessible_choice_facts(evaluation);
if remaining == 0 && !inaccessible.is_empty() {
writeln!(
writer,
"partial result · no actionable facts; {} choice dependenc{} inactive",
inaccessible.len(),
if inaccessible.len() == 1 {
"y is"
} else {
"ies are"
}
)?;
} else {
writeln!(
writer,
"partial result · {} fact{} still unknown ({} deferred)",
remaining,
if remaining == 1 { "" } else { "s" },
progress.deferred
)?;
}
for fact in inaccessible {
writeln!(
writer,
" {} requires {} = {} (currently {}); use :goto {}",
fact.path,
fact.selector_path,
fact.required_variant,
fact.selected_variant,
fact.selector_path
)?;
}
}
DecisionStatus::Undefined => writeln!(writer, "no rule produced a value")?,
DecisionStatus::Conflict { values, .. } => {
writeln!(writer, "conflict: {} value(s)", values.len())?;
}
}
writeln!(
writer,
"Enter finishes · Alt-Left/:back revisits the previous fact · :tree · :review"
)
}
const fn status_mark(status: &DecisionStatus) -> &'static str {
match status {
DecisionStatus::Resolved { .. } => "✓",
DecisionStatus::Unknown { .. } => "?",
DecisionStatus::Undefined => "○",
DecisionStatus::Conflict { .. } => "✗",
}
}
fn select_query_terminal(
program: &CompiledProgram,
query: Option<Query>,
preferred_decision: Option<&str>,
editor: &mut QuestionnaireEditor,
completion_data: &Arc<RwLock<CompletionData>>,
signal: &AtomicU8,
writer: &mut impl Write,
) -> 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 {
if let Some(decision) = preferred_decision.and_then(|preferred| {
decisions
.iter()
.copied()
.find(|decision| normalize_name(&decision.name.value) == normalize_name(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))),
_ => {
let choices = decisions
.iter()
.map(|decision| query_for_decision(decision).render())
.collect::<Vec<_>>();
configure_completion(completion_data, choices.clone(), &[]);
writeln!(writer, "Select a decision (↑/↓, Tab, or number):")?;
for (index, choice) in choices.iter().enumerate() {
writeln!(writer, " {}. {choice}", index + 1)?;
}
loop {
writer.flush()?;
signal.store(SIGNAL_NONE, Ordering::Release);
let answer = match editor.readline("> ") {
Ok(line) => line,
Err(ReadlineError::Interrupted | ReadlineError::Eof) => return Ok(None),
Err(error) => return Err(readline_failure(error)),
};
let answer = answer.trim();
if matches!(answer, ":quit" | ":cancel") {
return Ok(None);
}
if let Some(decision) = answer
.parse::<usize>()
.ok()
.and_then(|index| index.checked_sub(1))
.and_then(|index| decisions.get(index).copied())
.or_else(|| {
decisions.iter().copied().find(|decision| {
normalize_name(&decision.name.value) == normalize_name(answer)
|| query_for_decision(decision).render() == answer
})
})
{
return Ok(Some(query_for_decision(decision)));
}
writeln!(
writer,
"invalid choice; use ↑/↓, Tab, a number, or a decision name"
)?;
}
}
}
}
fn readline_failure(error: ReadlineError) -> QuestionnaireError {
match error {
ReadlineError::Io(error) => QuestionnaireError::Io(error),
other => QuestionnaireError::Io(io::Error::other(other.to_string())),
}
}
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, Debug)]
struct Question {
binding: String,
field: FieldDecl,
choice: Option<ChoiceQuestion>,
}
#[derive(Clone, Debug)]
enum ChoiceQuestion {
Selector {
branches: Vec<ChoiceBranchQuestion>,
},
Member {
selector: String,
variant: String,
required_when_active: bool,
},
}
#[derive(Clone, Debug)]
struct ChoiceBranchQuestion {
variant: String,
members: Vec<String>,
}
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 tree_path(&self) -> String {
self.display_path()
}
fn choice_annotation(&self) -> Option<String> {
match &self.choice {
Some(ChoiceQuestion::Member {
selector, variant, ..
}) => Some(format!("choice {selector} = {variant}")),
Some(ChoiceQuestion::Selector { .. }) | None => None,
}
}
fn is_optional(&self) -> bool {
match &self.choice {
Some(ChoiceQuestion::Member {
required_when_active,
..
}) => !required_when_active,
Some(ChoiceQuestion::Selector { .. }) | None => self.field.optional,
}
}
}
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))
})?;
let mut choices = vec![None; record.fields.len()];
for choice in &record.choices {
let branches = choice
.branches
.iter()
.map(|branch| ChoiceBranchQuestion {
variant: branch.variant.value.clone(),
members: branch
.members
.iter()
.filter_map(|member| record.fields.get(member.field))
.map(|field| field.name.value.clone())
.collect(),
})
.collect::<Vec<_>>();
if let Some(role) = choices.get_mut(choice.selector_field) {
*role = Some(ChoiceQuestion::Selector { branches });
}
let Some(selector) = record.fields.get(choice.selector_field) else {
continue;
};
for branch in &choice.branches {
for member in &branch.members {
if let Some(role) = choices.get_mut(member.field) {
*role = Some(ChoiceQuestion::Member {
selector: selector.name.value.clone(),
variant: branch.variant.value.clone(),
required_when_active: member.required_when_active,
});
}
}
}
}
questions.extend(
record
.fields
.iter()
.cloned()
.zip(choices)
.map(|(field, choice)| Question {
binding: argument.clone(),
field,
choice,
}),
);
}
Ok(questions)
}
#[derive(Clone, Debug)]
struct RankedQuestion {
question: Question,
fanout: usize,
rules: Vec<String>,
order: usize,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Progress {
entered: usize,
needed: usize,
deferred: usize,
open_branches: usize,
candidates: usize,
}
#[derive(Clone, Debug)]
struct InaccessibleChoiceFact {
path: String,
selector_path: String,
required_variant: String,
selected_variant: String,
rules: Vec<String>,
}
struct QuestionnaireSession<'a> {
program: &'a CompiledProgram,
query: Query,
catalog: Vec<Question>,
input: Input,
skipped: BTreeSet<String>,
discovered: BTreeSet<String>,
trail: Vec<String>,
cursor: Option<usize>,
}
impl<'a> QuestionnaireSession<'a> {
fn new(program: &'a CompiledProgram, query: Query) -> Result<Self, QuestionnaireError> {
let catalog = questions(program, &query)?;
let input = empty_input(program, &query)?;
Ok(Self {
program,
query,
catalog,
input,
skipped: BTreeSet::new(),
discovered: BTreeSet::new(),
trail: Vec::new(),
cursor: None,
})
}
fn evaluate(&mut self) -> Result<Evaluation, QuestionnaireError> {
let evaluation = evaluate_query(self.program, &self.input, &self.query)
.map_err(QuestionnaireError::Language)?;
self.discovered.extend(self.actionable_missing(&evaluation));
Ok(evaluation)
}
fn completed(self, evaluation: Evaluation) -> Outcome {
Outcome::Completed {
input: self.input,
evaluation,
}
}
fn current_or_next(&mut self, evaluation: &Evaluation) -> Option<RankedQuestion> {
if let Some(index) = self.cursor {
let path = self.trail.get(index)?;
let question = self.question(path)?.clone();
if self.question_is_active(&question) {
return Some(self.rank_question(evaluation, question, usize::MAX));
}
self.cursor = None;
}
let ranked = self.ranked_questions(evaluation).into_iter().next()?;
let path = ranked.question.normalized_path();
if self.trail.last() != Some(&path) {
self.trail.push(path);
}
self.cursor = self.trail.len().checked_sub(1);
Some(ranked)
}
fn ranked_questions(&self, evaluation: &Evaluation) -> Vec<RankedQuestion> {
self.rank_missing_questions(evaluation, false)
}
fn deferred_questions(&self, evaluation: &Evaluation) -> Vec<RankedQuestion> {
self.rank_missing_questions(evaluation, true)
}
fn rank_missing_questions(
&self,
evaluation: &Evaluation,
deferred: bool,
) -> Vec<RankedQuestion> {
if !matches!(evaluation.result.status, DecisionStatus::Unknown { .. }) {
return Vec::new();
}
let missing = self.actionable_missing(evaluation);
let mut ranked = self
.catalog
.iter()
.enumerate()
.filter(|(_, question)| {
let path = question.normalized_path();
self.question_is_active(question)
&& missing.contains(&path)
&& self.skipped.contains(&path) == deferred
})
.map(|(order, question)| self.rank_question(evaluation, question.clone(), order))
.collect::<Vec<_>>();
ranked.sort_by(|left, right| {
right
.fanout
.cmp(&left.fanout)
.then_with(|| left.order.cmp(&right.order))
});
ranked
}
fn rank_question(
&self,
evaluation: &Evaluation,
question: Question,
order: usize,
) -> RankedQuestion {
let paths = self.question_dependency_paths(&question);
let rules = evaluation
.rules
.iter()
.filter(|trace| {
trace.outcome == RuleOutcome::Blocked
&& trace
.missing
.iter()
.any(|missing| paths.contains(&normalize_path(missing)))
})
.map(|trace| trace.rule.clone())
.collect::<BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>();
RankedQuestion {
question,
fanout: rules.len(),
rules,
order,
}
}
fn progress(&self, evaluation: &Evaluation) -> Progress {
let missing = match &evaluation.result.status {
DecisionStatus::Unknown { .. } => self.actionable_missing(evaluation),
DecisionStatus::Resolved { .. }
| DecisionStatus::Undefined
| DecisionStatus::Conflict { .. } => BTreeSet::new(),
};
let deferred = missing.intersection(&self.skipped).count();
let needed = missing.len().saturating_sub(deferred);
let open_branches = evaluation
.rules
.iter()
.filter(|trace| {
if trace.outcome != RuleOutcome::Blocked {
return false;
}
self.project_actionable_paths(&self.input, trace.missing.iter().map(String::as_str))
.iter()
.any(|path| missing.contains(path))
})
.map(|trace| trace.rule.as_str())
.collect::<BTreeSet<_>>()
.len();
Progress {
entered: self
.discovered
.iter()
.filter(|path| {
self.question(path)
.is_some_and(|question| self.current_value(question).is_some())
})
.count(),
needed,
deferred,
open_branches,
candidates: evaluation.result.candidates.len(),
}
}
fn question(&self, normalized_path: &str) -> Option<&Question> {
self.catalog
.iter()
.find(|question| question.normalized_path() == normalized_path)
}
fn selector_question(&self, binding: &str, selector: &str) -> Option<&Question> {
let binding = normalize_name(binding);
let selector = normalize_name(selector);
self.catalog.iter().find(|question| {
normalize_name(&question.binding) == binding
&& normalize_name(&question.field.name.value) == selector
&& matches!(question.choice, Some(ChoiceQuestion::Selector { .. }))
})
}
fn selected_variant(&self, binding: &str, selector: &str) -> Option<&str> {
self.selected_variant_in(&self.input, binding, selector)
}
fn selected_variant_in<'input>(
&self,
input: &'input Input,
binding: &str,
selector: &str,
) -> Option<&'input str> {
let selector = self.selector_question(binding, selector)?;
match Self::value_in_input(input, selector) {
Some(Value::Enum { variant, .. }) => Some(variant),
_ => None,
}
}
fn question_is_active(&self, question: &Question) -> bool {
match &question.choice {
Some(ChoiceQuestion::Member {
selector, variant, ..
}) => self
.selected_variant(&question.binding, selector)
.is_some_and(|selected| normalize_name(selected) == normalize_name(variant)),
Some(ChoiceQuestion::Selector { .. }) | None => true,
}
}
fn navigable_questions(&self) -> Vec<Question> {
self.catalog
.iter()
.filter(|question| self.question_is_active(question))
.cloned()
.collect()
}
fn question_dependency_paths(&self, question: &Question) -> BTreeSet<String> {
let mut paths = BTreeSet::from([question.normalized_path()]);
let Some(ChoiceQuestion::Selector { branches }) = &question.choice else {
return paths;
};
if self.current_value(question).is_some() {
return paths;
}
for member in branches.iter().flat_map(|branch| &branch.members) {
paths.insert(format!(
"{}.{}",
normalize_name(&question.binding),
normalize_name(member)
));
}
paths
}
fn actionable_missing(&self, evaluation: &Evaluation) -> BTreeSet<String> {
let DecisionStatus::Unknown { missing } = &evaluation.result.status else {
return BTreeSet::new();
};
self.project_actionable_paths(&self.input, missing.iter().map(String::as_str))
}
fn actionable_missing_in(&self, evaluation: &Evaluation, input: &Input) -> BTreeSet<String> {
let DecisionStatus::Unknown { missing } = &evaluation.result.status else {
return BTreeSet::new();
};
self.project_actionable_paths(input, missing.iter().map(String::as_str))
}
fn inaccessible_choice_facts(&self, evaluation: &Evaluation) -> Vec<InaccessibleChoiceFact> {
let DecisionStatus::Unknown { missing } = &evaluation.result.status else {
return Vec::new();
};
let missing = missing
.iter()
.map(|path| normalize_path(path))
.collect::<BTreeSet<_>>();
self.catalog
.iter()
.filter_map(|question| {
let ChoiceQuestion::Member {
selector, variant, ..
} = question.choice.as_ref()?
else {
return None;
};
let path = question.normalized_path();
if !missing.contains(&path) {
return None;
}
let selected = self.selected_variant(&question.binding, selector)?;
if normalize_name(selected) == normalize_name(variant) {
return None;
}
let rules = evaluation
.rules
.iter()
.filter(|trace| {
trace.outcome == RuleOutcome::Blocked
&& trace
.missing
.iter()
.any(|missing| normalize_path(missing) == path)
})
.map(|trace| trace.rule.clone())
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
Some(InaccessibleChoiceFact {
path,
selector_path: format!("{}.{}", question.binding, selector),
required_variant: variant.clone(),
selected_variant: selected.to_owned(),
rules,
})
})
.collect()
}
fn project_actionable_paths<'path>(
&self,
input: &Input,
paths: impl IntoIterator<Item = &'path str>,
) -> BTreeSet<String> {
let mut actionable = paths
.into_iter()
.map(normalize_path)
.collect::<BTreeSet<_>>();
for selector in self
.catalog
.iter()
.filter(|question| matches!(question.choice, Some(ChoiceQuestion::Selector { .. })))
{
let Some(ChoiceQuestion::Selector { branches }) = &selector.choice else {
continue;
};
let selected = Self::value_in_input(input, selector).and_then(|value| match value {
Value::Enum { variant, .. } => Some(normalize_name(variant)),
_ => None,
});
let selector_path = selector.normalized_path();
let mut blocked_by_choice = false;
for branch in branches {
let active = selected
.as_ref()
.is_some_and(|value| *value == normalize_name(&branch.variant));
for member in &branch.members {
let path = format!(
"{}.{}",
normalize_name(&selector.binding),
normalize_name(member)
);
if selected.is_none() && actionable.remove(&path) {
blocked_by_choice = true;
} else if selected.is_some() && !active {
actionable.remove(&path);
}
}
}
if selected.is_none() && (blocked_by_choice || actionable.contains(&selector_path)) {
actionable.insert(selector_path);
}
}
actionable
}
fn current_value(&self, question: &Question) -> Option<&Value> {
Self::value_in_input(&self.input, question)
}
fn value_in_input<'input>(input: &'input Input, question: &Question) -> Option<&'input Value> {
input
.bindings
.get(&normalize_name(&question.binding))
.and_then(|binding| {
binding
.fields
.get(&normalize_name(&question.field.name.value))
})
}
fn answer(&mut self, question: &Question, value: Value) -> Result<(), Diagnostic> {
let input_before = self.input.clone();
let skipped_before = self.skipped.clone();
let discovered_before = self.discovered.clone();
let trail_before = self.trail.clone();
let cursor_before = self.cursor;
let previous = self.current_value(question).cloned();
let was_skipped = self.skipped.remove(&question.normalized_path());
let changed = previous.as_ref() != Some(&value) || was_skipped;
self.clear_inactive_choice_descendants(question, Some(&value));
if let Err(diagnostic) = set_value(self.program, &mut self.input, question, value) {
self.input = input_before;
self.skipped = skipped_before;
self.discovered = discovered_before;
self.trail = trail_before;
self.cursor = cursor_before;
return Err(diagnostic);
}
self.after_submission(changed);
Ok(())
}
fn skip(&mut self, question: &Question) {
self.clear_inactive_choice_descendants(question, None);
let changed =
self.remove_value(question) || self.skipped.insert(question.normalized_path());
self.after_submission(changed);
}
fn clear(&mut self, question: &Question) {
self.clear_inactive_choice_descendants(question, None);
let changed =
self.remove_value(question) | self.skipped.remove(&question.normalized_path());
self.after_submission(changed);
}
fn clear_inactive_choice_descendants(&mut self, question: &Question, selected: Option<&Value>) {
let Some(ChoiceQuestion::Selector { branches }) = &question.choice else {
return;
};
let selected = selected.and_then(|value| match value {
Value::Enum { variant, .. } => Some(normalize_name(variant)),
_ => None,
});
let mut removed_paths = BTreeSet::new();
clear_inactive_choice_input(&mut self.input, question, selected.as_deref());
for branch in branches {
if selected
.as_ref()
.is_some_and(|variant| *variant == normalize_name(&branch.variant))
{
continue;
}
for member in &branch.members {
removed_paths.insert(format!(
"{}.{}",
normalize_name(&question.binding),
normalize_name(member)
));
}
}
self.skipped.retain(|path| !removed_paths.contains(path));
self.discovered.retain(|path| !removed_paths.contains(path));
self.trail.retain(|path| !removed_paths.contains(path));
self.cursor = None;
}
fn remove_value(&mut self, question: &Question) -> bool {
self.input
.bindings
.get_mut(&normalize_name(&question.binding))
.and_then(|binding| {
binding
.fields
.remove(&normalize_name(&question.field.name.value))
})
.is_some()
}
fn advance_without_change(&mut self) {
self.after_submission(false);
}
fn after_submission(&mut self, changed: bool) {
let Some(index) = self.cursor else {
return;
};
if changed {
self.trail.truncate(index + 1);
self.cursor = None;
} else if index + 1 < self.trail.len() {
self.cursor = Some(index + 1);
} else {
self.cursor = None;
}
}
fn back(&mut self) -> bool {
match self.cursor {
Some(0) => false,
Some(index) => {
self.cursor = Some(index - 1);
true
}
None if !self.trail.is_empty() => {
self.cursor = self.trail.len().checked_sub(1);
true
}
None => false,
}
}
fn forward(&mut self) -> bool {
let Some(index) = self.cursor else {
return false;
};
if index + 1 < self.trail.len() {
self.cursor = Some(index + 1);
} else {
self.cursor = None;
}
true
}
fn goto(&mut self, requested: &str) -> bool {
let normalized = normalize_path(requested.trim());
let matches = self
.catalog
.iter()
.filter(|question| {
self.question_is_active(question)
&& (question.normalized_path() == normalized
|| normalize_name(&question.field.name.value)
== normalize_name(requested.trim()))
})
.map(Question::normalized_path)
.collect::<Vec<_>>();
let [path] = matches.as_slice() else {
return false;
};
if let Some(index) = self.trail.iter().rposition(|visited| visited == path) {
self.cursor = Some(index);
} else {
self.trail.push(path.clone());
self.cursor = self.trail.len().checked_sub(1);
}
true
}
fn goto_requirement(&self, requested: &str) -> Option<String> {
let normalized = normalize_path(requested.trim());
let matches = self
.catalog
.iter()
.filter(|question| {
!self.question_is_active(question)
&& (question.normalized_path() == normalized
|| normalize_name(&question.field.name.value)
== normalize_name(requested.trim()))
})
.collect::<Vec<_>>();
let [question] = matches.as_slice() else {
return None;
};
let Some(ChoiceQuestion::Member {
selector, variant, ..
}) = &question.choice
else {
return None;
};
Some(format!(
"select `{}.{selector} = {variant}` before navigating to `{}`",
question.binding,
question.display_path()
))
}
}
fn normalize_path(path: &str) -> String {
path.split_once('.').map_or_else(
|| normalize_name(path),
|(binding, field)| format!("{}.{}", normalize_name(binding), normalize_name(field)),
)
}
enum Action {
Value(Value),
Skip,
Keep,
Back,
Forward,
Tree,
Review,
Goto(String),
Clear,
Help,
Done,
Eof,
Cancel,
Invalid(String),
}
fn read_fallback_action<R: BufRead, W: Write>(
session: &QuestionnaireSession<'_>,
question: &Question,
reader: &mut R,
writer: &mut W,
) -> Result<Action, QuestionnaireError> {
write!(writer, "> ")?;
writer.flush()?;
let Some(line) = read_line(reader)? else {
return Ok(Action::Eof);
};
Ok(parse_action(session, question, line.trim()))
}
fn parse_action(session: &QuestionnaireSession<'_>, question: &Question, answer: &str) -> Action {
if answer.is_empty() {
return if session.current_value(question).is_some() {
Action::Keep
} else {
Action::Skip
};
}
match answer {
"?" | ":skip" | ":unknown" => return Action::Skip,
":back" | ":prev" => return Action::Back,
":forward" | ":next" => return Action::Forward,
":tree" | ":why" => return Action::Tree,
":review" => return Action::Review,
":clear" => return Action::Clear,
":help" => return Action::Help,
":done" | ":finish" => return Action::Done,
":quit" | ":cancel" => return Action::Cancel,
_ => {}
}
if let Some(path) = answer.strip_prefix(":goto ") {
return Action::Goto(path.trim().to_owned());
}
if answer.starts_with(':') {
return Action::Invalid(
"unknown command; type `:help` for interactive commands".to_owned(),
);
}
let choices = finite_choices(session.program, &question.field);
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(session.program, &question.field.ty, &selected) {
Ok(value) => Action::Value(value),
Err(message) => Action::Invalid(message),
}
}
fn render_question_card(
session: &QuestionnaireSession<'_>,
evaluation: &Evaluation,
ranked: &RankedQuestion,
writer: &mut impl Write,
rich: bool,
) -> io::Result<()> {
let progress = session.progress(evaluation);
let phase = if session.current_value(&ranked.question).is_some()
|| session.skipped.contains(&ranked.question.normalized_path())
{
"review / edit"
} else if progress.candidates > 0 {
"detail / refinement"
} else if ranked.fanout > 1 {
"shared fact"
} else {
"branch fact"
};
writeln!(writer)?;
writeln!(
writer,
"path {} entered {} · needed {} · deferred {} · open branches {}",
progress_bar(progress),
progress.entered,
progress.needed,
progress.deferred,
progress.open_branches
)?;
if progress.candidates > 0 {
let values = evaluation
.result
.candidates
.iter()
.map(|candidate| candidate.value.to_string())
.collect::<BTreeSet<_>>()
.into_iter()
.take(3)
.collect::<Vec<_>>()
.join(", ");
writeln!(
writer,
"provisional {} · {values} · continue to refine or use :done",
progress.candidates
)?;
}
writeln!(
writer,
"\n▶ {} ({}){} · {phase}",
ranked.question.display_path(),
field_type(&ranked.question.field),
if ranked.question.is_optional() {
" [optional]"
} else {
""
}
)?;
if let Some(value) = session.current_value(&ranked.question) {
writeln!(writer, " current: {}", input_literal(value))?;
} else if session.skipped.contains(&ranked.question.normalized_path()) {
writeln!(writer, " current: unknown (deferred)")?;
}
if ranked.fanout > 0 {
let examples = ranked.rules.iter().take(3).cloned().collect::<Vec<_>>();
let more = ranked.fanout.saturating_sub(examples.len());
writeln!(
writer,
" shared by {} open rule{}: {}{}",
ranked.fanout,
if ranked.fanout == 1 { "" } else { "s" },
examples.join(", "),
if more == 0 {
String::new()
} else {
format!(" +{more}")
}
)?;
}
for (index, choice) in finite_choices(session.program, &ranked.question.field)
.iter()
.enumerate()
{
let preview = preview_choice(session, evaluation, &ranked.question, choice);
writeln!(writer, " {}. {choice} → {preview}", index + 1)?;
}
if rich {
writeln!(
writer,
" ↑/↓ choice · Tab complete · ←/→ edit · Alt-←/Alt-→ back/forward · Ctrl-T tree"
)?;
} else {
writeln!(
writer,
" value/number · :back/:forward · :tree · :review · :goto FIELD · :done"
)?;
}
Ok(())
}
fn progress_bar(progress: Progress) -> String {
const WIDTH: usize = 10;
let total = progress
.entered
.saturating_add(progress.needed)
.saturating_add(progress.deferred);
let filled = progress
.entered
.saturating_mul(WIDTH)
.saturating_add(total.saturating_sub(1))
.checked_div(total)
.unwrap_or(WIDTH);
format!("[{}{}]", "#".repeat(filled), "-".repeat(WIDTH - filled))
}
fn preview_choice(
session: &QuestionnaireSession<'_>,
current: &Evaluation,
question: &Question,
choice: &str,
) -> String {
let literal = interactive_literal(&question.field.ty, choice);
let Ok(value) = parse_value(session.program, &question.field.ty, &literal) else {
return "invalid preview".to_owned();
};
let mut input = session.input.clone();
let selected = match &value {
Value::Enum { variant, .. } => Some(variant.as_str()),
_ => None,
};
clear_inactive_choice_input(&mut input, question, selected);
if set_value(session.program, &mut input, question, value).is_err() {
return "invalid preview".to_owned();
}
let Ok(evaluation) = evaluate_query(session.program, &input, &session.query) else {
return "unavailable".to_owned();
};
match &evaluation.result.status {
DecisionStatus::Resolved { values } => {
let values = values
.iter()
.take(2)
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ");
if values.is_empty() {
"resolved (no values)".to_owned()
} else {
format!("resolved: {values}")
}
}
DecisionStatus::Unknown { .. } => {
let before = session.actionable_missing(current).len();
let after = session.actionable_missing_in(&evaluation, &input).len();
let direction = if after > before {
format!("{after} details open")
} else {
format!("{after} facts remain")
};
if evaluation.result.candidates.is_empty() {
direction
} else {
format!(
"{} provisional · {direction}",
evaluation.result.candidates.len()
)
}
}
DecisionStatus::Undefined => "no matching rule".to_owned(),
DecisionStatus::Conflict { .. } => "conflict".to_owned(),
}
}
fn render_tree(
session: &QuestionnaireSession<'_>,
evaluation: &Evaluation,
writer: &mut impl Write,
) -> io::Result<()> {
writeln!(writer, "\nDecision DAG · {}", session.query.render())?;
if evaluation.result.candidates.is_empty() {
writeln!(writer, "├─ provisional candidates: none")?;
} else {
writeln!(writer, "├─ provisional candidates")?;
for candidate in &evaluation.result.candidates {
writeln!(writer, "│ ├─ {} ← {}", candidate.value, candidate.rule)?;
}
}
let ranked = session.ranked_questions(evaluation);
let deferred = session.deferred_questions(evaluation);
let inaccessible = session.inaccessible_choice_facts(evaluation);
if ranked.is_empty() && deferred.is_empty() && inaccessible.is_empty() {
writeln!(writer, "└─ open facts: none")?;
return Ok(());
}
writeln!(writer, "└─ open facts (shared nodes are asked once)")?;
for (index, item) in ranked.iter().enumerate() {
let last = index + 1 == ranked.len();
let edge = if last { " └─" } else { " ├─" };
let annotation = item
.question
.choice_annotation()
.map_or_else(String::new, |value| format!(" [{value}]"));
writeln!(
writer,
"{edge} {}{annotation} [{} rule{}]",
item.question.tree_path(),
item.fanout,
if item.fanout == 1 { "" } else { "s" }
)?;
for rule in item.rules.iter().take(3) {
let child = if last { " " } else { " │ " };
writeln!(writer, "{child} · {rule}")?;
}
if item.rules.len() > 3 {
let child = if last { " " } else { " │ " };
writeln!(writer, "{child} · +{} more", item.rules.len() - 3)?;
}
}
if !deferred.is_empty() {
writeln!(writer, " deferred facts ({})", deferred.len())?;
for item in deferred {
let annotation = item
.question
.choice_annotation()
.map_or_else(String::new, |value| format!(" [{value}]"));
writeln!(
writer,
" · {}{annotation} [{} rule{}]",
item.question.tree_path(),
item.fanout,
if item.fanout == 1 { "" } else { "s" }
)?;
}
}
if !inaccessible.is_empty() {
writeln!(
writer,
" inactive choice dependencies ({})",
inaccessible.len()
)?;
for fact in inaccessible {
writeln!(
writer,
" · {} — select {} = {} (currently {}) [{} rule{}]",
fact.path,
fact.selector_path,
fact.required_variant,
fact.selected_variant,
fact.rules.len(),
if fact.rules.len() == 1 { "" } else { "s" }
)?;
}
}
Ok(())
}
fn render_review(session: &QuestionnaireSession<'_>, writer: &mut impl Write) -> io::Result<()> {
writeln!(writer, "\nFacts · {}", session.query.render())?;
for question in &session.catalog {
let value = session.current_value(question);
let state = value.map_or_else(
|| {
if session.skipped.contains(&question.normalized_path()) {
"unknown (deferred)".to_owned()
} else {
"—".to_owned()
}
},
input_literal,
);
match &question.choice {
Some(ChoiceQuestion::Selector { branches }) => {
writeln!(writer, " {} = {state} [choice]", question.display_path())?;
let selected = session
.selected_variant(&question.binding, &question.field.name.value)
.map(normalize_name);
for branch in branches {
let active = selected
.as_ref()
.is_some_and(|value| *value == normalize_name(&branch.variant));
writeln!(
writer,
" {} {}",
if active { "├─" } else { "│ " },
if active {
format!("{} [active]", branch.variant)
} else {
format!("{} [inactive]", branch.variant)
}
)?;
}
}
Some(ChoiceQuestion::Member {
selector, variant, ..
}) => {
let active = session
.selected_variant(&question.binding, selector)
.is_some_and(|value| normalize_name(value) == normalize_name(variant));
writeln!(
writer,
" {} {} = {state} [{}]",
if active { "└─" } else { "·" },
question.tree_path(),
question
.choice_annotation()
.expect("choice member has an annotation")
)?;
}
None => writeln!(writer, " {} = {state}", question.display_path())?,
}
}
Ok(())
}
fn render_help(writer: &mut impl Write) -> io::Result<()> {
writeln!(
writer,
"\nInteractive controls:\n ↑/↓ or Tab select/complete a value in a terminal\n ←/→ edit the current value\n Alt-←/Alt-→ previous/next visited fact (PageUp/PageDown also work)\n :back/:forward previous/next visited fact\n :tree or Ctrl-T show the active decision DAG\n :review list all entered and deferred facts\n :goto FIELD jump to a fact by path or unique field name\n :clear clear the focused fact\n Enter or ? keep an existing value, otherwise defer as unknown\n :done finish with the current resolved/unknown result\n :quit cancel without a result"
)
}
fn input_literal(value: &Value) -> String {
match value {
Value::Enum { variant, .. } => variant.clone(),
Value::Date(value) => value.format("%Y-%m-%d").to_string(),
_ => value.to_string(),
}
}
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 clear_inactive_choice_input(input: &mut Input, question: &Question, selected: Option<&str>) {
let Some(ChoiceQuestion::Selector { branches }) = &question.choice else {
return;
};
let fields = &mut input
.bindings
.get_mut(&normalize_name(&question.binding))
.expect("interactive input created every query binding")
.fields;
for branch in branches {
if selected
.is_some_and(|variant| normalize_name(variant) == normalize_name(&branch.variant))
{
continue;
}
for member in &branch.members {
fields.remove(&normalize_name(member));
}
}
}
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 invalid_edit_rolls_back_a_deferred_fact_transactionally() {
let program = program(
r"enum Result:
High
Low
record Request:
score: Int? [0, 10]
rule high:
score >= 5: outcome = High
rule low:
score < 5: outcome = Low
",
);
let query = Query::parse("outcome(Request)").unwrap();
let mut session = QuestionnaireSession::new(&program, query).unwrap();
let evaluation = session.evaluate().unwrap();
let score = session.current_or_next(&evaluation).unwrap().question;
session.skip(&score);
assert!(session.skipped.contains(&score.normalized_path()));
assert!(session.answer(&score, Value::Int(11)).is_err());
assert!(
session.skipped.contains(&score.normalized_path()),
"a rejected edit must preserve the prior deferred state"
);
assert!(session.current_value(&score).is_none());
}
#[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}");
}
#[test]
fn shared_rule_fanout_prioritizes_core_facts_over_branch_details() {
let program = program(
r"mod priority
enum Result:
A
B
None
record Request:
detail_a: Bool?
common: Bool?
detail_b: Bool?
rule a:
common & detail_a: outcome = A
rule b:
common & detail_b: outcome = B
rule none:
common X: outcome = None
assert outcomes_are_many:
outcome*
",
);
let query = Query::parse("outcome(Request)").unwrap();
let mut session = QuestionnaireSession::new(&program, query).unwrap();
let evaluation = session.evaluate().unwrap();
let ranked = session.ranked_questions(&evaluation);
assert_eq!(ranked[0].question.display_path(), "Request.common");
assert_eq!(ranked[0].fanout, 3);
assert_eq!(ranked[1].fanout, 1);
assert_eq!(ranked[2].fanout, 1);
}
#[test]
fn back_forward_preserve_answers_and_changed_ancestor_reopens_the_dag() {
let program = program(
r"mod history
enum Result:
Yes
No
enum Route:
DirectYes
Details
record Request:
route: Route
observed: Bool?
audience: Int? [0, 100]
rule direct:
route = DirectYes: outcome = Yes
rule detailed_yes:
route = Details & observed & audience >= 2: outcome = Yes
rule detailed_no:
route = Details & (observed X | audience < 2): outcome = No
",
);
let query = Query::parse("outcome(Request)").unwrap();
let mut session = QuestionnaireSession::new(&program, query).unwrap();
let evaluation = session.evaluate().unwrap();
let route = session.current_or_next(&evaluation).unwrap().question;
session
.answer(
&route,
Value::Enum {
type_name: "Route".to_owned(),
variant: "Details".to_owned(),
},
)
.unwrap();
let evaluation = session.evaluate().unwrap();
let observed = session.current_or_next(&evaluation).unwrap().question;
session.answer(&observed, Value::Bool(true)).unwrap();
let evaluation = session.evaluate().unwrap();
let audience = session.current_or_next(&evaluation).unwrap().question;
assert_eq!(audience.field.name.value, "audience");
assert!(session.back());
let evaluation = session.evaluate().unwrap();
let revisited = session.current_or_next(&evaluation).unwrap().question;
assert_eq!(revisited.field.name.value, "observed");
assert_eq!(session.current_value(&revisited), Some(&Value::Bool(true)));
assert!(session.forward());
let evaluation = session.evaluate().unwrap();
assert_eq!(
session
.current_or_next(&evaluation)
.unwrap()
.question
.field
.name
.value,
"audience"
);
assert!(session.goto("route"));
let evaluation = session.evaluate().unwrap();
let route = session.current_or_next(&evaluation).unwrap().question;
session
.answer(
&route,
Value::Enum {
type_name: "Route".to_owned(),
variant: "DirectYes".to_owned(),
},
)
.unwrap();
let evaluation = session.evaluate().unwrap();
assert!(evaluation.result.status.is_resolved());
assert_eq!(session.trail, ["Request.route"]);
assert_eq!(
session.input.bindings["Request"].fields["observed"],
Value::Bool(true),
"inactive branch answers remain cached"
);
}
#[test]
fn tree_and_choice_preview_expose_dynamic_progress() {
let program = program(
r"mod preview
enum Result:
Yes
No
record Request:
enabled: Bool
detail: Bool?
rule no:
enabled X: outcome = No
rule yes:
enabled & detail: outcome = Yes
",
);
let query = Query::parse("outcome(Request)").unwrap();
let mut session = QuestionnaireSession::new(&program, query).unwrap();
let evaluation = session.evaluate().unwrap();
let ranked = session.current_or_next(&evaluation).unwrap();
let mut output = Vec::new();
render_question_card(&session, &evaluation, &ranked, &mut output, true).unwrap();
render_tree(&session, &evaluation, &mut output).unwrap();
let output = String::from_utf8(output).unwrap();
assert!(output.contains("path [----------]"), "{output}");
assert!(output.contains("1. O →"), "{output}");
assert!(output.contains("2. X → resolved"), "{output}");
assert!(
output.contains("Decision DAG · outcome(Request)"),
"{output}"
);
assert!(
output.contains("open facts (shared nodes are asked once)"),
"{output}"
);
}
#[test]
fn tagged_choices_gate_questions_and_switch_branches_transactionally() {
let program = program(
r"mod tagged
enum Result:
Yes
No
enum Route:
Direct
Details
record Request:
choice route Route:
Direct:
approved: Bool
Details:
observed: Bool
audience: Int? [0, 100]
rule direct_yes:
route = Direct & approved: outcome = Yes
rule direct_no:
route = Direct & approved X: outcome = No
rule detailed_yes:
route = Details & observed & audience >= 2: outcome = Yes
rule detailed_no:
route = Details & (observed X | audience < 2): outcome = No
",
);
let query = Query::parse("outcome(Request)").unwrap();
let mut session = QuestionnaireSession::new(&program, query).unwrap();
let evaluation = session.evaluate().unwrap();
let ranked = session.ranked_questions(&evaluation);
assert_eq!(
ranked.len(),
1,
"the selector replaces all blocked descendants"
);
assert_eq!(ranked[0].question.field.name.value, "route");
assert_eq!(session.progress(&evaluation).needed, 1);
assert!(
!session.goto("approved"),
"inactive branches cannot be jumped to"
);
assert_eq!(
session.goto_requirement("approved").as_deref(),
Some("select `Request.route = Direct` before navigating to `Request.approved`")
);
let route = ranked[0].question.clone();
session
.answer(
&route,
Value::Enum {
type_name: "Route".to_owned(),
variant: "Details".to_owned(),
},
)
.unwrap();
let evaluation = session.evaluate().unwrap();
let active = session
.ranked_questions(&evaluation)
.into_iter()
.map(|ranked| ranked.question.field.name.value)
.collect::<BTreeSet<_>>();
assert_eq!(
active,
BTreeSet::from(["audience".to_owned(), "observed".to_owned()])
);
assert!(session.goto("observed"));
assert!(!session.goto("approved"));
let observed = session
.catalog
.iter()
.find(|question| question.field.name.value == "observed")
.unwrap()
.clone();
session.answer(&observed, Value::Bool(true)).unwrap();
let audience = session
.catalog
.iter()
.find(|question| question.field.name.value == "audience")
.unwrap()
.clone();
session.skip(&audience);
assert!(
session.input.bindings["Request"]
.fields
.contains_key("observed")
);
assert!(session.skipped.contains(&audience.normalized_path()));
assert!(session.goto("route"));
session
.answer(
&route,
Value::Enum {
type_name: "Route".to_owned(),
variant: "Direct".to_owned(),
},
)
.unwrap();
assert!(
!session.input.bindings["Request"]
.fields
.contains_key("observed")
);
assert!(
!session.input.bindings["Request"]
.fields
.contains_key("audience")
);
assert!(!session.skipped.contains(&audience.normalized_path()));
assert!(session.trail.iter().all(|path| {
path != &observed.normalized_path() && path != &audience.normalized_path()
}));
let evaluation = session.evaluate().unwrap();
let active = session.ranked_questions(&evaluation);
assert_eq!(active.len(), 1);
assert_eq!(active[0].question.field.name.value, "approved");
assert!(!active[0].question.is_optional());
assert!(session.goto(&active[0].question.tree_path()));
let mut tree = Vec::new();
render_tree(&session, &evaluation, &mut tree).unwrap();
let tree = String::from_utf8(tree).unwrap();
assert!(
tree.contains("Request.approved [choice route = Direct]"),
"{tree}"
);
let mut review = Vec::new();
render_review(&session, &mut review).unwrap();
let review = String::from_utf8(review).unwrap();
assert!(
review.contains("Request.route = Direct [choice]"),
"{review}"
);
assert!(review.contains("Direct [active]"), "{review}");
assert!(review.contains("Details [inactive]"), "{review}");
}
#[test]
fn choice_projection_keeps_progress_and_deferred_tree_consistent() {
let program = program(
r"enum Result:
Yes
No
enum Route:
Direct
Details
record Request:
choice route Route:
Direct:
approved: Bool?
Details:
observed: Bool?
rule yes:
approved: outcome = Yes
rule no:
approved X: outcome = No
",
);
let query = Query::parse("outcome(Request)").unwrap();
let mut session = QuestionnaireSession::new(&program, query).unwrap();
let evaluation = session.evaluate().unwrap();
let ranked = session.ranked_questions(&evaluation);
assert_eq!(ranked.len(), 1);
assert_eq!(ranked[0].question.field.name.value, "route");
assert_eq!(ranked[0].fanout, 2);
assert_eq!(
session.progress(&evaluation),
Progress {
entered: 0,
needed: 1,
deferred: 0,
open_branches: 2,
candidates: 0,
}
);
let route = ranked[0].question.clone();
session.skip(&route);
let evaluation = session.evaluate().unwrap();
assert_eq!(session.progress(&evaluation).needed, 0);
assert_eq!(session.progress(&evaluation).deferred, 1);
assert_eq!(session.progress(&evaluation).open_branches, 2);
let mut output = Vec::new();
render_tree(&session, &evaluation, &mut output).unwrap();
render_completion(&session, &evaluation, &mut output).unwrap();
let output = String::from_utf8(output).unwrap();
assert!(output.contains("deferred facts (1)"), "{output}");
assert!(output.contains("Request.route"), "{output}");
assert!(
output.contains("1 fact still unknown (1 deferred)"),
"{output}"
);
let query = Query::parse("outcome(Request)").unwrap();
let mut blocked = QuestionnaireSession::new(&program, query).unwrap();
let evaluation = blocked.evaluate().unwrap();
let route = blocked.current_or_next(&evaluation).unwrap().question;
blocked
.answer(
&route,
Value::Enum {
type_name: "Route".to_owned(),
variant: "Details".to_owned(),
},
)
.unwrap();
let evaluation = blocked.evaluate().unwrap();
assert!(blocked.ranked_questions(&evaluation).is_empty());
let inaccessible = blocked.inaccessible_choice_facts(&evaluation);
assert_eq!(inaccessible.len(), 1);
assert_eq!(inaccessible[0].path, "Request.approved");
let mut output = Vec::new();
render_tree(&blocked, &evaluation, &mut output).unwrap();
render_completion(&blocked, &evaluation, &mut output).unwrap();
let output = String::from_utf8(output).unwrap();
assert!(
output.contains("inactive choice dependencies (1)"),
"{output}"
);
assert!(
output.contains("no actionable facts; 1 choice dependency is inactive"),
"{output}"
);
assert!(
output.contains(
"Request.approved requires Request.route = Direct (currently Details); use :goto Request.route"
),
"{output}"
);
}
#[test]
fn unicode_value_completion_filters_by_prefix() {
let candidates = vec![
"직접확정".to_owned(),
"허가승인사실".to_owned(),
"직접판단".to_owned(),
];
let completed = completion_pairs("직접", &candidates)
.into_iter()
.map(|pair| pair.replacement)
.collect::<Vec<_>>();
assert_eq!(completed, ["직접확정", "직접판단"]);
}
#[test]
fn arrow_choice_cycle_wraps_in_both_directions() {
let choices = vec!["O".to_owned(), "X".to_owned()];
assert_eq!(cycle_choice(&choices, "", 1), Some("O"));
assert_eq!(cycle_choice(&choices, "O", 1), Some("X"));
assert_eq!(cycle_choice(&choices, "X", 1), Some("O"));
assert_eq!(cycle_choice(&choices, "", -1), Some("X"));
assert_eq!(cycle_choice(&choices, "O", -1), Some("X"));
}
}