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::{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    // clippy: renaming `default` would change apimock::args::EnvArgs's
46    // public API surface; this is a fallible constructor, not the
47    // std::default::Default trait's method.
48    #[allow(clippy::should_implement_trait)]
49    pub fn default() -> AppResult<Option<Self>> {
50        let raw: Vec<String> = env::args().collect();
51
52        // `--version` / `--help` short-circuit before anything else -
53        // before a config file is read and before any listener binds
54        // (RFC 049 Goals 2/3). This must work in a directory with no
55        // config file, and in one with a deliberately broken config:
56        // "what version am I running" is asked precisely when something
57        // is wrong, so it can't depend on config loading having
58        // succeeded. `--help` is reachable per subcommand too - the
59        // subcommand name (if any) is `raw.get(1)`.
60        if any_present(&raw, VERSION_OPTION_NAMES.as_ref()) {
61            println!("apimock {}", env!("CARGO_PKG_VERSION"));
62            std::process::exit(0);
63        }
64        if any_present(&raw, HELP_OPTION_NAMES.as_ref()) {
65            println!("{}", help_text(raw.get(1).map(String::as_str)));
66            std::process::exit(0);
67        }
68
69        // `apimock match-test …` — dry-run rule matching.
70        if raw.get(1).map(String::as_str) == Some("match-test") {
71            crate::cmd::match_test::run(&raw[2..])?;
72            return Ok(None);
73        }
74
75        // `apimock validate …` — validate config without starting the server.
76        if raw.get(1).map(String::as_str) == Some("validate") {
77            std::process::exit(crate::cmd::validate::run(&raw[2..]));
78        }
79
80        // RFC 049 Goal 1: anything left that looks like a flag and isn't
81        // one of the top-level names above is unrecognised and must
82        // error, not be silently discarded. Applied before `--init`
83        // branches so both the "start the server" and "--init" surfaces
84        // get the same treatment - the defect this closes isn't
85        // specific to either.
86        reject_unknown_arguments(&raw);
87
88        let mut ret = EnvArgs::from_args()?;
89
90        let init_config = args_option_value(INIT_CONFIG_OPTION_NAMES.as_ref()).is_some();
91        if init_config {
92            let includes_middleware =
93                args_option_value(INCLUDES_MIDDLEWARE_OPTION_NAMES.as_ref()).is_some();
94            let force_defaults = args_option_value(YES_OPTION_NAMES.as_ref()).is_some();
95            // Drive the interactive prompt (or fall back to defaults in
96            // non-TTY / --yes contexts). We log but don't propagate the
97            // error: a failed init is a user-level problem, and forcing
98            // the binary to exit 1 on a partial write would be more
99            // disruptive than informative.
100            if let Err(err) = ret.init_config_interactive(includes_middleware, force_defaults) {
101                log::error!("failed to init config ({})", err);
102            }
103            return Ok(None);
104        }
105
106        ret.default_config_file_path();
107        ret.validate()?;
108
109        Ok(Some(ret))
110    }
111
112    /// Ensure paths referenced by CLI flags actually exist.
113    ///
114    /// We only check existence, not permission or content — a file the
115    /// process can see but can't read will still produce a better error
116    /// downstream at the point it's actually used.
117    pub fn validate(&self) -> AppResult<()> {
118        if let Some(config_file_path) = self.config_file_path.as_ref()
119            && !Path::new(config_file_path.as_str()).exists()
120        {
121            bail!(
122                "config file specified via --config does not exist: {}",
123                config_file_path
124            );
125        }
126
127        if let Some(fallback_respond_dir_path) = self.fallback_respond_dir_path.as_ref()
128            && !Path::new(fallback_respond_dir_path.as_str()).exists()
129        {
130            bail!(
131                "fallback response dir specified via --dir does not exist: {}",
132                fallback_respond_dir_path
133            );
134        }
135
136        Ok(())
137    }
138
139    /// Build an `EnvArgs` by reading `env::args()`.
140    fn from_args() -> AppResult<Self> {
141        // RFC 049: an invalid --port value is a usage error (exit 2), not
142        // "everything else" (exit 1) - it's caught before any config is
143        // read or listener bound, same as an unknown flag.
144        let port = args_option_value(CONFIG_LISTENER_PORT_OPTION_NAMES.as_ref()).map(|port_str| {
145            port_str.parse::<u16>().unwrap_or_else(|_| {
146                exit_usage_error(&format!("invalid value for --port: '{}'", port_str))
147            })
148        });
149
150        Ok(EnvArgs {
151            // RFC 049 Goal 4: a bare relative `--config apimock.toml`
152            // must resolve the same as `--config ./apimock.toml`. The
153            // actual defect is downstream, in
154            // `apimock_config::path_util` (`Path::parent()` returns
155            // `Some("")` for a bare filename, not `None`, and
156            // canonicalizing "" fails) - but this RFC's scope is the CLI
157            // surface only, so the fix is applied to the input here,
158            // before it ever reaches config loading, rather than to
159            // config loading itself.
160            config_file_path: args_option_value(CONFIG_FILE_PATH_OPTION_NAMES.as_ref())
161                .map(normalize_bare_relative_path),
162            port,
163            fallback_respond_dir_path: args_option_value(
164                FALLBACK_RESPOND_DIR_PATH_OPTION_NAMES.as_ref(),
165            ),
166        })
167    }
168
169    /// Scaffold `apimock.toml` (and related files) into the current directory,
170    /// driven by interactive prompts when stdin is a TTY.
171    ///
172    /// Files that already exist are left untouched — `--init` is a
173    /// convenience for fresh directories, not an overwrite tool.
174    ///
175    /// # Why this never returns an error for an existing config file
176    ///
177    /// If the operator already has an `apimock.toml`, bailing out with a
178    /// non-zero exit would break repeatable idempotent scripts that run
179    /// `--init` before starting the server. Printing a warning and
180    /// continuing preserves that usage pattern.
181    fn init_config_interactive(
182        &mut self,
183        cli_middleware_override: bool,
184        force_defaults: bool,
185    ) -> Result<(), io::Error> {
186        // Early exit if the root config already exists — we never overwrite
187        // it, and asking a barrage of questions we're about to ignore would
188        // waste the user's time.
189        if Path::new(DEFAULT_CONFIG_FILE_PATH).exists() {
190            println!(
191                "[warn] quit because default root config file exists: {}.",
192                DEFAULT_CONFIG_FILE_PATH
193            );
194            return Ok(());
195        }
196
197        let answers = init_interactive::run(force_defaults, cli_middleware_override)?;
198
199        // Middleware file — honours both the CLI flag and the interactive answer.
200        if answers.include_middleware {
201            if !Path::new(DEFAULT_MIDDLEWARE_FILE_PATH).exists() {
202                let content = include_str!("../examples/config/default/apimock-middleware.rhai");
203                fs::write(DEFAULT_MIDDLEWARE_FILE_PATH, content)?;
204                println!(
205                    "middleware scripting file is created: {}.",
206                    DEFAULT_MIDDLEWARE_FILE_PATH
207                );
208            } else {
209                println!(
210                    "[warn] middleware scripting file exists: {}.",
211                    DEFAULT_MIDDLEWARE_FILE_PATH
212                );
213            }
214        }
215
216        // Root config — templated from the collected answers so the file
217        // reflects the user's actual choices rather than a fixed example.
218        let config_content = init_interactive::render_apimock_toml(&answers);
219        fs::write(DEFAULT_CONFIG_FILE_PATH, config_content)?;
220        println!("root config file is created: {}.", DEFAULT_CONFIG_FILE_PATH);
221
222        // Rule set file — still the example content, because customising
223        // rule shapes interactively would be a much larger prompt tree
224        // for diminishing value. Users are expected to edit this file.
225        if answers.include_rule_set && !Path::new(DEFAULT_RULE_SET_FILE_PATH).exists() {
226            let rule_set_content = include_str!("../examples/config/default/apimock-rule-set.toml");
227            fs::write(DEFAULT_RULE_SET_FILE_PATH, rule_set_content)?;
228            println!(
229                "rule set config file is created: {}.",
230                DEFAULT_RULE_SET_FILE_PATH
231            );
232        }
233
234        init_interactive::print_summary(&answers);
235        Ok(())
236    }
237
238    /// If no config file was specified on the command line and one exists
239    /// at `./apimock.toml`, use that.
240    ///
241    /// This is what powers the "run `apimock` in your project directory
242    /// and it just picks up the config" behaviour.
243    fn default_config_file_path(&mut self) {
244        if self.config_file_path.is_some() {
245            return;
246        }
247        if !Path::new(DEFAULT_CONFIG_FILE_PATH).exists() {
248            return;
249        }
250        self.config_file_path = Some(DEFAULT_CONFIG_FILE_PATH.to_owned());
251    }
252}
253
254/// Look up the value associated with any of the given option names in
255/// `env::args()`.
256///
257/// For flags that don't take a value (e.g. `--init`), returns `Some("")`
258/// so the caller can check `.is_some()` without caring about the payload.
259fn args_option_value(option_names: &[&str]) -> Option<String> {
260    let args: Vec<String> = env::args().collect();
261
262    let name_index = args
263        .iter()
264        .position(|arg| option_names.contains(&arg.as_str()))?;
265
266    let name_value = args.get(name_index + 1);
267    match name_value {
268        Some(v) if !v.starts_with('-') => Some(v.to_owned()),
269        _ => Some(String::new()),
270    }
271}
272
273/// True if any of `raw` matches one of `option_names` exactly.
274fn any_present(raw: &[String], option_names: &[&str]) -> bool {
275    raw.iter().any(|arg| option_names.contains(&arg.as_str()))
276}
277
278/// Print a usage-error message to stderr and exit 2 (RFC 049: "usage
279/// error - unknown option, missing or invalid value"), without
280/// producing any output on stdout and without starting a server.
281fn exit_usage_error(message: &str) -> ! {
282    eprintln!("apimock: {}", message);
283    std::process::exit(2);
284}
285
286/// RFC 049 Goal 1: after every known top-level flag name is accounted
287/// for, anything left that looks like a flag (starts with `-`) is
288/// unrecognised. Exits 2 naming the offender, with a near-match
289/// suggestion where one exists - the difference between a dead end and
290/// a self-correction, for a person and for an agent alike.
291///
292/// Positional values consumed by a known flag (e.g. the number after
293/// `-p`) are skipped along with that flag, using the exact same
294/// "does the next token start with `-`" rule `args_option_value` uses,
295/// so this never disagrees with how a flag's value actually gets read.
296fn reject_unknown_arguments(raw: &[String]) {
297    let known = KNOWN_TOP_LEVEL_OPTION_NAMES.as_ref();
298    let mut skip_next = false;
299    for (i, arg) in raw.iter().enumerate() {
300        if i == 0 {
301            continue; // argv[0]: the binary path itself
302        }
303        if skip_next {
304            skip_next = false;
305            continue;
306        }
307        if arg == "match-test" || arg == "validate" {
308            // Positional subcommand names are handled by their own
309            // caller before this runs; reaching here at all means
310            // neither matched, so nothing to do with them here either.
311            continue;
312        }
313        if known.contains(&arg.as_str()) {
314            let next_is_value = raw.get(i + 1).is_some_and(|next| !next.starts_with('-'));
315            if next_is_value {
316                skip_next = true;
317            }
318            continue;
319        }
320        if arg.starts_with('-') {
321            match near_match(arg, known) {
322                Some(suggestion) => exit_usage_error(&format!(
323                    "unknown option '{}'; did you mean '{}'?",
324                    arg, suggestion
325                )),
326                None => exit_usage_error(&format!("unknown option '{}'", arg)),
327            }
328        }
329    }
330}
331
332/// Find the closest known flag to an unrecognised one, if the edit
333/// distance is small enough relative to length to be a plausible typo
334/// rather than an unrelated word.
335fn near_match<'a>(unknown: &str, known: &[&'a str]) -> Option<&'a str> {
336    known
337        .iter()
338        .map(|&candidate| (candidate, edit_distance(unknown, candidate)))
339        .filter(|&(candidate, distance)| {
340            distance > 0 && distance <= (unknown.len().max(candidate.len()) / 3).max(1)
341        })
342        .min_by_key(|&(_, distance)| distance)
343        .map(|(candidate, _)| candidate)
344}
345
346/// Levenshtein edit distance, for [`near_match`].
347fn edit_distance(a: &str, b: &str) -> usize {
348    let a: Vec<char> = a.chars().collect();
349    let b: Vec<char> = b.chars().collect();
350
351    let mut prev: Vec<usize> = (0..=b.len()).collect();
352    let mut curr = vec![0usize; b.len() + 1];
353
354    for i in 1..=a.len() {
355        curr[0] = i;
356        for j in 1..=b.len() {
357            let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
358            curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost);
359        }
360        std::mem::swap(&mut prev, &mut curr);
361    }
362
363    prev[b.len()]
364}
365
366/// RFC 049 Goal 4: give a bare relative path (no directory component,
367/// e.g. `"apimock.toml"`) an explicit `./` prefix, so it resolves the
368/// same way `"./apimock.toml"` already does. Absolute paths and paths
369/// that already have a directory component (including a leading `./`
370/// or `../`) are returned unchanged.
371fn normalize_bare_relative_path(path: String) -> String {
372    // `args_option_value` returns `Some("")` for a value-taking flag
373    // given with nothing after it (the same encoding it uses for a
374    // boolean flag's mere presence) - already a meaningless invocation
375    // either way, but prepending "./" to it would turn "path not given"
376    // into "path is the current directory", a more confusing failure
377    // than the original "no such file" for an empty path. Leave it
378    // alone so it fails the same way it always did.
379    if path.is_empty() {
380        return path;
381    }
382
383    let p = Path::new(&path);
384    let has_directory_component = p
385        .parent()
386        .is_some_and(|parent| !parent.as_os_str().is_empty());
387    if has_directory_component || p.is_absolute() {
388        path
389    } else {
390        format!("./{}", path)
391    }
392}
393
394/// Top-level usage text, or a subcommand's, matching
395/// `docs/src/reference/cli-reference.md`.
396fn help_text(subcommand: Option<&str>) -> &'static str {
397    match subcommand {
398        Some("match-test") => {
399            "apimock match-test --rule-set <path> [--rule <n>] [--path <url_path>] \\\n  [--method <METHOD>] [--header \"Name: value\"]... \\\n  [--body <json> | --body-file <path>] [--quiet]\n\nBuilds a synthetic request from the flags below and checks it against\na rule set directly - no server, no network request.\n\n  --rule-set, -r <path>       Required. The rule-set file to check against\n  --rule <n>                  Check only this rule, 1-based\n  --path, -p <url_path>       The synthetic request's URL path\n  --method, -m <METHOD>       The synthetic request's HTTP method\n  --header, -H \"Name: value\"  Add a header; repeatable\n  --body, -b <json>           The synthetic request's JSON body, inline\n  --body-file <path>          The synthetic request's JSON body, from a file\n  --quiet, -q                 Suppress the per-condition breakdown\n\nExit codes: 0 matched, 1 no rule matched, 2 an argument or input error."
400        }
401        Some("validate") => {
402            "apimock validate --config <path> [--strict] [--quiet] [--json] [--format text|json]\n\nLoads the whole workspace - root config and every rule set it\nreferences - and reports diagnostics, without binding a port.\n\n  --config, -c <path>  Required. The root config to validate\n  --strict             Treat warnings as failures too\n  --quiet              Suppress non-error output\n  --json               Deprecated - emits the same bare diagnostics array as before, with a one-line warning on stderr. Use --format json\n  --format text|json   text (default): today's output. json: the RFC 053 response envelope\n\n--json and --format may not be combined.\n\nExit codes: 0 clean, 1 at least one error, 2 the config couldn't be loaded, or a bad invocation."
403        }
404        _ => {
405            "apimock [-p <port>] [-d <dir>] [-c <config>] [--init [--yes] [--middleware]]\n\nRun with no flags to serve the current directory: zero-config mode\nserves ./ by URL path on port 3001, or ./apimock.toml if it exists.\n\n  -c, --config <path>  Load a config file (a bare relative path resolves\n                       the same as one prefixed with ./)\n  -p, --port <port>    Listen on a custom port\n  -d, --dir <dir>      Serve a custom fallback directory instead of ./\n  --init               Scaffold a starting config in the current directory\n  --yes                With --init, skip prompts and accept defaults\n  --middleware         With --init, also scaffold a middleware file\n  -h, --help           Print this help and exit\n  --version            Print the version and exit\n\nSubcommands:\n  match-test  Dry-run a rule match against a rule set, no server\n  validate    Validate a config, no server\n\nRun 'apimock <subcommand> --help' for subcommand-specific help."
406        }
407    }
408}