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 = ["docker", "terraform", "helm", "kubectl", "npm", "cargo", "go"];
76 if parts.len() >= 2 && compound_commands.contains(&parts[0]) {
77 format!("{} {}", parts[0], parts[1])
78 } else {
79 parts[0].to_string()
80 }
81}
82
83fn display_command_box(command: &str, working_dir: &str) {
85 let term_width = term_size::dimensions().map(|(w, _)| w).unwrap_or(80);
86 let box_width = term_width.min(70);
87 let inner_width = box_width - 4; println!(
91 "{}",
92 format!(
93 "{}{}{}",
94 "┌─ Bash command ".dimmed(),
95 "─".repeat(inner_width.saturating_sub(15)).dimmed(),
96 "┐".dimmed()
97 )
98 );
99
100 let command_lines = textwrap::wrap(command, inner_width - 2);
102 for line in &command_lines {
103 println!(
104 "{} {}{}",
105 "│".dimmed(),
106 line.cyan().bold(),
107 " ".repeat(inner_width.saturating_sub(line.len() + 2))
108 );
109 }
110
111 let dir_display = format!("in {}", working_dir);
113 println!(
114 "{} {}{}{}",
115 "│".dimmed(),
116 dir_display.dimmed(),
117 " ".repeat(inner_width.saturating_sub(dir_display.len() + 2)),
118 "│".dimmed()
119 );
120
121 println!(
123 "{}",
124 format!(
125 "{}{}{}",
126 "└".dimmed(),
127 "─".repeat(box_width - 2).dimmed(),
128 "┘".dimmed()
129 )
130 );
131 println!();
132}
133
134pub fn confirm_shell_command(
141 command: &str,
142 working_dir: &str,
143) -> ConfirmationResult {
144 display_command_box(command, working_dir);
145
146 let prefix = extract_command_prefix(command);
147 let short_dir = std::path::Path::new(working_dir)
148 .file_name()
149 .map(|n| n.to_string_lossy().to_string())
150 .unwrap_or_else(|| working_dir.to_string());
151
152 let options = vec![
153 format!("Yes"),
154 format!("Yes, and don't ask again for `{}` commands in {}", prefix, short_dir),
155 format!("Type here to tell Syncable Agent what to do differently"),
156 ];
157
158 println!("{}", "Do you want to proceed?".white());
159
160 let selection = Select::new("", options.clone())
161 .with_render_config(get_confirmation_render_config())
162 .with_page_size(3) .with_help_message("↑↓ to move, Enter to select, Esc to cancel")
164 .prompt();
165
166 match selection {
167 Ok(answer) => {
168 if answer == options[0] {
169 ConfirmationResult::Proceed
170 } else if answer == options[1] {
171 ConfirmationResult::ProceedAlways(prefix)
172 } else {
173 println!();
175 match Text::new("What should I do instead?")
176 .with_help_message("Press Enter to submit, Esc to cancel")
177 .prompt()
178 {
179 Ok(feedback) if !feedback.trim().is_empty() => {
180 ConfirmationResult::Modify(feedback)
181 }
182 _ => ConfirmationResult::Cancel,
183 }
184 }
185 }
186 Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => {
187 ConfirmationResult::Cancel
188 }
189 Err(_) => ConfirmationResult::Cancel,
190 }
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196
197 #[test]
198 fn test_extract_command_prefix() {
199 assert_eq!(extract_command_prefix("docker build -t test ."), "docker build");
200 assert_eq!(extract_command_prefix("npm run test"), "npm run");
201 assert_eq!(extract_command_prefix("cargo build"), "cargo build");
202 assert_eq!(extract_command_prefix("make"), "make");
203 assert_eq!(extract_command_prefix("hadolint Dockerfile"), "hadolint");
204 }
205
206 #[test]
207 fn test_allowed_commands() {
208 let allowed = AllowedCommands::new();
209 assert!(!allowed.is_allowed("docker build -t test ."));
210
211 allowed.allow("docker build".to_string());
212 assert!(allowed.is_allowed("docker build -t test ."));
213 assert!(allowed.is_allowed("docker build --no-cache ."));
214 assert!(!allowed.is_allowed("docker run test"));
215 }
216}