use std::{
ffi::OsString,
fs::{File, OpenOptions},
io::{self, Write},
path::{Path, PathBuf},
process::ExitCode,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
time::Duration,
};
use clap::{ArgAction, CommandFactory, FromArgMatches, Parser};
use eska_analyzer::{
check::{self, Failure, Report},
configuration,
process::Analyzer,
};
use eska_presentation::{
arguments::{
bootstrap_lang,
check::{CheckArgs, Format},
},
localization::{Localizer, resolve_locale_from_environment},
};
use serde_json::json;
use crate::report;
#[derive(Debug, Parser)]
#[command(
name = "eska-check",
disable_help_flag = true,
disable_version_flag = true
)]
struct Cli {
#[arg(long)]
lang: Option<String>,
#[arg(long, default_value = ".")]
project_dir: PathBuf,
#[command(flatten)]
arguments: CheckArgs,
#[arg(short='V', long, action=ArgAction::Version)]
version: Option<bool>,
}
impl std::ops::Deref for Cli {
type Target = CheckArgs;
fn deref(&self) -> &Self::Target {
&self.arguments
}
}
pub fn run() -> ExitCode {
let args: Vec<OsString> = std::env::args_os().collect();
if let Some(code) = eska_presentation::components::tool_contract(
&args,
"eska-check",
env!("CARGO_PKG_VERSION"),
None,
) {
return code;
}
let locale = resolve_locale_from_environment(bootstrap_lang(&args).as_deref());
let localizer = match Localizer::try_new(locale) {
Ok(localizer) => localizer,
Err(error) => {
eprintln!("{error}");
return ExitCode::from(3);
}
};
let matches = localized(&localizer).get_matches_from(args);
let cli = Cli::from_arg_matches(&matches).unwrap_or_else(|error| error.exit());
if let Err(code) = eska_presentation::components::verify_installation("eska-check", &localizer)
{
return code;
}
let initial =
configuration::inspect(&cli.project_dir, cli.application.as_deref()).map_err(Failure::from);
let mut report_file = match initial
.as_ref()
.ok()
.zip(cli.report.as_deref())
.map(|(plan, path)| open_report(path, plan))
.transpose()
{
Ok(file) => file,
Err(source) => return emit_report_error(&cli, &localizer, &source),
};
let cancelled = Arc::new(AtomicBool::new(false));
let signal = Arc::clone(&cancelled);
let result = ctrlc::set_handler(move || signal.store(true, Ordering::Release))
.map_err(|error| {
Failure::Configuration(eska_analyzer::Error::ProcessIo(io::Error::other(error)))
})
.and(initial)
.and_then(|plan| execute(&cli, &plan, &cancelled));
let code = match &result {
Ok(report) => u8::from(report.counts.exceeds(&report.policy)),
Err(error) => {
if error.code() == "cancelled" {
130
} else {
3
}
}
};
if let Some(file) = &mut report_file
&& let Err(source) =
report::write_json(file, result.as_ref()).and_then(|()| file.sync_all())
{
return emit_report_error(&cli, &localizer, &source);
}
let written = match cli.format {
Format::Json => report::write_json(&mut io::stdout().lock(), result.as_ref()),
Format::Human => result.as_ref().map_or(Ok(()), |report| {
report::human(&mut io::stdout().lock(), report, &localizer)
}),
};
if written.is_err() {
eprintln!("{}", localizer.text("check-output-error"));
return ExitCode::from(3);
}
if let Err(error) = &result {
report::human_error(error, &localizer);
}
ExitCode::from(code)
}
fn execute(
cli: &Cli,
initial: &configuration::Plan,
cancelled: &AtomicBool,
) -> Result<Report, Failure> {
let analyzer = Analyzer {
binary: cli
.analyzer
.clone()
.or_else(|| std::env::var_os("ESKA_BSL_ANALYZER").map(PathBuf::from))
.unwrap_or_else(|| PathBuf::from("bsl-analyzer-app")),
timeout: Duration::from_secs(cli.timeout),
cancelled,
connection_closed: None,
};
let plan = configuration::prepare(initial, &analyzer, false)?;
check::analyze(&plan, &analyzer)
}
fn open_report(path: &Path, plan: &configuration::Plan) -> io::Result<File> {
let parent = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
let parent = parent.canonicalize()?;
if plan
.context
.sources()
.iter()
.any(|source| parent.starts_with(source.project().source()))
{
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"report inside project source",
));
}
OpenOptions::new().write(true).create_new(true).open(path)
}
fn emit_report_error(cli: &Cli, localizer: &Localizer, source: &io::Error) -> ExitCode {
if matches!(cli.format, Format::Json) {
let document = json!({"schema_version":1,"kind":"check","status":"incomplete","complete":false,"error":{"code":"report_write","data":{"path":cli.report.as_deref().map(report::path),"io_kind":source.kind().to_string()}}});
let _ = writeln!(io::stdout().lock(), "{document}");
}
eprintln!("{}", localizer.text("check-report-error"));
ExitCode::from(3)
}
fn localized(localizer: &Localizer) -> clap::Command {
let mut command = Cli::command()
.version(env!("CARGO_PKG_VERSION"))
.about(localizer.text("check-tool-about"))
.help_template(format!(
"{{name}} {{version}}\n{{about-with-newline}}\n{}: {{usage}}\n\n{}:\n{{options}}",
localizer.text("cli-usage"),
localizer.text("cli-options")
));
command = eska_presentation::arguments::check::localize(command, localizer);
for (name, key) in [
("lang", "cli-lang-help"),
("project_dir", "check-project-dir-help"),
("version", "cli-version"),
] {
command = command.mut_arg(name, |arg| arg.help(localizer.text(key)));
}
command
}