Skip to main content

opendev_tui/controllers/
approval.rs

1//! Approval prompt controller for inline command approval within the TUI.
2//!
3//! Mirrors Python's `ApprovalPromptController` from
4//! `opendev/ui_textual/controllers/approval_prompt_controller.py`.
5//!
6//! The controller manages a state machine:
7//! 1. A tool requests approval → `start()` activates the prompt
8//! 2. User navigates options with Up/Down, confirms with Enter, cancels with Esc
9//! 3. Result is sent back via a oneshot channel
10
11use tokio::sync::oneshot;
12
13/// User's decision from the approval prompt.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct ApprovalDecision {
16    /// Whether the action was approved.
17    pub approved: bool,
18    /// Which option was selected ("1", "2", or "3").
19    pub choice: String,
20    /// The (potentially edited) command text.
21    pub command: String,
22}
23
24/// A single option in the approval prompt.
25#[derive(Debug, Clone)]
26pub struct ApprovalOption {
27    /// Display choice key (e.g. "1", "2", "3").
28    pub choice: String,
29    /// Short label (e.g. "Yes", "No").
30    pub label: String,
31    /// Longer description.
32    pub description: String,
33    /// Whether selecting this option means approval.
34    pub approved: bool,
35}
36
37/// Manages the inline approval prompt state machine.
38pub struct ApprovalController {
39    active: bool,
40    options: Vec<ApprovalOption>,
41    selected_index: usize,
42    command: String,
43    working_dir: String,
44    response_tx: Option<oneshot::Sender<ApprovalDecision>>,
45}
46
47impl ApprovalController {
48    /// Create a new inactive approval controller.
49    pub fn new() -> Self {
50        Self {
51            active: false,
52            options: Vec::new(),
53            selected_index: 0,
54            command: String::new(),
55            working_dir: String::from("."),
56            response_tx: None,
57        }
58    }
59
60    /// Whether the approval prompt is currently active.
61    pub fn active(&self) -> bool {
62        self.active
63    }
64
65    /// The command being approved.
66    pub fn command(&self) -> &str {
67        &self.command
68    }
69
70    /// The working directory for the command.
71    pub fn working_dir(&self) -> &str {
72        &self.working_dir
73    }
74
75    /// The available options.
76    pub fn options(&self) -> &[ApprovalOption] {
77        &self.options
78    }
79
80    /// The currently selected option index.
81    pub fn selected_index(&self) -> usize {
82        self.selected_index
83    }
84
85    /// Start the approval prompt for a command.
86    ///
87    /// Returns a receiver that will yield the user's decision.
88    pub fn start(
89        &mut self,
90        command: String,
91        working_dir: String,
92    ) -> oneshot::Receiver<ApprovalDecision> {
93        let base_prefix = command.split_whitespace().next().unwrap_or("").to_string();
94
95        let auto_desc = if !base_prefix.is_empty() {
96            format!(
97                "Automatically approve commands starting with '{}' in {}.",
98                base_prefix, working_dir
99            )
100        } else {
101            format!("Automatically approve future commands in {}.", working_dir)
102        };
103
104        self.options = vec![
105            ApprovalOption {
106                choice: "1".into(),
107                label: "Yes".into(),
108                description: "Run this command now.".into(),
109                approved: true,
110            },
111            ApprovalOption {
112                choice: "2".into(),
113                label: "Yes, and don't ask again".into(),
114                description: auto_desc,
115                approved: true,
116            },
117            ApprovalOption {
118                choice: "3".into(),
119                label: "No".into(),
120                description: "Cancel and adjust your request.".into(),
121                approved: false,
122            },
123        ];
124
125        self.command = command;
126        self.working_dir = working_dir;
127        self.selected_index = 0;
128        self.active = true;
129
130        let (tx, rx) = oneshot::channel();
131        self.response_tx = Some(tx);
132        rx
133    }
134
135    /// Move the selection by `delta` positions (wrapping).
136    pub fn move_selection(&mut self, delta: i32) {
137        if !self.active || self.options.is_empty() {
138            return;
139        }
140        let len = self.options.len() as i32;
141        let new_idx = ((self.selected_index as i32) + delta).rem_euclid(len);
142        self.selected_index = new_idx as usize;
143    }
144
145    /// Confirm the current selection and send the decision.
146    pub fn confirm(&mut self) {
147        if !self.active {
148            return;
149        }
150
151        let option = &self.options[self.selected_index];
152        let decision = ApprovalDecision {
153            approved: option.approved,
154            choice: option.choice.clone(),
155            command: self.command.clone(),
156        };
157
158        if let Some(tx) = self.response_tx.take() {
159            let _ = tx.send(decision);
160        }
161
162        self.cleanup();
163    }
164
165    /// Cancel the approval (selects "No" and confirms).
166    pub fn cancel(&mut self) {
167        if !self.active || self.options.is_empty() {
168            return;
169        }
170        // Select the last option ("No")
171        self.selected_index = self.options.len() - 1;
172        self.confirm();
173    }
174
175    /// Reset the controller to inactive state.
176    fn cleanup(&mut self) {
177        self.active = false;
178        self.options.clear();
179        self.selected_index = 0;
180        self.command.clear();
181        self.response_tx = None;
182    }
183}
184
185impl Default for ApprovalController {
186    fn default() -> Self {
187        Self::new()
188    }
189}
190
191#[cfg(test)]
192#[path = "approval_tests.rs"]
193mod tests;