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        let init_config = args_option_value(INIT_CONFIG_OPTION_NAMES.as_ref()).is_some();
49        if init_config {
50            let includes_middleware =
51                args_option_value(INCLUDES_MIDDLEWARE_OPTION_NAMES.as_ref()).is_some();
52            let force_defaults = args_option_value(YES_OPTION_NAMES.as_ref()).is_some();
53            // Drive the interactive prompt (or fall back to defaults in
54            // non-TTY / --yes contexts). We log but don't propagate the
55            // error: a failed init is a user-level problem, and forcing
56            // the binary to exit 1 on a partial write would be more
57            // disruptive than informative.
58            if let Err(err) = ret.init_config_interactive(includes_middleware, force_defaults) {
59                log::error!("failed to init config ({})", err);
60            }
61            return Ok(None);
62        }
63
64        ret.default_config_file_path();
65        ret.validate()?;
66
67        Ok(Some(ret))
68    }
69
70    /// Ensure paths referenced by CLI flags actually exist.
71    ///
72    /// We only check existence, not permission or content — a file the
73    /// process can see but can't read will still produce a better error
74    /// downstream at the point it's actually used.
75    pub fn validate(&self) -> AppResult<()> {
76        if let Some(config_file_path) = self.config_file_path.as_ref() {
77            if !Path::new(config_file_path.as_str()).exists() {
78                bail!(
79                    "config file specified via --config does not exist: {}",
80                    config_file_path
81                );
82            }
83        }
84
85        if let Some(fallback_respond_dir_path) = self.fallback_respond_dir_path.as_ref() {
86            if !Path::new(fallback_respond_dir_path.as_str()).exists() {
87                bail!(
88                    "fallback response dir specified via --dir does not exist: {}",
89                    fallback_respond_dir_path
90                );
91            }
92        }
93
94        Ok(())
95    }
96
97    /// Build an `EnvArgs` by reading `env::args()`.
98    fn from_args() -> AppResult<Self> {
99        let port = match args_option_value(CONFIG_LISTENER_PORT_OPTION_NAMES.as_ref()) {
100            Some(port_str) => Some(port_str.parse::<u16>().with_context(|| {
101                format!("--port value is not a valid u16: {}", port_str)
102            })?),
103            None => None,
104        };
105
106        Ok(EnvArgs {
107            config_file_path: args_option_value(CONFIG_FILE_PATH_OPTION_NAMES.as_ref()),
108            port,
109            fallback_respond_dir_path: args_option_value(
110                FALLBACK_RESPOND_DIR_PATH_OPTION_NAMES.as_ref(),
111            ),
112        })
113    }
114
115    /// Scaffold `apimock.toml` (and related files) into the current directory,
116    /// driven by interactive prompts when stdin is a TTY.
117    ///
118    /// Files that already exist are left untouched — `--init` is a
119    /// convenience for fresh directories, not an overwrite tool.
120    ///
121    /// # Why this never returns an error for an existing config file
122    ///
123    /// If the operator already has an `apimock.toml`, bailing out with a
124    /// non-zero exit would break repeatable idempotent scripts that run
125    /// `--init` before starting the server. Printing a warning and
126    /// continuing preserves that usage pattern.
127    fn init_config_interactive(
128        &mut self,
129        cli_middleware_override: bool,
130        force_defaults: bool,
131    ) -> Result<(), io::Error> {
132        // Early exit if the root config already exists — we never overwrite
133        // it, and asking a barrage of questions we're about to ignore would
134        // waste the user's time.
135        if Path::new(DEFAULT_CONFIG_FILE_PATH).exists() {
136            println!(
137                "[warn] quit because default root config file exists: {}.",
138                DEFAULT_CONFIG_FILE_PATH
139            );
140            return Ok(());
141        }
142
143        let answers = init_interactive::run(force_defaults, cli_middleware_override)?;
144
145        // Middleware file — honours both the CLI flag and the interactive answer.
146        if answers.include_middleware {
147            if !Path::new(DEFAULT_MIDDLEWARE_FILE_PATH).exists() {
148                let content =
149                    include_str!("../examples/config/default/apimock-middleware.rhai");
150                fs::write(DEFAULT_MIDDLEWARE_FILE_PATH, content)?;
151                println!(
152                    "middleware scripting file is created: {}.",
153                    DEFAULT_MIDDLEWARE_FILE_PATH
154                );
155            } else {
156                println!(
157                    "[warn] middleware scripting file exists: {}.",
158                    DEFAULT_MIDDLEWARE_FILE_PATH
159                );
160            }
161        }
162
163        // Root config — templated from the collected answers so the file
164        // reflects the user's actual choices rather than a fixed example.
165        let config_content = init_interactive::render_apimock_toml(&answers);
166        fs::write(DEFAULT_CONFIG_FILE_PATH, config_content)?;
167        println!("root config file is created: {}.", DEFAULT_CONFIG_FILE_PATH);
168
169        // Rule set file — still the example content, because customising
170        // rule shapes interactively would be a much larger prompt tree
171        // for diminishing value. Users are expected to edit this file.
172        if answers.include_rule_set && !Path::new(DEFAULT_RULE_SET_FILE_PATH).exists() {
173            let rule_set_content =
174                include_str!("../examples/config/default/apimock-rule-set.toml");
175            fs::write(DEFAULT_RULE_SET_FILE_PATH, rule_set_content)?;
176            println!(
177                "rule set config file is created: {}.",
178                DEFAULT_RULE_SET_FILE_PATH
179            );
180        }
181
182        init_interactive::print_summary(&answers);
183        Ok(())
184    }
185
186    /// If no config file was specified on the command line and one exists
187    /// at `./apimock.toml`, use that.
188    ///
189    /// This is what powers the "run `apimock` in your project directory
190    /// and it just picks up the config" behaviour.
191    fn default_config_file_path(&mut self) {
192        if self.config_file_path.is_some() {
193            return;
194        }
195        if !Path::new(DEFAULT_CONFIG_FILE_PATH).exists() {
196            return;
197        }
198        self.config_file_path = Some(DEFAULT_CONFIG_FILE_PATH.to_owned());
199    }
200}
201
202/// Look up the value associated with any of the given option names in
203/// `env::args()`.
204///
205/// For flags that don't take a value (e.g. `--init`), returns `Some("")`
206/// so the caller can check `.is_some()` without caring about the payload.
207fn args_option_value(option_names: &[&str]) -> Option<String> {
208    let args: Vec<String> = env::args().collect();
209
210    let name_index = args
211        .iter()
212        .position(|arg| option_names.iter().any(|n| arg.as_str() == *n))?;
213
214    let name_value = args.get(name_index + 1);
215    match name_value {
216        Some(v) if !v.starts_with('-') => Some(v.to_owned()),
217        _ => Some(String::new()),
218    }
219}