Skip to main content

magi/
choice_effect.rs

1//! One description of each executable question choice, shared by cards and briefs.
2use crate::{ask::Question, land, merge_decision, release_watch, triage};
3
4/// None means this label promises no operation implemented by its issuer.
5/// Positional protocols are confined to nodes whose consumers use positions.
6pub 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
66/// Generated conductor prose cannot delegate manual maintenance to the owner.
67/// Keep only explicitly executable choices; a safe hold is always available.
68pub(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
80/// Regenerated each turn so old deputy briefs cannot conceal new effects.
81pub(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}