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}