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}