Skip to main content

send_sms/
config.rs

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(); // Load .env file if it exists
20
21        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        // Try CLI args and env vars first
134        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                // Don't prompt during tests (when running in CI or non-TTY environment)
147                if cfg!(test) || !std::io::stdin().is_terminal() {
148                    return Err(err);
149                }
150                // Interactive prompt for missing user ID
151                Self::prompt_for_user_id()
152            }
153        }
154    }
155
156    fn get_api_key(matches: &ArgMatches) -> Result<String, FreeMobileError> {
157        // Try CLI args and env vars first
158        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                // Don't prompt during tests (when running in CI or non-TTY environment)
171                if cfg!(test) || !std::io::stdin().is_terminal() {
172                    return Err(err);
173                }
174                // Interactive prompt for missing API key
175                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        // Should not fail - will fall back to interactive mode
281        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        // Test that CLI args have priority over env vars
291        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        // Test the validator function directly
319        assert!(Config::validate_user_id("12345678").is_ok());
320        assert!(Config::validate_user_id("1234567").is_err()); // too short
321        assert!(Config::validate_user_id("123456789").is_err()); // too long
322        assert!(Config::validate_user_id("1234567a").is_err()); // contains letter
323        assert!(Config::validate_user_id("").is_err()); // empty
324    }
325
326    #[test]
327    fn test_interactive_prompt_detection() {
328        // Test the logic that determines when to show interactive prompts
329
330        // In test environment, prompts should be disabled
331        assert!(cfg!(test), "This test should run in test mode");
332
333        // Test that TTY detection works (will be false in CI/test environment)
334        let _is_tty = std::io::stdin().is_terminal();
335        // In test environment, this is usually false, which is what we want
336        // (prompts disabled when not interactive)
337    }
338}