analyse_json/
lib.rs

1use anyhow::{Context, Result};
2use clap::builder::styling::AnsiColor;
3use clap::builder::Styles;
4use clap::CommandFactory;
5use clap::Parser;
6use clap_complete::Shell;
7use glob::glob;
8use grep_cli::is_readable_stdin;
9use humantime::format_duration;
10use json::ndjson::JSONStats;
11use json::ndjson::StatsResult;
12use serde_json_path::JsonPath;
13use std::io;
14use std::path::PathBuf;
15use std::time::Instant;
16
17use crate::json::ndjson;
18
19mod io_helpers;
20pub mod json;
21
22fn styles() -> Styles {
23    Styles::styled()
24        .header(AnsiColor::Yellow.on_default())
25        .usage(AnsiColor::Green.on_default())
26        .literal(AnsiColor::Green.on_default())
27        .placeholder(AnsiColor::Green.on_default())
28}
29
30#[derive(Parser, Default, PartialEq, Eq)]
31#[clap(author, version, about, long_about = None, styles = styles())]
32pub struct Cli {
33    /// File to process, expected to contain a single JSON object or Newline Delimited (ND) JSON objects
34    #[clap(value_parser)]
35    file_path: Option<std::path::PathBuf>,
36
37    /// Process all files identified by this glob pattern
38    #[clap(short, long)]
39    glob: Option<String>,
40
41    /// Limit inspection to the first n lines
42    #[clap(short = 'n', long)]
43    lines: Option<usize>,
44
45    /// JSONpath query to filter/limit the inspection to e.g. `'$.a_key.an_array[0]'`
46    #[clap(long)]
47    jsonpath: Option<String>,
48
49    /// Walk the elements of arrays grouping elements paths together under `$.path.to.array[*]`?
50    /// See also `--explode-arrays`
51    #[clap(long)]
52    inspect_arrays: bool,
53
54    /// Walk the elements of arrays treating arrays like a map of their enumerated elements?
55    /// (E.g. $.path.to.array[0], $.path.to.array[1], ...)
56    /// See also `--inspect-arrays`
57    #[clap(long, conflicts_with = "inspect_arrays")]
58    explode_arrays: bool,
59
60    /// Include combined results for all files when using glob
61    #[clap(long)]
62    merge: bool,
63
64    /// Use multi-threaded version of the processing
65    #[clap(long)]
66    parallel: bool,
67
68    /// Silence error logging
69    #[clap(short, long)]
70    quiet: bool,
71
72    /// Output shell completions for the chosen shell to stdout
73    #[clap(value_enum, long, id = "SHELL")]
74    generate_completions: Option<Shell>,
75}
76
77impl Cli {
78    fn jsonpath_selector(&self) -> Result<Option<JsonPath>> {
79        let jsonpath_selector = if let Some(jsonpath) = &self.jsonpath {
80            let path = JsonPath::parse(jsonpath)
81                .with_context(|| format!("Failed to parse jsonpath query string: {jsonpath}"))?;
82            Some(path)
83        } else {
84            None
85        };
86        Ok(jsonpath_selector)
87    }
88}
89
90/// Wrapper around [`Cli`] to hold derived attributes
91pub struct Settings {
92    args: Cli,
93    jsonpath_selector: Option<JsonPath>,
94}
95
96impl Settings {
97    fn init(args: Cli) -> Result<Self> {
98        let jsonpath_selector = args.jsonpath_selector()?;
99        Ok(Self {
100            args,
101            jsonpath_selector,
102        })
103    }
104}
105
106fn process_ndjson_file_path(settings: &Settings, file_path: &PathBuf) -> Result<ndjson::Stats> {
107    let StatsResult { stats, errors } = file_path.json_stats(settings).with_context(|| {
108        format!(
109            "Failed to collect stats for JSON file: {}",
110            file_path.display()
111        )
112    })?;
113
114    if !settings.args.quiet {
115        errors.eprint();
116    }
117
118    Ok(stats)
119}
120
121fn run_stdin(settings: Settings) -> Result<()> {
122    let StatsResult { stats, errors } = io::stdin()
123        .json_stats(&settings)
124        .context("Failed to collect stats for JSON stdin")?;
125
126    if !settings.args.quiet {
127        errors.eprint();
128    }
129
130    stats.print()?;
131    Ok(())
132}
133
134fn run_no_stdin(settings: Settings) -> Result<()> {
135    if let Some(file_path) = &settings.args.file_path {
136        let file_stats = process_ndjson_file_path(&settings, file_path)?;
137
138        file_stats.print()?;
139        return Ok(());
140    }
141
142    if let Some(pattern) = &settings.args.glob {
143        let mut file_stats_list = Vec::new();
144
145        println!("Glob '{}':", pattern);
146        let file_paths = glob(pattern).context(
147            "Failed to parse glob pattern, try quoting '<pattern>' to avoid shell parsing",
148        )?;
149        for entry in file_paths {
150            let file_path = entry?;
151            println!("File '{}':", file_path.display());
152            let file_stats = ndjson::FileStats::new(
153                file_path.to_string_lossy().into_owned(),
154                process_ndjson_file_path(&settings, &file_path)?,
155            );
156
157            file_stats.stats.print().with_context(|| {
158                format!("Failed to print stats for file: {}", file_path.display())
159            })?;
160            if settings.args.merge {
161                file_stats_list.push(file_stats)
162            }
163        }
164        if settings.args.merge {
165            println!("Overall Stats");
166            let overall_file_stats: ndjson::Stats = file_stats_list.iter().sum();
167            overall_file_stats
168                .print()
169                .context("Failed to print combined stats")?;
170        }
171        return Ok(());
172    }
173    Ok(())
174}
175
176fn print_completions(args: Cli) {
177    let mut cmd = Cli::command();
178    let shell = args
179        .generate_completions
180        .expect("function only called when argument specified");
181    let bin_name = cmd.get_name().to_string();
182    clap_complete::generate(shell, &mut cmd, bin_name, &mut io::stdout());
183}
184
185pub fn run(args: Cli) -> Result<()> {
186    let now = Instant::now();
187    let settings = Settings::init(args).context("Failed to initialise settings from CLI args")?;
188    if settings.args.generate_completions.is_some() {
189        print_completions(settings.args);
190        return Ok(());
191    } else if is_readable_stdin() {
192        run_stdin(settings).context("Failed to process stdin")?;
193    } else if settings.args == Cli::default() {
194        let mut cmd = Cli::command();
195        cmd.print_help().context("Failed to pring CLI help")?;
196        return Ok(());
197    } else {
198        run_no_stdin(settings).context("Failed to process file(s)")?;
199    }
200    eprintln!("Completed in {}", format_duration(now.elapsed()));
201    Ok(())
202}
203
204#[test]
205fn verify_cli() {
206    Cli::command().debug_assert()
207}