Skip to main content

alopex_cli/batch/
detector.rs

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}