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}