1use clap::{Arg, ArgAction, ArgMatches, Command};
2use freemobile_api::{Credentials, FreeMobileError};
3use is_terminal::IsTerminal;
4use std::env;
5use std::path::PathBuf;
6
7type Validator = fn(&str) -> Result<(), FreeMobileError>;
8
9#[derive(Debug, Clone)]
10pub struct Config {
11 pub credentials: Credentials,
12 pub message: Option<String>,
13 pub file_path: Option<PathBuf>,
14 pub verbose: bool,
15}
16
17impl Config {
18 pub fn from_args() -> Result<Self, FreeMobileError> {
19 dotenv::dotenv().ok(); let matches = Self::build_cli().get_matches();
22 Self::from_matches(&matches)
23 }
24
25 pub fn from_matches(matches: &ArgMatches) -> Result<Self, FreeMobileError> {
26 let user = Self::get_user_id(matches)?;
27 let pass = Self::get_api_key(matches)?;
28 let credentials = Credentials::new(user, pass);
29
30 let config = Config {
31 credentials,
32 message: matches.get_one::<String>("message").cloned(),
33 file_path: matches.get_one::<String>("file").map(PathBuf::from),
34 verbose: matches.get_flag("verbose"),
35 };
36
37 Ok(config)
38 }
39
40 fn build_cli() -> Command {
41 Command::new("send-sms")
42 .version(env!("CARGO_PKG_VERSION"))
43 .author("davlgd")
44 .about("Send SMS messages via FreeMobile API")
45 .arg(
46 Arg::new("user")
47 .short('u')
48 .long("user")
49 .env("FREEMOBILE_USER")
50 .value_name("USER_ID")
51 .help("FreeMobile user ID (8 digits)")
52 .required(false),
53 )
54 .arg(
55 Arg::new("pass")
56 .short('p')
57 .long("pass")
58 .env("FREEMOBILE_PASS")
59 .value_name("API_KEY")
60 .help("FreeMobile API key")
61 .required(false),
62 )
63 .arg(
64 Arg::new("message")
65 .short('m')
66 .long("message")
67 .value_name("TEXT")
68 .help("Message to send")
69 .conflicts_with("file"),
70 )
71 .arg(
72 Arg::new("file")
73 .short('f')
74 .long("file")
75 .value_name("PATH")
76 .help("Read message from file")
77 .conflicts_with("message"),
78 )
79 .arg(
80 Arg::new("verbose")
81 .short('v')
82 .long("verbose")
83 .help("Verbose output")
84 .action(ArgAction::SetTrue),
85 )
86 }
87
88 fn get_config_value(
89 matches: &ArgMatches,
90 cli_arg: &str,
91 env_var: &str,
92 error_message: &str,
93 field_name: &str,
94 validator: Option<Validator>,
95 ) -> Result<String, FreeMobileError> {
96 matches
97 .get_one::<String>(cli_arg)
98 .cloned()
99 .or_else(|| env::var(env_var).ok())
100 .ok_or_else(|| {
101 if error_message.is_empty() {
102 FreeMobileError::ConfigError("Value not found".to_string())
103 } else {
104 FreeMobileError::ConfigError(error_message.to_string())
105 }
106 })
107 .and_then(|value| {
108 if value.trim().is_empty() {
109 Err(FreeMobileError::ConfigError(format!(
110 "{} cannot be empty",
111 field_name
112 )))
113 } else {
114 if let Some(validate) = validator {
115 validate(&value)?;
116 }
117 Ok(value)
118 }
119 })
120 }
121
122 fn validate_user_id(user_id: &str) -> Result<(), FreeMobileError> {
123 if !user_id.chars().all(|c| c.is_ascii_digit()) || user_id.len() != 8 {
124 Err(FreeMobileError::ConfigError(
125 "User ID must be exactly 8 digits".to_string(),
126 ))
127 } else {
128 Ok(())
129 }
130 }
131
132 fn get_user_id(matches: &ArgMatches) -> Result<String, FreeMobileError> {
133 let result = Self::get_config_value(
135 matches,
136 "user",
137 "FREEMOBILE_USER",
138 "FreeMobile user ID not found. Set FREEMOBILE_USER environment variable or use -u option",
139 "User ID",
140 Some(Self::validate_user_id),
141 );
142
143 match result {
144 Ok(user_id) => Ok(user_id),
145 Err(err) => {
146 if cfg!(test) || !std::io::stdin().is_terminal() {
148 return Err(err);
149 }
150 Self::prompt_for_user_id()
152 }
153 }
154 }
155
156 fn get_api_key(matches: &ArgMatches) -> Result<String, FreeMobileError> {
157 let result = Self::get_config_value(
159 matches,
160 "pass",
161 "FREEMOBILE_PASS",
162 "FreeMobile API key not found. Set FREEMOBILE_PASS environment variable or use -p option",
163 "API key",
164 None,
165 );
166
167 match result {
168 Ok(api_key) => Ok(api_key),
169 Err(err) => {
170 if cfg!(test) || !std::io::stdin().is_terminal() {
172 return Err(err);
173 }
174 Self::prompt_for_api_key()
176 }
177 }
178 }
179
180 fn prompt_for_user_id() -> Result<String, FreeMobileError> {
181 use inquire::Text;
182
183 let user_id = Text::new("FreeMobile User ID:")
184 .with_help_message("8-digit user ID from your FreeMobile account")
185 .prompt()
186 .map_err(|e| {
187 if e.to_string().contains("interrupted") {
188 eprintln!("Operation cancelled by user");
189 std::process::exit(1);
190 } else {
191 FreeMobileError::ConfigError(format!("Failed to read user ID: {}", e))
192 }
193 })?;
194
195 if user_id.trim().is_empty() {
196 return Err(FreeMobileError::ConfigError(
197 "User ID cannot be empty".to_string(),
198 ));
199 }
200
201 Self::validate_user_id(&user_id)?;
202 Ok(user_id)
203 }
204
205 fn prompt_for_api_key() -> Result<String, FreeMobileError> {
206 use inquire::Password;
207
208 let api_key = Password::new("FreeMobile API Key:")
209 .with_help_message("API key from your FreeMobile account settings")
210 .without_confirmation()
211 .with_display_mode(inquire::PasswordDisplayMode::Masked)
212 .prompt()
213 .map_err(|e| {
214 if e.to_string().contains("interrupted") {
215 eprintln!("Operation cancelled by user");
216 std::process::exit(1);
217 } else {
218 FreeMobileError::ConfigError(format!("Failed to read API key: {}", e))
219 }
220 })?;
221
222 if api_key.trim().is_empty() {
223 return Err(FreeMobileError::ConfigError(
224 "API key cannot be empty".to_string(),
225 ));
226 }
227
228 Ok(api_key)
229 }
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235 use clap::ArgMatches;
236
237 fn create_test_matches(args: &[&str]) -> ArgMatches {
238 Config::build_cli().try_get_matches_from(args).unwrap()
239 }
240
241 #[test]
242 fn test_config_with_message() {
243 unsafe {
244 env::set_var("FREEMOBILE_USER", "12345678");
245 env::set_var("FREEMOBILE_PASS", "testkey");
246 }
247
248 let matches = create_test_matches(&["send-sms", "-m", "Hello world"]);
249 let config = Config::from_matches(&matches).unwrap();
250
251 assert_eq!(config.credentials.user, "12345678");
252 assert_eq!(config.credentials.pass, "testkey");
253 assert_eq!(config.message.unwrap(), "Hello world");
254 assert!(!config.verbose);
255 }
256
257 #[test]
258 fn test_invalid_user_id() {
259 let matches =
260 create_test_matches(&["send-sms", "-u", "invalid", "-p", "key", "-m", "test"]);
261 let result = Config::from_matches(&matches);
262
263 assert!(result.is_err());
264 assert!(matches!(
265 result.unwrap_err(),
266 FreeMobileError::ConfigError(_)
267 ));
268 }
269
270 #[test]
271 fn test_no_message_source_is_valid() {
272 unsafe {
273 env::set_var("FREEMOBILE_USER", "12345678");
274 env::set_var("FREEMOBILE_PASS", "testkey");
275 }
276
277 let matches = create_test_matches(&["send-sms"]);
278 let result = Config::from_matches(&matches);
279
280 assert!(result.is_ok());
282 let config = result.unwrap();
283 assert!(config.message.is_none());
284 assert!(config.file_path.is_none());
285 assert!(!config.verbose);
286 }
287
288 #[test]
289 fn test_get_config_value_with_cli_args() {
290 let matches = Config::build_cli()
292 .try_get_matches_from(&["send-sms", "-u", "11111111", "-p", "cli-key", "-m", "test"])
293 .unwrap();
294
295 let user_result = Config::get_config_value(
296 &matches,
297 "user",
298 "FREEMOBILE_USER",
299 "User not found",
300 "User ID",
301 Some(Config::validate_user_id),
302 );
303 let pass_result = Config::get_config_value(
304 &matches,
305 "pass",
306 "FREEMOBILE_PASS",
307 "Pass not found",
308 "API key",
309 None,
310 );
311
312 assert_eq!(user_result.unwrap(), "11111111");
313 assert_eq!(pass_result.unwrap(), "cli-key");
314 }
315
316 #[test]
317 fn test_validate_user_id_function() {
318 assert!(Config::validate_user_id("12345678").is_ok());
320 assert!(Config::validate_user_id("1234567").is_err()); assert!(Config::validate_user_id("123456789").is_err()); assert!(Config::validate_user_id("1234567a").is_err()); assert!(Config::validate_user_id("").is_err()); }
325
326 #[test]
327 fn test_interactive_prompt_detection() {
328 assert!(cfg!(test), "This test should run in test mode");
332
333 let _is_tty = std::io::stdin().is_terminal();
335 }
338}