syncable_cli/agent/ui/
confirmation.rs1use colored::Colorize;
7use inquire::ui::{Color, IndexPrefix, RenderConfig, StyleSheet, Styled};
8use inquire::{InquireError, Select, Text};
9use std::collections::HashSet;
10use std::sync::Mutex;
11
12fn get_confirmation_render_config() -> RenderConfig<'static> {
14 RenderConfig::default()
15 .with_highlighted_option_prefix(Styled::new("> ").with_fg(Color::LightCyan))
16 .with_option_index_prefix(IndexPrefix::Simple)
17 .with_selected_option(Some(StyleSheet::new().with_fg(Color::LightCyan)))
18 .with_scroll_up_prefix(Styled::new("▲ "))
19 .with_scroll_down_prefix(Styled::new("▼ "))
20}
21
22#[derive(Debug, Clone)]
24pub enum ConfirmationResult {
25 Proceed,
27 ProceedAlways(String), Modify(String),
31 Cancel,
33}
34
35#[derive(Debug)]
37pub struct AllowedCommands {
38 prefixes: Mutex<HashSet<String>>,
39}
40
41impl AllowedCommands {
42 pub fn new() -> Self {
43 Self {
44 prefixes: Mutex::new(HashSet::new()),
45 }
46 }
47
48 pub fn is_allowed(&self, command: &str) -> bool {
50 let prefixes = self.prefixes.lock().unwrap();
51 prefixes.iter().any(|prefix| command.starts_with(prefix))
52 }
53
54 pub fn allow(&self, prefix: String) {
56 let mut prefixes = self.prefixes.lock().unwrap();
57 prefixes.insert(prefix);
58 }
59}
60
61impl Default for AllowedCommands {
62 fn default() -> Self {
63 Self::new()
64 }
65}
66
67fn extract_command_prefix(command: &str) -> String {
69 let parts: Vec<&str> = command.split_whitespace().collect();
70 if parts.is_empty() {
71 return command.to_string();
72 }
73
74 let compound_commands = [
76 "docker",
77 "terraform",
78 "helm",
79 "kubectl",
80 "npm",
81 "cargo",
82 "go",
83 ];
84 if parts.len() >= 2 && compound_commands.contains(&parts[0]) {
85 format!("{} {}", parts[0], parts[1])
86 } else {
87 parts[0].to_string()
88 }
89}
90
91fn display_command_box(command: &str, working_dir: &str) {
93 let term_width = term_size::dimensions().map(|(w, _)| w).unwrap_or(80);
94 let box_width = term_width.min(70);
95 let inner_width = box_width - 4; println!(
99 "{}{}{}",
100 "┌─ Bash command ".dimmed(),
101 "─".repeat(inner_width.saturating_sub(15)).dimmed(),
102 "┐".dimmed()
103 );
104
105 let command_lines = textwrap::wrap(command, inner_width - 2);
107 for line in &command_lines {
108 println!(
109 "{} {}{}",
110 "│".dimmed(),
111 line.cyan().bold(),
112 " ".repeat(inner_width.saturating_sub(line.len() + 2))
113 );
114 }
115
116 let dir_display = format!("in {}", working_dir);
118 println!(
119 "{} {}{}{}",
120 "│".dimmed(),
121 dir_display.dimmed(),
122 " ".repeat(inner_width.saturating_sub(dir_display.len() + 2)),
123 "│".dimmed()
124 );
125
126 println!(
128 "{}{}{}",
129 "└".dimmed(),
130 "─".repeat(box_width - 2).dimmed(),
131 "┘".dimmed()
132 );
133 println!();
134}
135
136pub fn confirm_shell_command(command: &str, working_dir: &str) -> ConfirmationResult {
143 display_command_box(command, working_dir);
144
145 let prefix = extract_command_prefix(command);
146 let short_dir = std::path::Path::new(working_dir)
147 .file_name()
148 .map(|n| n.to_string_lossy().to_string())
149 .unwrap_or_else(|| working_dir.to_string());
150
151 let options = vec![
152 format!("Yes"),
153 format!(
154 "Yes, and don't ask again for `{}` commands in {}",
155 prefix, short_dir
156 ),
157 format!("Type here to tell Syncable Agent what to do differently"),
158 ];
159
160 println!("{}", "Do you want to proceed?".white());
161
162 let selection = Select::new("", options.clone())
163 .with_render_config(get_confirmation_render_config())
164 .with_page_size(3) .with_help_message("↑↓ to move, Enter to select, Esc to cancel")
166 .prompt();
167
168 match selection {
169 Ok(answer) => {
170 if answer == options[0] {
171 ConfirmationResult::Proceed
172 } else if answer == options[1] {
173 ConfirmationResult::ProceedAlways(prefix)
174 } else {
175 println!();
177 match Text::new("What should I do instead?")
178 .with_help_message("Press Enter to submit, Esc to cancel")
179 .prompt()
180 {
181 Ok(feedback) if !feedback.trim().is_empty() => {
182 ConfirmationResult::Modify(feedback)
183 }
184 _ => ConfirmationResult::Cancel,
185 }
186 }
187 }
188 Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => {
189 ConfirmationResult::Cancel
190 }
191 Err(_) => ConfirmationResult::Cancel,
192 }
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198
199 #[test]
200 fn test_extract_command_prefix() {
201 assert_eq!(
202 extract_command_prefix("docker build -t test ."),
203 "docker build"
204 );
205 assert_eq!(extract_command_prefix("npm run test"), "npm run");
206 assert_eq!(extract_command_prefix("cargo build"), "cargo build");
207 assert_eq!(extract_command_prefix("make"), "make");
208 assert_eq!(extract_command_prefix("hadolint Dockerfile"), "hadolint");
209 }
210
211 #[test]
212 fn test_allowed_commands() {
213 let allowed = AllowedCommands::new();
214 assert!(!allowed.is_allowed("docker build -t test ."));
215
216 allowed.allow("docker build".to_string());
217 assert!(allowed.is_allowed("docker build -t test ."));
218 assert!(allowed.is_allowed("docker build --no-cache ."));
219 assert!(!allowed.is_allowed("docker run test"));
220 }
221}