mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-16 23:43:10 +00:00
The review question sentence was written in four places and the URL safety rules in three. Collapse each to one definition. - Add `ReviewTarget::question_text_with_link` as the single definition of the question wording. `question_text()` and the Slack header both use it, so a wording change is now one edit. - Delete `ReviewTargetKind::noun()`. The enum already derives `strum::Display` with the same snake_case output. - Share one `review_target_line` helper between the console interviewer and the CLI attach client, which held a byte-identical copy. Print only the URL: `question.text` already carries the label and the noun. - Trim the web-side check to the URL scheme, host, and credentials, which are what a raw `href` can act on. Label length and control characters cannot affect the DOM and stay server-side. - Split validation from presentation in the web UI. `safeReviewTarget` returns the target or null, and each caller picks its own fallback, so an unsafe target now falls back to the same Markdown rendering as a question with no target. - Derive the resource noun from `kind` in the web UI instead of hardcoding "document". - Use `ReviewTargetKind.DOCUMENT` and the shared `isRecord` guard when parsing events, instead of a raw string and a hand-rolled object check that accepted arrays. - Drop `deny_unknown_fields` from the wire struct. The OpenAPI schema leaves `additionalProperties` permissive, so an added field would otherwise make persisted events unreadable. - Import `ReviewTarget` by name, and stop naming Slack in a fabro-types error message. - Document that `review_target=true` replaces the gate's `label`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
445 lines
15 KiB
Rust
445 lines
15 KiB
Rust
use std::io::IsTerminal;
|
|
|
|
use async_trait::async_trait;
|
|
use dialoguer::console::Term;
|
|
use dialoguer::theme::ColorfulTheme;
|
|
use fabro_types::{InterviewOption, Principal, QuestionType};
|
|
use fabro_util::terminal::Styles;
|
|
use tokio::io::{self, AsyncBufReadExt, BufReader};
|
|
use tokio::task;
|
|
|
|
use crate::{Answer, AnswerSubmission, AnswerValue, Interviewer, Question};
|
|
|
|
enum PromptRead {
|
|
Line(String),
|
|
Eof,
|
|
Error,
|
|
}
|
|
|
|
/// Reads from stdin to collect answers. Displays formatted prompts per spec
|
|
/// 6.4.
|
|
pub struct ConsoleInterviewer {
|
|
styles: &'static Styles,
|
|
actor: Principal,
|
|
}
|
|
|
|
impl ConsoleInterviewer {
|
|
#[must_use]
|
|
pub fn new(styles: &'static Styles, actor: Principal) -> Self {
|
|
Self { styles, actor }
|
|
}
|
|
}
|
|
|
|
fn find_matching_option(response: &str, options: &[InterviewOption]) -> Option<Answer> {
|
|
let trimmed = response.trim();
|
|
// Try matching by key (case-insensitive)
|
|
for opt in options {
|
|
if opt.key.eq_ignore_ascii_case(trimmed) {
|
|
return Some(Answer {
|
|
value: AnswerValue::Selected(opt.key.clone()),
|
|
selected_option: Some(opt.clone()),
|
|
text: None,
|
|
});
|
|
}
|
|
}
|
|
// Try matching by 1-based index
|
|
if let Ok(idx) = trimmed.parse::<usize>() {
|
|
if idx >= 1 && idx <= options.len() {
|
|
let opt = &options[idx - 1];
|
|
return Some(Answer {
|
|
value: AnswerValue::Selected(opt.key.clone()),
|
|
selected_option: Some(opt.clone()),
|
|
text: None,
|
|
});
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
#[allow(
|
|
clippy::print_stderr,
|
|
reason = "Prompts go to stderr so piped stdout stays machine-readable."
|
|
)]
|
|
async fn read_line(prompt: &str) -> PromptRead {
|
|
// Print the prompt to stderr so it doesn't interfere with piped stdout
|
|
eprint!("{prompt}");
|
|
let stdin = io::stdin();
|
|
let mut reader = BufReader::new(stdin);
|
|
let mut line = String::new();
|
|
match reader.read_line(&mut line).await {
|
|
Ok(0) => PromptRead::Eof,
|
|
Ok(_) => PromptRead::Line(line.trim_end().to_string()),
|
|
Err(_) => PromptRead::Error,
|
|
}
|
|
}
|
|
|
|
fn parse_non_tty_choice_response(question: &Question, prompt_read: PromptRead) -> Answer {
|
|
let PromptRead::Line(response) = prompt_read else {
|
|
return Answer::interrupted();
|
|
};
|
|
if response.trim().is_empty() {
|
|
return Answer::interrupted();
|
|
}
|
|
if let Some(answer) = find_matching_option(&response, &question.options) {
|
|
return answer;
|
|
}
|
|
if question.allow_freeform {
|
|
return Answer::text(response);
|
|
}
|
|
find_matching_option(&response, &question.options).unwrap_or_else(Answer::interrupted)
|
|
}
|
|
|
|
fn parse_non_tty_confirm_response(prompt_read: PromptRead) -> Answer {
|
|
let PromptRead::Line(response) = prompt_read else {
|
|
return Answer::interrupted();
|
|
};
|
|
match response.trim().to_lowercase().as_str() {
|
|
"y" | "yes" => Answer::yes(),
|
|
"n" | "no" => Answer::no(),
|
|
_ => Answer::interrupted(),
|
|
}
|
|
}
|
|
|
|
fn parse_non_tty_freeform_response(prompt_read: PromptRead) -> Answer {
|
|
let PromptRead::Line(response) = prompt_read else {
|
|
return Answer::interrupted();
|
|
};
|
|
if response.trim().is_empty() {
|
|
Answer::interrupted()
|
|
} else {
|
|
Answer::text(response)
|
|
}
|
|
}
|
|
|
|
/// The review target line printed above a question in terminal clients, which
|
|
/// cannot render a hyperlink label. The label and resource noun are already in
|
|
/// `question.text`, so only the URL is shown. Shared with `fabro-cli`'s attach
|
|
/// client.
|
|
#[must_use]
|
|
pub fn review_target_line(question: &Question) -> Option<String> {
|
|
question
|
|
.review_target
|
|
.as_ref()
|
|
.map(|target| format!("Review link: {}", target.url()))
|
|
}
|
|
|
|
/// Ask a multiple-choice question using dialoguer's `Select` widget on a TTY.
|
|
fn ask_select_interactive(question: &Question) -> Answer {
|
|
let items: Vec<String> = question
|
|
.options
|
|
.iter()
|
|
.map(|opt| format!("{} - {}", opt.key, opt.label))
|
|
.collect();
|
|
|
|
let has_freeform = question.allow_freeform;
|
|
let mut all_items = items;
|
|
if has_freeform {
|
|
all_items.push("Other (free text)...".to_string());
|
|
}
|
|
|
|
let selection = dialoguer::Select::with_theme(&ColorfulTheme::default())
|
|
.with_prompt(&question.text)
|
|
.items(&all_items)
|
|
.default(0)
|
|
.interact_on_opt(&Term::stderr());
|
|
|
|
match selection {
|
|
Ok(Some(idx)) if has_freeform && idx == question.options.len() => {
|
|
// User chose the free-text option
|
|
dialoguer::Input::<String>::with_theme(&ColorfulTheme::default())
|
|
.with_prompt("Enter your response")
|
|
.interact_on(&Term::stderr())
|
|
.map_or_else(
|
|
|_| Answer::interrupted(),
|
|
|response| {
|
|
if response.trim().is_empty() {
|
|
Answer::interrupted()
|
|
} else {
|
|
Answer::text(response)
|
|
}
|
|
},
|
|
)
|
|
}
|
|
Ok(Some(idx)) if idx < question.options.len() => {
|
|
let opt = &question.options[idx];
|
|
Answer {
|
|
value: AnswerValue::Selected(opt.key.clone()),
|
|
selected_option: Some(opt.clone()),
|
|
text: None,
|
|
}
|
|
}
|
|
_ => Answer::interrupted(),
|
|
}
|
|
}
|
|
|
|
/// Ask a multi-select question using dialoguer's `MultiSelect` widget on a TTY.
|
|
fn ask_multi_select_interactive(question: &Question) -> Answer {
|
|
let items: Vec<String> = question
|
|
.options
|
|
.iter()
|
|
.map(|opt| format!("{} - {}", opt.key, opt.label))
|
|
.collect();
|
|
|
|
let selection = dialoguer::MultiSelect::with_theme(&ColorfulTheme::default())
|
|
.with_prompt(&question.text)
|
|
.items(&items)
|
|
.interact_on_opt(&Term::stderr());
|
|
|
|
match selection {
|
|
Ok(Some(indices)) if !indices.is_empty() => {
|
|
let keys: Vec<String> = indices
|
|
.iter()
|
|
.map(|&i| question.options[i].key.clone())
|
|
.collect();
|
|
Answer::multi_selected(keys)
|
|
}
|
|
_ => Answer::interrupted(),
|
|
}
|
|
}
|
|
|
|
/// Ask a yes/no or confirmation question using dialoguer's `Confirm` widget on
|
|
/// a TTY.
|
|
fn ask_confirm_interactive(question: &Question) -> Answer {
|
|
let confirmed = dialoguer::Confirm::with_theme(&ColorfulTheme::default())
|
|
.with_prompt(&question.text)
|
|
.default(true)
|
|
.interact_on_opt(&Term::stderr());
|
|
|
|
match confirmed {
|
|
Ok(Some(true)) => Answer::yes(),
|
|
Ok(Some(false)) => Answer::no(),
|
|
_ => Answer::interrupted(),
|
|
}
|
|
}
|
|
|
|
/// Ask a freeform question using dialoguer's `Input` widget on a TTY.
|
|
fn ask_freeform_interactive(question: &Question) -> Answer {
|
|
dialoguer::Input::<String>::with_theme(&ColorfulTheme::default())
|
|
.with_prompt(&question.text)
|
|
.interact_on(&Term::stderr())
|
|
.map_or_else(
|
|
|_| Answer::interrupted(),
|
|
|response| {
|
|
if response.trim().is_empty() {
|
|
Answer::interrupted()
|
|
} else {
|
|
Answer::text(response)
|
|
}
|
|
},
|
|
)
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Interviewer for ConsoleInterviewer {
|
|
#[allow(
|
|
clippy::print_stderr,
|
|
reason = "Interactive questions and options belong on stderr, not captured stdout."
|
|
)]
|
|
async fn ask(&self, question: Question) -> AnswerSubmission {
|
|
// If stdin is a TTY, use dialoguer for interactive arrow-key navigation.
|
|
// Otherwise, fall back to the line-based reader for piped input.
|
|
#[expect(
|
|
clippy::disallowed_methods,
|
|
reason = "is_terminal() on the std stdin handle is a non-blocking fstat check; no \
|
|
actual I/O performed. The real blocking read runs inside spawn_blocking \
|
|
below."
|
|
)]
|
|
if std::io::stdin().is_terminal() {
|
|
if let Some(ref context_text) = question.context_display {
|
|
let rendered = self.styles.render_markdown(context_text);
|
|
eprint!("{rendered}");
|
|
}
|
|
if let Some(line) = review_target_line(&question) {
|
|
eprintln!("{line}");
|
|
}
|
|
let q = question;
|
|
let answer = task::spawn_blocking(move || match q.question_type {
|
|
QuestionType::MultipleChoice => ask_select_interactive(&q),
|
|
QuestionType::MultiSelect => ask_multi_select_interactive(&q),
|
|
QuestionType::YesNo | QuestionType::Confirmation => ask_confirm_interactive(&q),
|
|
QuestionType::Freeform => ask_freeform_interactive(&q),
|
|
})
|
|
.await
|
|
.unwrap_or_else(|_| Answer::interrupted());
|
|
return AnswerSubmission::new(answer, self.actor.clone());
|
|
}
|
|
|
|
// Non-TTY fallback: line-based stdin reading
|
|
let s = self.styles;
|
|
if let Some(line) = review_target_line(&question) {
|
|
eprintln!("{line}");
|
|
}
|
|
eprintln!("{} {}", s.bold_cyan.apply_to("?"), question.text);
|
|
|
|
let answer = match question.question_type {
|
|
QuestionType::MultipleChoice | QuestionType::MultiSelect => {
|
|
for (i, opt) in question.options.iter().enumerate() {
|
|
eprintln!(
|
|
" {}{}{} {} - {}",
|
|
s.dim.apply_to("["),
|
|
s.bold.apply_to(i + 1),
|
|
s.dim.apply_to("]"),
|
|
opt.key,
|
|
opt.label,
|
|
);
|
|
}
|
|
if question.allow_freeform {
|
|
eprintln!(" Or type a free-text response");
|
|
}
|
|
parse_non_tty_choice_response(&question, read_line("Select: ").await)
|
|
}
|
|
QuestionType::YesNo | QuestionType::Confirmation => {
|
|
parse_non_tty_confirm_response(read_line("[Y/N]: ").await)
|
|
}
|
|
QuestionType::Freeform => parse_non_tty_freeform_response(read_line("> ").await),
|
|
};
|
|
AnswerSubmission::new(answer, self.actor.clone())
|
|
}
|
|
|
|
#[allow(
|
|
clippy::print_stderr,
|
|
reason = "Stage notices belong on stderr, not captured stdout."
|
|
)]
|
|
async fn inform(&self, message: &str, stage: &str) {
|
|
let s = self.styles;
|
|
eprintln!("{} {message}", s.dim.apply_to(format!("[{stage}]")));
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn find_matching_option_by_key() {
|
|
let options = vec![
|
|
InterviewOption {
|
|
key: "A".to_string(),
|
|
label: "Approve".to_string(),
|
|
description: None,
|
|
preview: None,
|
|
},
|
|
InterviewOption {
|
|
key: "R".to_string(),
|
|
label: "Reject".to_string(),
|
|
description: None,
|
|
preview: None,
|
|
},
|
|
];
|
|
let result = find_matching_option("A", &options);
|
|
assert!(result.is_some());
|
|
let answer = result.unwrap();
|
|
assert_eq!(answer.value, AnswerValue::Selected("A".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn review_target_line_shows_only_the_url() {
|
|
let target = fabro_types::ReviewTarget::new(
|
|
"Quarry review exercise",
|
|
"https://quarry.lithos.computer/tmp/0123456789abcdef0123456789abcdef",
|
|
fabro_types::ReviewTargetKind::Document,
|
|
)
|
|
.unwrap();
|
|
let mut question = Question::new(target.question_text(), QuestionType::MultipleChoice);
|
|
question.review_target = Some(target);
|
|
|
|
assert_eq!(
|
|
review_target_line(&question).as_deref(),
|
|
Some(
|
|
"Review link: \
|
|
https://quarry.lithos.computer/tmp/0123456789abcdef0123456789abcdef"
|
|
)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn review_target_line_is_absent_without_a_target() {
|
|
let question = Question::new("Approve?", QuestionType::YesNo);
|
|
|
|
assert_eq!(review_target_line(&question), None);
|
|
}
|
|
|
|
#[test]
|
|
fn find_matching_option_by_key_case_insensitive() {
|
|
let options = vec![InterviewOption {
|
|
key: "Y".to_string(),
|
|
label: "Yes".to_string(),
|
|
description: None,
|
|
preview: None,
|
|
}];
|
|
let result = find_matching_option("y", &options);
|
|
assert!(result.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn find_matching_option_by_index() {
|
|
let options = vec![
|
|
InterviewOption {
|
|
key: "A".to_string(),
|
|
label: "Alpha".to_string(),
|
|
description: None,
|
|
preview: None,
|
|
},
|
|
InterviewOption {
|
|
key: "B".to_string(),
|
|
label: "Beta".to_string(),
|
|
description: None,
|
|
preview: None,
|
|
},
|
|
];
|
|
let result = find_matching_option("2", &options);
|
|
assert!(result.is_some());
|
|
let answer = result.unwrap();
|
|
assert_eq!(answer.value, AnswerValue::Selected("B".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn find_matching_option_no_match() {
|
|
let options = vec![InterviewOption {
|
|
key: "A".to_string(),
|
|
label: "Alpha".to_string(),
|
|
description: None,
|
|
preview: None,
|
|
}];
|
|
let result = find_matching_option("zzz", &options);
|
|
assert!(result.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn find_matching_option_index_out_of_range() {
|
|
let options = vec![InterviewOption {
|
|
key: "A".to_string(),
|
|
label: "Alpha".to_string(),
|
|
description: None,
|
|
preview: None,
|
|
}];
|
|
let result = find_matching_option("5", &options);
|
|
assert!(result.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn non_tty_multiple_choice_eof_returns_interrupted() {
|
|
let mut question = Question::new("Approve?", QuestionType::MultipleChoice);
|
|
question.options = vec![InterviewOption {
|
|
key: "A".to_string(),
|
|
label: "Approve".to_string(),
|
|
description: None,
|
|
preview: None,
|
|
}];
|
|
|
|
let answer = parse_non_tty_choice_response(&question, PromptRead::Eof);
|
|
assert_eq!(answer.value, AnswerValue::Interrupted);
|
|
}
|
|
|
|
#[test]
|
|
fn non_tty_confirmation_invalid_response_returns_interrupted() {
|
|
let answer = parse_non_tty_confirm_response(PromptRead::Line(String::new()));
|
|
assert_eq!(answer.value, AnswerValue::Interrupted);
|
|
}
|
|
|
|
#[test]
|
|
fn non_tty_freeform_blank_response_returns_interrupted() {
|
|
let answer = parse_non_tty_freeform_response(PromptRead::Line(" ".to_string()));
|
|
assert_eq!(answer.value, AnswerValue::Interrupted);
|
|
}
|
|
}
|