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 #[clap(value_parser)]
35 file_path: Option<std::path::PathBuf>,
36
37 #[clap(short, long)]
39 glob: Option<String>,
40
41 #[clap(short = 'n', long)]
43 lines: Option<usize>,
44
45 #[clap(long)]
47 jsonpath: Option<String>,
48
49 #[clap(long)]
52 inspect_arrays: bool,
53
54 #[clap(long, conflicts_with = "inspect_arrays")]
58 explode_arrays: bool,
59
60 #[clap(long)]
62 merge: bool,
63
64 #[clap(long)]
66 parallel: bool,
67
68 #[clap(short, long)]
70 quiet: bool,
71
72 #[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
90pub 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}