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 format!(
101 "{}{}{}",
102 "┌─ Bash command ".dimmed(),
103 "─".repeat(inner_width.saturating_sub(15)).dimmed(),
104 "┐".dimmed()
105 )
106 );
107
108 let command_lines = textwrap::wrap(command, inner_width - 2);
110 for line in &command_lines {
111 println!(
112 "{} {}{}",
113 "│".dimmed(),
114 line.cyan().bold(),
115 " ".repeat(inner_width.saturating_sub(line.len() + 2))
116 );
117 }
118
119 let dir_display = format!("in {}", working_dir);
121 println!(
122 "{} {}{}{}",
123 "│".dimmed(),
124 dir_display.dimmed(),
125 " ".repeat(inner_width.saturating_sub(dir_display.len() + 2)),
126 "│".dimmed()
127 );
128
129 println!(
131 "{}",
132 format!(
133 "{}{}{}",
134 "└".dimmed(),
135 "─".repeat(box_width - 2).dimmed(),
136 "┘".dimmed()
137 )
138 );
139 println!();
140}
141
142pub fn confirm_shell_command(command: &str, working_dir: &str) -> ConfirmationResult {
149 display_command_box(command, working_dir);
150
151 let prefix = extract_command_prefix(command);
152 let short_dir = std::path::Path::new(working_dir)
153 .file_name()
154 .map(|n| n.to_string_lossy().to_string())
155 .unwrap_or_else(|| working_dir.to_string());
156
157 let options = vec![
158 format!("Yes"),
159 format!(
160 "Yes, and don't ask again for `{}` commands in {}",
161 prefix, short_dir
162 ),
163 format!("Type here to tell Syncable Agent what to do differently"),
164 ];
165
166 println!("{}", "Do you want to proceed?".white());
167
168 let selection = Select::new("", options.clone())
169 .with_render_config(get_confirmation_render_config())
170 .with_page_size(3) .with_help_message("↑↓ to move, Enter to select, Esc to cancel")
172 .prompt();
173
174 match selection {
175 Ok(answer) => {
176 if answer == options[0] {
177 ConfirmationResult::Proceed
178 } else if answer == options[1] {
179 ConfirmationResult::ProceedAlways(prefix)
180 } else {
181 println!();
183 match Text::new("What should I do instead?")
184 .with_help_message("Press Enter to submit, Esc to cancel")
185 .prompt()
186 {
187 Ok(feedback) if !feedback.trim().is_empty() => {
188 ConfirmationResult::Modify(feedback)
189 }
190 _ => ConfirmationResult::Cancel,
191 }
192 }
193 }
194 Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => {
195 ConfirmationResult::Cancel
196 }
197 Err(_) => ConfirmationResult::Cancel,
198 }
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204
205 #[test]
206 fn test_extract_command_prefix() {
207 assert_eq!(
208 extract_command_prefix("docker build -t test ."),
209 "docker build"
210 );
211 assert_eq!(extract_command_prefix("npm run test"), "npm run");
212 assert_eq!(extract_command_prefix("cargo build"), "cargo build");
213 assert_eq!(extract_command_prefix("make"), "make");
214 assert_eq!(extract_command_prefix("hadolint Dockerfile"), "hadolint");
215 }
216
217 #[test]
218 fn test_allowed_commands() {
219 let allowed = AllowedCommands::new();
220 assert!(!allowed.is_allowed("docker build -t test ."));
221
222 allowed.allow("docker build".to_string());
223 assert!(allowed.is_allowed("docker build -t test ."));
224 assert!(allowed.is_allowed("docker build --no-cache ."));
225 assert!(!allowed.is_allowed("docker run test"));
226 }
227}