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}