1use std::io::{self, IsTerminal};
2
3use crate::cli::{
4 Cli, ColumnarCommand, Command, HnswCommand, IndexCommand, KvCommand, KvTxnCommand,
5 ProfileCommand,
6};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub struct BatchMode {
10 pub is_batch: bool,
11 pub is_tty: bool,
12 pub source: BatchModeSource,
13}
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum BatchModeSource {
17 Explicit,
18 Environment,
19 PipeDetected,
20 Default,
21}
22
23impl BatchMode {
24 pub fn detect(cli: &Cli) -> Self {
25 let forced = std::env::var("ALOPEX_TEST_TTY")
26 .map(|value| matches!(value.as_str(), "1" | "true" | "TRUE"))
27 .unwrap_or(false);
28 let is_tty = forced || io::stdin().is_terminal();
29 let env_mode = std::env::var("ALOPEX_MODE").ok();
30
31 Self::detect_with(cli, is_tty, env_mode.as_deref())
32 }
33
34 pub(crate) fn detect_with(cli: &Cli, is_tty: bool, env_mode: Option<&str>) -> Self {
35 if cli.batch {
36 return Self {
37 is_batch: true,
38 is_tty,
39 source: BatchModeSource::Explicit,
40 };
41 }
42
43 if matches!(env_mode, Some("batch")) {
44 return Self {
45 is_batch: true,
46 is_tty,
47 source: BatchModeSource::Environment,
48 };
49 }
50
51 if !is_tty {
52 return Self {
53 is_batch: true,
54 is_tty,
55 source: BatchModeSource::PipeDetected,
56 };
57 }
58
59 Self {
60 is_batch: false,
61 is_tty,
62 source: BatchModeSource::Default,
63 }
64 }
65
66 #[allow(dead_code)]
67 pub fn should_prompt(&self, cli: &Cli, is_destructive: bool) -> bool {
68 if cli.yes || self.is_batch {
69 return false;
70 }
71
72 is_destructive
73 }
74
75 #[allow(dead_code)]
76 pub fn should_show_progress(&self, explicit_progress: bool) -> bool {
77 let _ = self;
78 explicit_progress
79 }
80}
81
82#[allow(dead_code)]
83pub fn is_destructive_command(command: &Command) -> bool {
84 match command {
85 Command::Kv { command: kv_cmd } => matches!(
86 kv_cmd,
87 Some(KvCommand::Delete { .. } | KvCommand::Txn(KvTxnCommand::Delete { .. }))
88 ),
89 Command::Hnsw {
90 command: Some(HnswCommand::Drop { .. }),
91 } => true,
92 Command::Profile {
93 command: Some(ProfileCommand::Delete { .. }),
94 } => true,
95 Command::Columnar {
96 command: Some(ColumnarCommand::Index(IndexCommand::Drop { .. })),
97 } => true,
98 _ => false,
99 }
100}
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105 use clap::Parser;
106
107 fn parse_cli(args: &[&str]) -> Cli {
108 Cli::try_parse_from(args).expect("cli parse")
109 }
110
111 #[test]
112 fn detect_explicit_overrides_env_and_pipe() {
113 let cli = parse_cli(&["alopex", "--batch", "kv", "list"]);
114 let mode = BatchMode::detect_with(&cli, false, Some("batch"));
115
116 assert!(mode.is_batch);
117 assert_eq!(mode.source, BatchModeSource::Explicit);
118 assert!(!mode.is_tty);
119 }
120
121 #[test]
122 fn detect_env_overrides_pipe() {
123 let cli = parse_cli(&["alopex", "kv", "list"]);
124 let mode = BatchMode::detect_with(&cli, false, Some("batch"));
125
126 assert!(mode.is_batch);
127 assert_eq!(mode.source, BatchModeSource::Environment);
128 assert!(!mode.is_tty);
129 }
130
131 #[test]
132 fn detect_pipe_when_not_tty() {
133 let cli = parse_cli(&["alopex", "kv", "list"]);
134 let mode = BatchMode::detect_with(&cli, false, None);
135
136 assert!(mode.is_batch);
137 assert_eq!(mode.source, BatchModeSource::PipeDetected);
138 assert!(!mode.is_tty);
139 }
140
141 #[test]
142 fn detect_default_when_tty() {
143 let cli = parse_cli(&["alopex", "kv", "list"]);
144 let mode = BatchMode::detect_with(&cli, true, None);
145
146 assert!(!mode.is_batch);
147 assert_eq!(mode.source, BatchModeSource::Default);
148 assert!(mode.is_tty);
149 }
150
151 #[test]
152 fn should_prompt_respects_yes_and_batch() {
153 let cli = parse_cli(&["alopex", "--yes", "kv", "list"]);
154 let batch_mode = BatchMode {
155 is_batch: false,
156 is_tty: true,
157 source: BatchModeSource::Default,
158 };
159 let batch = BatchMode {
160 is_batch: true,
161 is_tty: true,
162 source: BatchModeSource::Explicit,
163 };
164
165 assert!(!batch_mode.should_prompt(&cli, true));
166 assert!(!batch.should_prompt(&cli, true));
167 }
168
169 #[test]
170 fn should_prompt_only_for_destructive_commands() {
171 let cli = parse_cli(&["alopex", "kv", "list"]);
172 let mode = BatchMode {
173 is_batch: false,
174 is_tty: true,
175 source: BatchModeSource::Default,
176 };
177
178 assert!(mode.should_prompt(&cli, true));
179 assert!(!mode.should_prompt(&cli, false));
180 }
181
182 #[test]
183 fn should_show_progress_respects_explicit_flag() {
184 let batch = BatchMode {
185 is_batch: true,
186 is_tty: false,
187 source: BatchModeSource::PipeDetected,
188 };
189 let interactive = BatchMode {
190 is_batch: false,
191 is_tty: true,
192 source: BatchModeSource::Default,
193 };
194
195 assert!(batch.should_show_progress(true));
196 assert!(!batch.should_show_progress(false));
197 assert!(!interactive.should_show_progress(false));
198 assert!(interactive.should_show_progress(true));
199 }
200
201 #[test]
202 fn destructive_command_detection() {
203 let kv_delete = Command::Kv {
204 command: Some(KvCommand::Delete { key: "key".into() }),
205 };
206 let kv_list = Command::Kv {
207 command: Some(KvCommand::List { prefix: None }),
208 };
209 let profile_delete = Command::Profile {
210 command: Some(ProfileCommand::Delete { name: "dev".into() }),
211 };
212 let hnsw_drop = Command::Hnsw {
213 command: Some(HnswCommand::Drop { name: "idx".into() }),
214 };
215
216 assert!(is_destructive_command(&kv_delete));
217 assert!(!is_destructive_command(&kv_list));
218 assert!(is_destructive_command(&profile_delete));
219 assert!(is_destructive_command(&hnsw_drop));
220
221 let kv_txn_delete = Command::Kv {
222 command: Some(KvCommand::Txn(KvTxnCommand::Delete {
223 key: "key".into(),
224 txn_id: "txn".into(),
225 })),
226 };
227 let columnar_index_drop = Command::Columnar {
228 command: Some(ColumnarCommand::Index(IndexCommand::Drop {
229 segment: "seg".into(),
230 column: "col".into(),
231 })),
232 };
233
234 assert!(is_destructive_command(&kv_txn_delete));
235 assert!(is_destructive_command(&columnar_index_drop));
236 }
237}