1use crate::{ask::Question, land, merge_decision, release_watch, triage};
3
4pub fn describe(q: &Question, label: &str) -> Option<String> {
7 if !q.choices.iter().any(|c| c == label) {
8 return None;
9 }
10 if let Some(action) = q.actions.get(label) {
11 return Some(action.describe());
12 }
13 if q.cwd.is_some() && q.node != crate::conduct::NODE && crate::deputy::kind_of(q).is_none() {
14 return Some("deliver this answer to the waiting agent".into());
15 }
16 let at = q.choices.iter().position(|c| c == label)?;
17 let effect = match q.node.as_str() {
18 triage::NODE => match at {
19 0 => "release and requeue the task",
20 1 => "keep the task held",
21 2 => "delete the task",
22 _ => return None,
23 },
24 triage::DEPS_NODE => match at {
25 0 => "release the dependency task",
26 1 => "detach dependants and delete the dependency task",
27 2 => "detach dependants",
28 _ => return None,
29 },
30 land::APPROVAL_NODE => match label {
31 land::APPROVE => "merge the approved pull request head",
32 land::HOLD => "keep the approval held",
33 land::REPAIR => "fix findings and re-review the run",
34 merge_decision::WITHDRAW => "close the pull request and end the task",
35 merge_decision::DISMISS => "end the obsolete approval",
36 c if merge_decision::selected_agent(c).is_some() => {
37 "start the selected replacement implementer"
38 }
39 _ => return None,
40 },
41 crate::bump::NOTICE_NODE if q.seat == "release-watch" => match label {
42 release_watch::RERUN_AGAIN => "rerun failed checks once more",
43 release_watch::RETRY => "retry the release job",
44 release_watch::LEAVE_IT => "dismiss this release watch",
45 land::APPROVE => "merge the approved release head",
46 land::HOLD => "keep the release held",
47 _ => return None,
48 },
49 crate::github_text::ASK_NODE => match label {
50 crate::github_text::POST_ORIGINAL => "post the original text",
51 crate::github_text::USE_REWRITE => "post the displayed rewrite",
52 crate::github_text::USE_MY_TEXT => "use the owner's supplied text",
53 crate::github_text::USE_FALLBACK => "use the fallback text",
54 _ => return None,
55 },
56 crate::reconcile::NODE if q.seat == crate::reconcile::SEAT => match at {
57 0 => "push the local branch",
58 1 => "keep the remote branch",
59 _ => return None,
60 },
61 _ => return None,
62 };
63 Some(effect.into())
64}
65
66pub(crate) fn executable_conductor(q: &mut Question) {
69 let offered_choices = !q.choices.is_empty();
70 q.choices.retain(|c| q.actions.contains_key(c));
71 q.actions.retain(|c, _| q.choices.contains(c));
72 if offered_choices && q.choices.is_empty() {
73 let hold = "daemon: 保留を続ける".to_owned();
74 q.actions
75 .insert(hold.clone(), crate::ask::ChoiceAction::Hold);
76 q.choices.push(hold);
77 }
78}
79
80pub(crate) fn brief(q: &Question) -> String {
82 let mut text =
83 String::from("Exact choice effects (daemon/component applies; you only settle):");
84 for label in &q.choices {
85 text.push_str(&format!(
86 "\n- `{label}`: {}",
87 describe(q, label).unwrap_or_else(|| "unsupported legacy choice; do not settle".into())
88 ));
89 if let Some(action) = q.actions.get(label) {
90 text.push_str(&format!(
91 "\n Exact persisted action: {}",
92 serde_json::to_string(action).expect("choice actions serialize")
93 ));
94 }
95 }
96 text
97}
98
99#[cfg(test)]
100mod tests {
101 use super::*;
102 #[test]
103 fn manual_only_choices_are_replaced_and_new_unmapped_choices_are_rejected() {
104 let mut q = Question::new(
105 "task".into(),
106 crate::conduct::NODE.into(),
107 "conduct".into(),
108 "maintenance".into(),
109 String::new(),
110 vec!["operator: edit predecessor record then resume".into()],
111 );
112 let dir = tempfile::tempdir().unwrap();
113 let store = crate::ask::Questions::at(dir.path().join("questions"));
114 assert!(store.put_executable(&mut q).is_err());
115 executable_conductor(&mut q);
116 assert_eq!(q.choices.len(), 1);
117 assert!(matches!(
118 q.actions.values().next(),
119 Some(crate::ask::ChoiceAction::Hold)
120 ));
121 store.put_executable(&mut q).unwrap();
122 q.choices.clear();
123 q.actions.clear();
124 executable_conductor(&mut q);
125 assert!(q.choices.is_empty(), "free-text questions remain free text");
126 }
127
128 #[test]
129 fn structured_action_parser_preserves_equals_in_a_hold_reason() {
130 let (_, action) = crate::ask::ChoiceAction::parse(
131 r#"release={"do":"releasetask","task":"t","held_at":"2026-01-01T00:00:00Z","reason":"a=b"}"#, "").unwrap();
132 assert!(
133 matches!(action, crate::ask::ChoiceAction::ReleaseTask { reason, .. } if reason.as_deref() == Some("a=b"))
134 );
135 }
136}