use std::path::{Path, PathBuf};
use std::time::Instant;
use clap::{Parser, Subcommand};
use crate::config::{Config, Format, MatchOverride, Preset, Tier};
use crate::pipeline::{PipelineError, PipelineEvent, ProgressSink};
use crate::{report, scan_with, Error};
pub const STARTER_CONFIG: &str = include_str!("../pockingbird.toml");
#[derive(Debug, Parser)]
#[command(name = "pockingbird", version, about, long_about = None)]
pub struct Cli {
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Subcommand)]
pub enum Command {
Find {
path: PathBuf,
#[arg(long, value_name = "PATH")]
config: Option<PathBuf>,
#[arg(long, value_enum)]
format: Option<Format>,
#[arg(long, value_enum)]
preset: Option<Preset>,
#[arg(long = "min-agree", value_name = "N")]
min_agree: Option<usize>,
#[arg(long = "tier", value_enum, value_name = "TIER")]
tiers: Vec<Tier>,
#[arg(long = "exclude", value_name = "LOCALE")]
exclude: Vec<String>,
},
Init {
#[arg(value_name = "PATH")]
path: Option<PathBuf>,
#[arg(long)]
force: bool,
},
}
struct FindOptions {
path: PathBuf,
config_path: Option<PathBuf>,
format: Option<Format>,
preset: Option<Preset>,
min_agree: Option<usize>,
tiers: Vec<Tier>,
exclude: Vec<String>,
}
pub fn run(cli: Cli) -> i32 {
match cli.command {
Command::Find {
path,
config,
format,
preset,
min_agree,
tiers,
exclude,
} => find(FindOptions {
path,
config_path: config,
format,
preset,
min_agree,
tiers,
exclude,
}),
Command::Init { path, force } => init(path, force),
}
0
}
fn find(opts: FindOptions) {
use std::io::Write;
let started = Instant::now();
let cli_match = MatchOverride {
preset: opts.preset,
tiers: (!opts.tiers.is_empty()).then_some(opts.tiers),
min_locales_agree: opts.min_agree,
..MatchOverride::default()
};
let mut config = match load_config(opts.config_path.as_deref(), cli_match) {
Ok(config) => config,
Err(message) => {
eprintln!("{message}");
return;
}
};
if !opts.exclude.is_empty() {
config.locales.exclude = opts.exclude;
}
eprintln!("pockingbird: scanning {} …", opts.path.display());
let mut progress = StderrProgress::default();
let report = match scan_with(&opts.path, &config, &mut progress) {
Ok(report) => report,
Err(error) => {
render_scan_error(&error, &opts.path);
return;
}
};
let rendered = if wants_json(opts.format, &config) {
report::to_json(&report.groups, report.total_keys)
} else {
report::to_text(&report.groups, report.total_keys)
};
eprintln!(
"pockingbird: {} groups total in {:.1?}",
report.groups.len(),
started.elapsed()
);
let _ = writeln!(std::io::stdout(), "{rendered}");
}
fn init(path: Option<PathBuf>, force: bool) {
let path = path.unwrap_or_else(|| PathBuf::from("pockingbird.toml"));
if path.exists() && !force {
eprintln!(
"pockingbird: {} already exists — pass --force to overwrite",
path.display()
);
return;
}
match std::fs::write(&path, STARTER_CONFIG) {
Ok(()) => eprintln!("pockingbird: wrote {}", path.display()),
Err(error) => eprintln!("pockingbird: cannot write {}: {error}", path.display()),
}
}
fn render_scan_error(error: &Error, path: &Path) {
match error {
Error::Discover(error) => eprintln!("scan error: {error}"),
Error::NoFiles => eprintln!("no .po files found under {}", path.display()),
Error::Pipeline(PipelineError::NoCatalogs) => eprintln!("no catalogs parsed"),
Error::Pipeline(PipelineError::Config(error)) => eprintln!("config error: {error}"),
}
}
#[derive(Default)]
struct StderrProgress {
skipped: Vec<String>,
tier_started: Option<Instant>,
}
impl ProgressSink for StderrProgress {
fn emit(&mut self, event: PipelineEvent<'_>) {
match event {
PipelineEvent::Skipped { path, error } => {
self.skipped.push(format!(" {}: {error}", path.display()));
}
PipelineEvent::Parsing { done, total } => {
eprint!("\rpockingbird: parsing {done}/{total}");
if done == total {
eprintln!(); if !self.skipped.is_empty() {
eprintln!("pockingbird: skipped {} file(s):", self.skipped.len());
for line in &self.skipped {
eprintln!("{line}");
}
}
}
}
PipelineEvent::Matrix { locales, keys } => {
eprintln!("pockingbird: matrix — {locales} locales × {keys} keys");
}
PipelineEvent::FloorClamped {
configured,
effective,
} => {
eprintln!(
"pockingbird: min_locales_agree {configured} exceeds {effective} active \
locale(s) → lowered to {effective} (otherwise the report would be empty)"
);
}
PipelineEvent::TierStarted(tier) => {
eprint!("pockingbird: grouping {tier:?} …");
self.tier_started = Some(Instant::now());
}
PipelineEvent::TierDone { groups, .. } => {
let elapsed = self.tier_started.take().map(|t| t.elapsed());
match elapsed {
Some(elapsed) => eprintln!(" {groups} groups ({elapsed:.1?})"),
None => eprintln!(" {groups} groups"),
}
}
}
}
}
fn load_config(path: Option<&Path>, cli_match: MatchOverride) -> Result<Config, String> {
let text = match path {
None => String::new(),
Some(path) => std::fs::read_to_string(path)
.map_err(|error| format!("cannot read config {}: {error}", path.display()))?,
};
Config::from_toml_with(&text, cli_match).map_err(|error| match path {
Some(path) => format!("invalid config {}: {error}", path.display()),
None => format!("invalid config: {error}"),
})
}
fn wants_json(format: Option<Format>, config: &Config) -> bool {
matches!(format.unwrap_or(config.output.format), Format::Json)
}
#[cfg(test)]
mod tests {
use super::*;
fn cli_override(
preset: Option<Preset>,
min_agree: Option<usize>,
tiers: Vec<Tier>,
) -> MatchOverride {
MatchOverride {
preset,
tiers: (!tiers.is_empty()).then_some(tiers),
min_locales_agree: min_agree,
..MatchOverride::default()
}
}
#[test]
fn starter_config_parses_to_defaults() {
assert_eq!(
Config::from_toml(STARTER_CONFIG).unwrap(),
Config::default()
);
}
#[test]
fn cli_overrides_follow_precedence() {
let cli = cli_override(Some(Preset::Loose), Some(2), vec![Tier::Exact]);
let config = Config::from_toml_with("[match]\npreset = \"strict\"\n", cli).unwrap();
assert_eq!(config.match_.fuzzy_max_distance, 3); assert_eq!(config.match_.min_locales_agree, 2); assert_eq!(config.match_.tiers, vec![Tier::Exact]); }
#[test]
fn cli_preset_keeps_explicit_file_field() {
let cli = cli_override(Some(Preset::Loose), None, Vec::new());
let config =
Config::from_toml_with("[match]\npreset = \"strict\"\nmin_locales_agree = 3\n", cli)
.unwrap();
assert_eq!(config.match_.min_locales_agree, 3); assert_eq!(config.match_.fuzzy_max_distance, 3); assert_eq!(config.match_.tiers, Tier::ALL.to_vec()); }
#[test]
fn empty_cli_override_equals_file_only() {
let text = "[match]\npreset = \"loose\"\n";
assert_eq!(
Config::from_toml_with(text, MatchOverride::default()).unwrap(),
Config::from_toml(text).unwrap(),
);
}
}