nimbus_cli/cli.rs
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5use std::{ffi::OsString, path::PathBuf};
6
7use chrono::Utc;
8use clap::{Args, Parser, Subcommand};
9
10#[derive(Parser)]
11#[command(
12 author,
13 long_about = r#"Mozilla Nimbus' command line tool for mobile apps"#
14)]
15pub(crate) struct Cli {
16 /// The app name according to Nimbus.
17 #[arg(short, long, value_name = "APP")]
18 pub(crate) app: Option<String>,
19
20 /// The channel according to Nimbus. This determines which app to talk to.
21 #[arg(short, long, value_name = "CHANNEL")]
22 pub(crate) channel: Option<String>,
23
24 /// The device id of the simulator, emulator or device.
25 #[arg(short, long, value_name = "DEVICE_ID")]
26 pub(crate) device_id: Option<String>,
27
28 #[command(subcommand)]
29 pub(crate) command: CliCommand,
30}
31
32#[derive(Subcommand, Clone)]
33pub(crate) enum CliCommand {
34 /// Send a complete JSON file to the Nimbus SDK and apply it immediately.
35 ApplyFile {
36 /// The filename to be loaded into the SDK.
37 file: PathBuf,
38
39 /// Keeps existing enrollments and experiments before enrolling.
40 ///
41 /// This is unlikely what you want to do.
42 #[arg(long, default_value = "false")]
43 preserve_nimbus_db: bool,
44
45 #[command(flatten)]
46 open: OpenArgs,
47 },
48
49 /// Capture the logs into a file.
50 CaptureLogs {
51 /// The file to put the logs.
52 file: PathBuf,
53 },
54
55 /// Print the defaults for the manifest.
56 Defaults {
57 /// An optional feature-id
58 #[arg(short, long = "feature")]
59 feature_id: Option<String>,
60
61 /// An optional file to print the manifest defaults.
62 #[arg(short, long, value_name = "OUTPUT_FILE")]
63 output: Option<PathBuf>,
64
65 #[command(flatten)]
66 manifest: ManifestArgs,
67 },
68
69 /// Enroll into an experiment or a rollout.
70 ///
71 /// The experiment slug is a combination of the actual slug, and the server it came from.
72 ///
73 /// * `release`/`stage` determines the server.
74 ///
75 /// * `preview` selects the preview collection.
76 ///
77 /// These can be further combined: e.g. $slug, preview/$slug, stage/$slug, stage/preview/$slug
78 Enroll {
79 #[command(flatten)]
80 experiment: ExperimentArgs,
81
82 /// The branch slug.
83 #[arg(short, long, value_name = "BRANCH")]
84 branch: String,
85
86 /// Optional rollout slugs, including the server and collection.
87 #[arg(value_name = "ROLLOUTS")]
88 rollouts: Vec<String>,
89
90 /// Preserves the original experiment targeting
91 #[arg(long, default_value = "false")]
92 preserve_targeting: bool,
93
94 /// Preserves the original experiment bucketing
95 #[arg(long, default_value = "false")]
96 preserve_bucketing: bool,
97
98 #[command(flatten)]
99 open: OpenArgs,
100
101 /// Keeps existing enrollments and experiments before enrolling.
102 ///
103 /// This is unlikely what you want to do.
104 #[arg(long, default_value = "false")]
105 preserve_nimbus_db: bool,
106
107 /// Don't validate the feature config files before enrolling
108 #[arg(long, default_value = "false")]
109 no_validate: bool,
110
111 #[command(flatten)]
112 manifest: ManifestArgs,
113 },
114
115 /// Print the feature configuration involved in the branch of an experiment.
116 ///
117 /// This can be optionally merged with the defaults from the feature manifest.
118 Features {
119 #[command(flatten)]
120 manifest: ManifestArgs,
121
122 #[command(flatten)]
123 experiment: ExperimentArgs,
124
125 /// The branch of the experiment
126 #[arg(short, long)]
127 branch: String,
128
129 /// If set, then merge the experimental configuration with the defaults from the manifest
130 #[arg(short, long, default_value = "false")]
131 validate: bool,
132
133 /// An optional feature-id: if it exists in this branch, print this feature
134 /// on its own.
135 #[arg(short, long = "feature")]
136 feature_id: Option<String>,
137
138 /// Print out the features involved in this branch as in a format:
139 /// `{ $feature_id: $value }`.
140 ///
141 /// Automated tools should use this, since the output is predictable.
142 #[arg(short, long = "multi", default_value = "false")]
143 multi: bool,
144
145 /// An optional file to print the output.
146 #[arg(short, long, value_name = "OUTPUT_FILE")]
147 output: Option<PathBuf>,
148 },
149
150 /// Fetch one or more named experiments and rollouts and put them in a file.
151 Fetch {
152 /// The file to download the recipes to.
153 #[arg(short, long, value_name = "OUTPUT_FILE")]
154 output: Option<PathBuf>,
155
156 #[command(flatten)]
157 experiment: ExperimentArgs,
158
159 /// The recipe slugs, including server.
160 ///
161 /// Use once per recipe to download. e.g.
162 /// fetch --output file.json preview/my-experiment my-rollout
163 ///
164 /// Cannot be used with the server option: use `fetch-list` instead.
165 #[arg(value_name = "RECIPE")]
166 recipes: Vec<String>,
167 },
168
169 /// Fetch a list of experiments and put it in a file.
170 FetchList {
171 /// The file to download the recipes to.
172 #[arg(short, long, value_name = "OUTPUT_FILE")]
173 output: Option<PathBuf>,
174
175 #[command(flatten)]
176 list: ExperimentListArgs,
177 },
178
179 /// Execute a nimbus-fml command. See
180 ///
181 /// nimbus-cli fml -- --help
182 ///
183 /// for more.
184 Fml { args: Vec<OsString> },
185
186 /// Displays information about an experiment
187 Info {
188 #[command(flatten)]
189 experiment: ExperimentArgs,
190
191 /// An optional file to print the output.
192 #[arg(short, long, value_name = "OUTPUT_FILE")]
193 output: Option<PathBuf>,
194 },
195
196 /// List the experiments from a server
197 List {
198 #[command(flatten)]
199 list: ExperimentListArgs,
200 },
201
202 /// Print the state of the Nimbus database to logs.
203 ///
204 /// This causes a restart of the app.
205 LogState {
206 #[command(flatten)]
207 open: OpenArgs,
208 },
209
210 /// Open the app without changing the state of experiment enrollments.
211 Open {
212 #[command(flatten)]
213 open: OpenArgs,
214
215 /// By default, the app is terminated before sending the a deeplink.
216 ///
217 /// If this flag is set, then do not terminate the app if it is already running.
218 #[arg(long, default_value = "false")]
219 no_clobber: bool,
220 },
221
222 /// Start a server
223 #[cfg(feature = "server")]
224 StartServer,
225
226 /// Reset the app back to its just installed state
227 ResetApp,
228
229 /// Follow the logs for the given app.
230 TailLogs,
231
232 /// Configure an application feature with one or more feature config files.
233 ///
234 /// One file per branch. The branch slugs will correspond to the file names.
235 ///
236 /// By default, the files are validated against the manifest; this can be
237 /// overridden with `--no-validate`.
238 TestFeature {
239 /// The identifier of the feature to configure
240 feature_id: String,
241
242 /// One or more files containing a feature config for the feature.
243 files: Vec<PathBuf>,
244
245 /// An optional patch file, used to patch feature configurations
246 ///
247 /// This is of the format that comes from the
248 /// `features --multi` or `defaults` commands.
249 #[arg(long, value_name = "PATCH_FILE")]
250 patch: Option<PathBuf>,
251
252 #[command(flatten)]
253 open: OpenArgs,
254
255 /// Don't validate the feature config files before enrolling
256 #[arg(long, default_value = "false")]
257 no_validate: bool,
258
259 #[command(flatten)]
260 manifest: ManifestArgs,
261 },
262
263 /// Unenroll from all experiments and rollouts
264 Unenroll {
265 #[command(flatten)]
266 open: OpenArgs,
267 },
268
269 /// Validate an experiment against a feature manifest
270 Validate {
271 #[command(flatten)]
272 experiment: ExperimentArgs,
273
274 #[command(flatten)]
275 manifest: ManifestArgs,
276 },
277}
278
279#[derive(Args, Clone, Debug, Default)]
280pub(crate) struct ManifestArgs {
281 /// An optional manifest file
282 #[arg(long, value_name = "MANIFEST_FILE")]
283 pub(crate) manifest: Option<String>,
284
285 /// An optional version of the app.
286 /// If present, constructs the `ref` from an app specific template.
287 /// Due to inconsistencies in branching names, this isn't always
288 /// reliable.
289 #[arg(long, value_name = "APP_VERSION")]
290 pub(crate) version: Option<String>,
291
292 /// The branch/tag/commit for the version of the manifest
293 /// to get from Github.
294 #[arg(long, value_name = "APP_VERSION", default_value = "main")]
295 pub(crate) ref_: String,
296}
297
298#[derive(Args, Clone, Debug, Default)]
299pub(crate) struct OpenArgs {
300 /// Optional deeplink. If present, launch with this link.
301 #[arg(long, value_name = "DEEPLINK")]
302 pub(crate) deeplink: Option<String>,
303
304 /// Resets the app back to its initial state before launching
305 #[arg(long, default_value = "false")]
306 pub(crate) reset_app: bool,
307
308 /// Instead of opening via adb or xcrun simctl, construct a deeplink
309 /// and put it into the pastebuffer.
310 ///
311 /// If present, then the app is not launched, so this option does not work with
312 /// `--reset-app` or passthrough arguments.
313 #[arg(long, default_value = "false")]
314 pub(crate) pbcopy: bool,
315
316 /// Instead of opening via adb or xcrun simctl, construct a deeplink
317 /// and put it into the pastebuffer.
318 ///
319 /// If present, then the app is not launched, so this option does not work with
320 /// `--reset-app` or passthrough arguments.
321 #[arg(long, default_value = "false")]
322 pub(crate) pbpaste: bool,
323
324 /// Optionally, add platform specific arguments to the adb or xcrun command.
325 ///
326 /// By default, arguments are added to the end of the command, likely to be passed
327 /// directly to the app.
328 ///
329 /// Arguments before a special placeholder `{}` are passed to
330 /// `adb am start` or `xcrun simctl launch` commands directly.
331 #[arg(last = true, value_name = "PASSTHROUGH_ARGS")]
332 pub(crate) passthrough: Vec<String>,
333
334 /// An optional file to dump experiments into.
335 ///
336 /// If present, then the app is not launched, so this option does not work with
337 /// `--reset-app` or passthrough arguments.
338 #[arg(long, value_name = "OUTPUT_FILE")]
339 pub(crate) output: Option<PathBuf>,
340}
341
342#[derive(Args, Clone, Debug, Default)]
343pub(crate) struct ExperimentArgs {
344 /// The experiment slug, including the server and collection.
345 #[arg(value_name = "EXPERIMENT_SLUG")]
346 pub(crate) experiment: String,
347
348 /// An optional file from which to get the experiment.
349 ///
350 /// By default, the file is fetched from the server.
351 #[arg(long, value_name = "EXPERIMENTS_FILE")]
352 pub(crate) file: Option<PathBuf>,
353
354 /// Use remote settings to fetch the experiment recipe.
355 ///
356 /// By default, the file is fetched from the v6 api of experimenter.
357 #[arg(long, default_value = "false")]
358 pub(crate) use_rs: bool,
359
360 /// An optional patch file, used to patch feature configurations
361 ///
362 /// This is of the format that comes from the
363 /// `features --multi` or `defaults` commands.
364 #[arg(long, value_name = "PATCH_FILE")]
365 pub(crate) patch: Option<PathBuf>,
366}
367
368#[derive(Args, Clone, Debug, Default)]
369pub(crate) struct ExperimentListArgs {
370 #[command(flatten)]
371 pub(crate) source: ExperimentListSourceArgs,
372
373 #[command(flatten)]
374 pub(crate) filter: ExperimentListFilterArgs,
375}
376
377#[derive(Args, Clone, Debug, Default)]
378pub(crate) struct ExperimentListSourceArgs {
379 /// A server slug e.g. preview, release, stage, stage/preview
380 #[arg(default_value = "")]
381 pub(crate) server: String,
382
383 /// An optional file
384 #[arg(short, long, value_name = "FILE")]
385 pub(crate) file: Option<PathBuf>,
386
387 /// Use the v6 API to fetch the experiment recipes.
388 ///
389 /// By default, the file is fetched from the Remote Settings.
390 ///
391 /// The API contains *all* launched experiments, past and present,
392 /// so this is considerably slower and longer than Remote Settings.
393 #[arg(long, default_value = "false")]
394 pub(crate) use_api: bool,
395}
396
397#[derive(Args, Clone, Debug, Default)]
398pub(crate) struct ExperimentListFilterArgs {
399 #[arg(short = 'S', long, value_name = "SLUG_PATTERN")]
400 pub(crate) slug: Option<String>,
401
402 #[arg(short = 'F', long, value_name = "FEATURE_PATTERN")]
403 pub(crate) feature: Option<String>,
404
405 #[arg(short = 'A', long, value_name = "DATE", value_parser=validate_date)]
406 pub(crate) active_on: Option<String>,
407
408 #[arg(short = 'E', long, value_name = "DATE", value_parser=validate_date)]
409 pub(crate) enrolling_on: Option<String>,
410
411 #[arg(short = 'C', long, value_name = "CHANNEL")]
412 pub(crate) channel: Option<String>,
413
414 #[arg(short = 'R', long, value_name = "FLAG")]
415 pub(crate) is_rollout: Option<bool>,
416}
417
418fn validate_num(s: &str, l: usize) -> Result<(), &'static str> {
419 if !s.chars().all(char::is_numeric) {
420 Err("String contains non-numeric characters")
421 } else if s.len() != l {
422 Err("String is the wrong length")
423 } else {
424 Ok(())
425 }
426}
427
428fn validate_date_parts(yyyy: &str, mm: &str, dd: &str) -> Result<(), &'static str> {
429 validate_num(yyyy, 4)?;
430 validate_num(mm, 2)?;
431 validate_num(dd, 2)?;
432 Ok(())
433}
434
435fn validate_date(s: &str) -> Result<String, String> {
436 if s == "today" {
437 let now = Utc::now();
438 return Ok(format!("{}", now.format("%Y-%m-%d")));
439 }
440 match s.splitn(3, '-').collect::<Vec<_>>().as_slice() {
441 [yyyy, mm, dd] if validate_date_parts(yyyy, mm, dd).is_ok() => Ok(s.to_string()),
442 _ => Err("Date string must be yyyy-mm-dd".to_string()),
443 }
444}