git_perf/
cli.rs

1use anyhow::{anyhow, bail, Result};
2use clap::{error::ErrorKind::ArgumentConflict, Args, Parser};
3use clap::{CommandFactory, Subcommand};
4use env_logger::Env;
5use log::Level;
6use std::path::PathBuf;
7
8use chrono::prelude::*;
9use chrono::Duration;
10
11use crate::audit;
12use crate::basic_measure::measure;
13use crate::config::bump_epoch;
14use crate::data::ReductionFunc;
15use crate::git_interop;
16use crate::git_interop::{prune, pull, push};
17use crate::measurement_storage::{add, remove_measurements_from_commits};
18use crate::reporting::report;
19
20#[derive(Parser)]
21#[command(version)]
22struct Cli {
23    /// Increase verbosity level (can be specified multiple times.) The first level sets level
24    /// "info", second sets level "debug", and third sets level "trace" for the logger.
25    #[arg(short, long, action = clap::ArgAction::Count)]
26    verbose: u8,
27
28    #[command(subcommand)]
29    command: Commands,
30}
31
32#[derive(Args)]
33struct CliMeasurement {
34    /// Name of the measurement
35    #[arg(short = 'm', long = "measurement", value_parser=parse_spaceless_string)]
36    name: String,
37
38    /// Key-value pairs separated by '='
39    #[arg(short, long, value_parser=parse_key_value)]
40    key_value: Vec<(String, String)>,
41}
42
43#[derive(Args)]
44struct CliReportHistory {
45    /// Limit the number of previous commits considered.
46    /// HEAD is included in this count.
47    #[arg(short = 'n', long, default_value = "40")]
48    max_count: usize,
49}
50
51#[derive(Subcommand)]
52enum Commands {
53    /// Measure the runtime of the supplied command (in nanoseconds)
54    Measure {
55        /// Repetitions
56        #[arg(short = 'n', long, value_parser=clap::value_parser!(u16).range(1..), default_value = "1")]
57        repetitions: u16,
58
59        #[command(flatten)]
60        measurement: CliMeasurement,
61
62        /// Command to measure
63        #[arg(required(true), last(true))]
64        command: Vec<String>,
65    },
66
67    /// Add single measurement
68    Add {
69        /// Measured value to be added
70        value: f64,
71
72        #[command(flatten)]
73        measurement: CliMeasurement,
74    },
75
76    /// Publish performance results to remote
77    Push {},
78
79    /// Pull performance results from remote
80    Pull {},
81
82    /// Create an HTML performance report
83    Report {
84        /// HTML output file
85        #[arg(short, long, default_value = "output.html")]
86        output: PathBuf,
87
88        #[command(flatten)]
89        report_history: CliReportHistory,
90
91        /// Select an individual measurements instead of all
92        #[arg(short, long)]
93        measurement: Vec<String>,
94
95        /// Key-value pairs separated by '=', select only matching measurements
96        #[arg(short, long, value_parser=parse_key_value)]
97        key_value: Vec<(String, String)>,
98
99        /// Create individual traces in the graph by grouping with the value of this selector
100        #[arg(short, long, value_parser=parse_spaceless_string)]
101        separate_by: Option<String>,
102
103        /// What to aggregate the measurements in each group with
104        #[arg(short, long)]
105        aggregate_by: Option<ReductionFunc>,
106    },
107
108    /// For a given measurement, check perfomance deviations of the HEAD commit
109    /// against `<n>` previous commits. Group previous results and aggregate their
110    /// results before comparison.
111    Audit {
112        #[arg(short, long, value_parser=parse_spaceless_string)]
113        measurement: String,
114
115        #[command(flatten)]
116        report_history: CliReportHistory,
117
118        /// Key-value pair separated by "=" with no whitespaces to subselect measurements
119        #[arg(short, long, value_parser=parse_key_value)]
120        selectors: Vec<(String, String)>,
121
122        /// Minimum number of measurements needed. If less, pass test and assume
123        /// more measurements are needed.
124        /// A minimum of two historic measurements are needed for proper evaluation of standard
125        /// deviation.
126        // TODO(kaihowl) fix up min value and default_value
127        #[arg(long, value_parser=clap::value_parser!(u16).range(1..), default_value="2")]
128        min_measurements: u16,
129
130        /// What to aggregate the measurements in each group with
131        #[arg(short, long, default_value = "min")]
132        aggregate_by: ReductionFunc,
133
134        /// Multiple of the stddev after which a outlier is detected.
135        /// If the HEAD measurement is within `[mean-<d>*sigma; mean+<d>*sigma]`,
136        /// it is considered acceptable.
137        #[arg(short = 'd', long, default_value = "4.0")]
138        sigma: f64,
139    },
140
141    /// Accept HEAD commit's measurement for audit, even if outside of range.
142    /// This is allows to accept expected performance changes.
143    /// This is accomplished by starting a new epoch for the given measurement.
144    /// The epoch is configured in the git perf config file.
145    /// A change to the epoch therefore has to be committed and will result in a new HEAD for which
146    /// new measurements have to be taken.
147    BumpEpoch {
148        #[arg(short = 'm', long = "measurement", value_parser=parse_spaceless_string)]
149        measurement: String,
150    },
151
152    /// Remove all performance measurements for commits that have been committed
153    /// before the specified time period.
154    Remove {
155        #[arg(long = "older-than", value_parser = parse_datetime_value_now)]
156        older_than: DateTime<Utc>,
157    },
158
159    /// Remove all performance measurements for non-existent/unreachable objects.
160    /// Will refuse to work if run on a shallow clone.
161    Prune {},
162
163    /// Generate the manpage content
164    #[command(hide = true)]
165    Manpage {},
166}
167
168fn parse_key_value(s: &str) -> Result<(String, String)> {
169    let pos = s
170        .find('=')
171        .ok_or_else(|| anyhow!("invalid key=value: no '=' found in '{}'", s))?;
172    let key = parse_spaceless_string(&s[..pos])?;
173    let value = parse_spaceless_string(&s[pos + 1..])?;
174    Ok((key, value))
175}
176
177fn parse_spaceless_string(s: &str) -> Result<String> {
178    if s.split_whitespace().count() > 1 {
179        Err(anyhow!("invalid string/key/value: found space in '{}'", s))
180    } else {
181        Ok(String::from(s))
182    }
183}
184
185fn parse_datetime_value_now(input: &str) -> Result<DateTime<Utc>> {
186    parse_datetime_value(&Utc::now(), input)
187}
188
189fn parse_datetime_value(now: &DateTime<Utc>, input: &str) -> Result<DateTime<Utc>> {
190    if input.len() < 2 {
191        bail!("Invalid datetime format");
192    }
193
194    let (num, unit) = input.split_at(input.len() - 1);
195    let num: i64 = num.parse()?;
196    let subtractor = match unit {
197        "w" => Duration::weeks(num),
198        "d" => Duration::days(num),
199        "h" => Duration::hours(num),
200        _ => bail!("Unsupported datetime format"),
201    };
202    Ok(*now - subtractor)
203}
204
205pub fn handle_calls() -> Result<()> {
206    let cli = Cli::parse();
207    let logger_level = match cli.verbose {
208        0 => Level::Warn,
209        1 => Level::Info,
210        2 => Level::Debug,
211        3 | _ => Level::Trace,
212    };
213    env_logger::Builder::from_env(Env::default().default_filter_or(logger_level.as_str())).init();
214
215    git_interop::check_git_version()?;
216
217    match cli.command {
218        Commands::Measure {
219            repetitions,
220            command,
221            measurement,
222        } => Ok(measure(
223            &measurement.name,
224            repetitions,
225            &command,
226            &measurement.key_value,
227        )?),
228        Commands::Add { value, measurement } => {
229            Ok(add(&measurement.name, value, &measurement.key_value)?)
230        }
231        Commands::Push {} => Ok(push(None)?),
232        Commands::Pull {} => Ok(pull(None)?),
233        Commands::Report {
234            output,
235            separate_by,
236            report_history,
237            measurement,
238            key_value,
239            aggregate_by,
240        } => Ok(report(
241            output,
242            separate_by,
243            report_history.max_count,
244            &measurement,
245            &key_value,
246            aggregate_by,
247        )?),
248        Commands::Audit {
249            measurement,
250            report_history,
251            selectors,
252            min_measurements,
253            aggregate_by,
254            sigma,
255        } => {
256            if report_history.max_count < min_measurements.into() {
257                Cli::command().error(ArgumentConflict, format!("The minimal number of measurements ({}) cannot be more than the maximum number of measurements ({})", min_measurements, report_history.max_count)).exit()
258            }
259            Ok(audit::audit(
260                &measurement,
261                report_history.max_count,
262                min_measurements,
263                &selectors,
264                aggregate_by,
265                sigma,
266            )?)
267        }
268        Commands::BumpEpoch { measurement } => Ok(bump_epoch(&measurement)?),
269        Commands::Prune {} => Ok(prune()?),
270        Commands::Manpage {} => {
271            generate_manpage().expect("Man page generation failed");
272            Ok(())
273        }
274        Commands::Remove { older_than } => remove_measurements_from_commits(older_than),
275    }
276}
277
278fn generate_manpage() -> Result<()> {
279    let man = clap_mangen::Man::new(Cli::command());
280    man.render(&mut std::io::stdout())?;
281
282    // TODO(kaihowl) this does not look very nice. Fix it.
283    for command in Cli::command()
284        .get_subcommands()
285        .filter(|c| !c.is_hide_set())
286    {
287        let man = clap_mangen::Man::new(command.clone());
288        man.render(&mut std::io::stdout())?
289    }
290
291    Ok(())
292}
293
294#[cfg(test)]
295mod test {
296    use super::*;
297
298    #[test]
299    fn verify_cli() {
300        Cli::command().debug_assert()
301    }
302
303    #[test]
304    fn verify_date_parsing() {
305        let now = Utc::now();
306
307        assert_eq!(
308            now - Duration::weeks(2),
309            parse_datetime_value(&now, "2w").unwrap()
310        );
311
312        assert_eq!(
313            now - Duration::days(30),
314            parse_datetime_value(&now, "30d").unwrap()
315        );
316
317        assert_eq!(
318            now - Duration::hours(72),
319            parse_datetime_value(&now, "72h").unwrap()
320        );
321
322        assert!(parse_datetime_value(&now, " 2w ").is_err());
323
324        assert!(parse_datetime_value(&now, "").is_err());
325
326        assert!(parse_datetime_value(&now, "945kjfg").is_err());
327    }
328}