Skip to main content

apimock/
args.rs

1use std::{env, fs, io, path::Path};
2
3pub mod constant;
4pub mod init_interactive;
5
6use constant::*;
7
8use anyhow::{Context, Result as AppResult, bail};
9
10/// CLI arguments parsed at process start-up.
11///
12/// # Why these three fields are the only command-line surface
13///
14/// `apimock` deliberately keeps its CLI tiny: config-file path, port,
15/// fallback respond dir. Anything richer than that belongs in the TOML
16/// config so that it can be checked in with the rest of the mock setup
17/// and reproduced between machines. The three CLI flags exist only for
18/// quick ad-hoc overrides that don't warrant editing the config file.
19#[derive(Clone)]
20pub struct EnvArgs {
21    /// path to the config TOML file (usually `./apimock.toml`)
22    pub config_file_path: Option<String>,
23    /// overrides `listener.port` in the config file
24    pub port: Option<u16>,
25    /// overrides `service.fallback_respond_dir` in the config file
26    pub fallback_respond_dir_path: Option<String>,
27}
28
29impl EnvArgs {
30    /// Parse `env::args()` and apply defaults.
31    ///
32    /// Returns:
33    /// - `Ok(Some(args))` for the normal "start the server" path,
34    /// - `Ok(None)` when a meta command (e.g. `--init`) has already
35    ///   completed its side effect and the process should exit cleanly,
36    /// - `Err(_)` when an argument was malformed or a referenced file
37    ///   is missing.
38    ///
39    /// # Why return `AppResult<Option<_>>` instead of panicking
40    ///
41    /// Previously invalid arguments triggered `panic!`, which printed a
42    /// backtrace for a user-level error. Returning a typed error lets the
43    /// binary print "invalid port: foo" and exit 1, which is what users
44    /// of CLI tools actually expect.
45    pub fn default() -> AppResult<Option<Self>> {
46        let mut ret = EnvArgs::from_args()?;
47
48        // `apimock match-test …` — dry-run rule matching.
49        let raw: Vec<String> = env::args().collect();
50        if raw.get(1).map(String::as_str) == Some("match-test") {
51            crate::cmd::match_test::run(&raw[2..])?;
52            return Ok(None); // run() calls process::exit; this is unreachable
53        }
54
55        let init_config = args_option_value(INIT_CONFIG_OPTION_NAMES.as_ref()).is_some();
56        if init_config {
57            let includes_middleware =
58                args_option_value(INCLUDES_MIDDLEWARE_OPTION_NAMES.as_ref()).is_some();
59            let force_defaults = args_option_value(YES_OPTION_NAMES.as_ref()).is_some();
60            // Drive the interactive prompt (or fall back to defaults in
61            // non-TTY / --yes contexts). We log but don't propagate the
62            // error: a failed init is a user-level problem, and forcing
63            // the binary to exit 1 on a partial write would be more
64            // disruptive than informative.
65            if let Err(err) = ret.init_config_interactive(includes_middleware, force_defaults) {
66                log::error!("failed to init config ({})", err);
67            }
68            return Ok(None);
69        }
70
71        ret.default_config_file_path();
72        ret.validate()?;
73
74        Ok(Some(ret))
75    }
76
77    /// Ensure paths referenced by CLI flags actually exist.
78    ///
79    /// We only check existence, not permission or content — a file the
80    /// process can see but can't read will still produce a better error
81    /// downstream at the point it's actually used.
82    pub fn validate(&self) -> AppResult<()> {
83        if let Some(config_file_path) = self.config_file_path.as_ref() {
84            if !Path::new(config_file_path.as_str()).exists() {
85                bail!(
86                    "config file specified via --config does not exist: {}",
87                    config_file_path
88                );
89            }
90        }
91
92        if let Some(fallback_respond_dir_path) = self.fallback_respond_dir_path.as_ref() {
93            if !Path::new(fallback_respond_dir_path.as_str()).exists() {
94                bail!(
95                    "fallback response dir specified via --dir does not exist: {}",
96                    fallback_respond_dir_path
97                );
98            }
99        }
100
101        Ok(())
102    }
103
104    /// Build an `EnvArgs` by reading `env::args()`.
105    fn from_args() -> AppResult<Self> {
106        let port = match args_option_value(CONFIG_LISTENER_PORT_OPTION_NAMES.as_ref()) {
107            Some(port_str) => Some(port_str.parse::<u16>().with_context(|| {
108                format!("--port value is not a valid u16: {}", port_str)
109            })?),
110            None => None,
111        };
112
113        Ok(EnvArgs {
114            config_file_path: args_option_value(CONFIG_FILE_PATH_OPTION_NAMES.as_ref()),
115            port,
116            fallback_respond_dir_path: args_option_value(
117                FALLBACK_RESPOND_DIR_PATH_OPTION_NAMES.as_ref(),
118            ),
119        })
120    }
121
122    /// Scaffold `apimock.toml` (and related files) into the current directory,
123    /// driven by interactive prompts when stdin is a TTY.
124    ///
125    /// Files that already exist are left untouched — `--init` is a
126    /// convenience for fresh directories, not an overwrite tool.
127    ///
128    /// # Why this never returns an error for an existing config file
129    ///
130    /// If the operator already has an `apimock.toml`, bailing out with a
131    /// non-zero exit would break repeatable idempotent scripts that run
132    /// `--init` before starting the server. Printing a warning and
133    /// continuing preserves that usage pattern.
134    fn init_config_interactive(
135        &mut self,
136        cli_middleware_override: bool,
137        force_defaults: bool,
138    ) -> Result<(), io::Error> {
139        // Early exit if the root config already exists — we never overwrite
140        // it, and asking a barrage of questions we're about to ignore would
141        // waste the user's time.
142        if Path::new(DEFAULT_CONFIG_FILE_PATH).exists() {
143            println!(
144                "[warn] quit because default root config file exists: {}.",
145                DEFAULT_CONFIG_FILE_PATH
146            );
147            return Ok(());
148        }
149
150        let answers = init_interactive::run(force_defaults, cli_middleware_override)?;
151
152        // Middleware file — honours both the CLI flag and the interactive answer.
153        if answers.include_middleware {
154            if !Path::new(DEFAULT_MIDDLEWARE_FILE_PATH).exists() {
155                let content =
156                    include_str!("../examples/config/default/apimock-middleware.rhai");
157                fs::write(DEFAULT_MIDDLEWARE_FILE_PATH, content)?;
158                println!(
159                    "middleware scripting file is created: {}.",
160                    DEFAULT_MIDDLEWARE_FILE_PATH
161                );
162            } else {
163                println!(
164                    "[warn] middleware scripting file exists: {}.",
165                    DEFAULT_MIDDLEWARE_FILE_PATH
166                );
167            }
168        }
169
170        // Root config — templated from the collected answers so the file
171        // reflects the user's actual choices rather than a fixed example.
172        let config_content = init_interactive::render_apimock_toml(&answers);
173        fs::write(DEFAULT_CONFIG_FILE_PATH, config_content)?;
174        println!("root config file is created: {}.", DEFAULT_CONFIG_FILE_PATH);
175
176        // Rule set file — still the example content, because customising
177        // rule shapes interactively would be a much larger prompt tree
178        // for diminishing value. Users are expected to edit this file.
179        if answers.include_rule_set && !Path::new(DEFAULT_RULE_SET_FILE_PATH).exists() {
180            let rule_set_content =
181                include_str!("../examples/config/default/apimock-rule-set.toml");
182            fs::write(DEFAULT_RULE_SET_FILE_PATH, rule_set_content)?;
183            println!(
184                "rule set config file is created: {}.",
185                DEFAULT_RULE_SET_FILE_PATH
186            );
187        }
188
189        init_interactive::print_summary(&answers);
190        Ok(())
191    }
192
193    /// If no config file was specified on the command line and one exists
194    /// at `./apimock.toml`, use that.
195    ///
196    /// This is what powers the "run `apimock` in your project directory
197    /// and it just picks up the config" behaviour.
198    fn default_config_file_path(&mut self) {
199        if self.config_file_path.is_some() {
200            return;
201        }
202        if !Path::new(DEFAULT_CONFIG_FILE_PATH).exists() {
203            return;
204        }
205        self.config_file_path = Some(DEFAULT_CONFIG_FILE_PATH.to_owned());
206    }
207}
208
209/// Look up the value associated with any of the given option names in
210/// `env::args()`.
211///
212/// For flags that don't take a value (e.g. `--init`), returns `Some("")`
213/// so the caller can check `.is_some()` without caring about the payload.
214fn args_option_value(option_names: &[&str]) -> Option<String> {
215    let args: Vec<String> = env::args().collect();
216
217    let name_index = args
218        .iter()
219        .position(|arg| option_names.iter().any(|n| arg.as_str() == *n))?;
220
221    let name_value = args.get(name_index + 1);
222    match name_value {
223        Some(v) if !v.starts_with('-') => Some(v.to_owned()),
224        _ => Some(String::new()),
225    }
226}