#![allow(rustdoc::broken_intra_doc_links)]
mod crawl;
mod git;
mod oci;
mod output;
mod sarif;
mod scan;
mod settings;
mod stats;
use std::fs::File;
use std::io::{self, BufWriter, Write};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use clap::{Args, Parser, Subcommand, ValueEnum};
use pelicode_core::{
Config, FileFeatures, IdentMode, Language, Level, discover, extract_all, slash_path,
};
use pelicode_db::{Database, PackageInfo};
#[derive(Parser)]
#[command(name = "pelicode", version, about)]
struct Cli {
#[command(subcommand)]
command: Command,
#[arg(long, global = true, value_name = "FILE", display_order = 900)]
config: Option<PathBuf>,
#[arg(long, global = true, conflicts_with = "config", display_order = 901)]
no_config: bool,
}
#[derive(Subcommand)]
enum Command {
Index(IndexArgs),
Extract(ExtractArgs),
Export(ExportArgs),
Scan(scan::ScanArgs),
Crawl(crawl::CrawlArgs),
Stats(stats::StatsArgs),
Clean(CleanArgs),
Snapshot(SnapshotArgs),
Pull(PullArgs),
Languages,
}
#[derive(Args)]
struct FeatureArgs {
#[arg(long, default_value_t = Config::default().min_tokens)]
min_tokens: usize,
#[arg(long, default_value = "uniform")]
ident_mode: IdentMode,
#[arg(long, value_delimiter = ',', default_value = "N1")]
levels: Vec<Level>,
#[arg(long, default_value_t = Config::default().kgram)]
kgram: usize,
#[arg(long, default_value_t = Config::default().window)]
window: usize,
#[arg(long, default_value_t = Config::default().minhash_size)]
minhash_size: usize,
#[arg(long, default_value_t = Config::default().bands)]
bands: usize,
}
impl FeatureArgs {
fn config(&self, keep_tokens: bool) -> Result<Config> {
let config = Config {
min_tokens: self.min_tokens,
ident_mode: self.ident_mode,
kgram: self.kgram,
window: self.window,
minhash_size: self.minhash_size,
bands: self.bands,
fingerprint_levels: self.levels.clone(),
keep_tokens,
};
config.validate()?;
Ok(config)
}
}
#[derive(Args)]
struct IndexArgs {
#[arg(required = true)]
paths: Vec<PathBuf>,
#[arg(long)]
db: Option<PathBuf>,
#[arg(long, default_value = "local")]
ecosystem: String,
#[arg(long)]
name: Option<String>,
#[arg(long, default_value = "unversioned")]
version: String,
#[arg(long)]
license: Option<String>,
#[arg(long)]
repository: Option<String>,
#[arg(short, long, value_enum, default_value_t = SummaryFormat::Text)]
format: SummaryFormat,
#[arg(long)]
refresh_stats: bool,
#[command(flatten)]
features: FeatureArgs,
}
#[derive(Args)]
struct ExtractArgs {
#[arg(required = true)]
paths: Vec<PathBuf>,
#[arg(short, long, value_enum, default_value_t = Format::Text)]
format: Format,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long, value_name = "LEVEL", num_args = 0..=1, default_missing_value = "N1")]
tokens: Option<Level>,
#[command(flatten)]
features: FeatureArgs,
}
#[derive(Args)]
struct ExportArgs {
#[arg(long)]
db: Option<PathBuf>,
#[arg(short, long, value_enum, default_value_t = Format::Text)]
format: Format,
#[arg(short, long)]
output: Option<PathBuf>,
}
#[derive(Args)]
struct CleanArgs {
#[arg(long)]
db: Option<PathBuf>,
#[arg(long)]
dry_run: bool,
#[arg(long)]
no_vacuum: bool,
#[arg(short, long, value_enum, default_value_t = SummaryFormat::Text)]
format: SummaryFormat,
}
#[derive(Args)]
struct SnapshotArgs {
#[arg(long)]
db: Option<PathBuf>,
#[arg(short, long)]
out: PathBuf,
#[arg(long = "ecosystem", value_name = "ECOSYSTEM", value_delimiter = ',')]
ecosystems: Vec<String>,
#[arg(long)]
latest: bool,
#[arg(long)]
force: bool,
#[arg(long, value_name = "FILE")]
annotations: Option<PathBuf>,
#[arg(short, long, value_enum, default_value_t = SummaryFormat::Text)]
format: SummaryFormat,
}
#[derive(Args)]
struct PullArgs {
reference: String,
#[arg(short, long)]
out: Option<PathBuf>,
#[arg(long, conflicts_with = "out")]
info: bool,
#[arg(short, long, value_enum, default_value_t = SummaryFormat::Text)]
format: SummaryFormat,
}
#[derive(Clone, Copy, ValueEnum)]
enum Format {
Text,
Json,
Csv,
}
#[derive(Clone, Copy, ValueEnum)]
enum SummaryFormat {
Text,
Json,
}
fn main() -> Result<()> {
match run(Cli::parse()) {
Err(e) if is_broken_pipe(&e) => Ok(()),
other => other,
}
}
fn is_broken_pipe(e: &anyhow::Error) -> bool {
e.chain().any(|cause| {
cause
.downcast_ref::<io::Error>()
.is_some_and(|io| io.kind() == io::ErrorKind::BrokenPipe)
|| cause
.downcast_ref::<csv::Error>()
.is_some_and(|c| matches!(c.kind(), csv::ErrorKind::Io(io) if io.kind() == io::ErrorKind::BrokenPipe))
|| cause
.downcast_ref::<serde_json::Error>()
.is_some_and(|j| j.io_error_kind() == Some(io::ErrorKind::BrokenPipe))
})
}
fn run(cli: Cli) -> Result<()> {
let loaded = settings::load(cli.config.as_deref(), cli.no_config)?;
let settings = loaded.as_ref();
match cli.command {
Command::Index(args) => index(args, settings),
Command::Extract(args) => extract(args),
Command::Export(args) => export(args, settings),
Command::Stats(args) => stats::run(args, settings),
Command::Clean(args) => clean(args, settings),
Command::Snapshot(args) => snapshot(args, settings),
Command::Pull(args) => pull(args),
Command::Crawl(args) => {
if !crawl::run(args, settings)? {
std::process::exit(1);
}
Ok(())
}
Command::Scan(args) => {
if scan::run(args, settings)? {
std::process::exit(1);
}
Ok(())
}
Command::Languages => {
for lang in Language::ALL {
println!("{:<8} {}", lang.name(), lang.extensions().join(", "));
}
Ok(())
}
}
}
fn databases(db: &[PathBuf], settings: Option<&settings::Loaded>) -> Vec<PathBuf> {
if !db.is_empty() {
return db.to_vec();
}
let configured = settings
.map(settings::Loaded::databases)
.unwrap_or_default();
if configured.is_empty() {
vec![PathBuf::from("pelicode.db")]
} else {
configured
}
}
fn database(db: &Option<PathBuf>, settings: Option<&settings::Loaded>) -> Result<PathBuf> {
let mut all = databases(db.as_slice(), settings);
if all.len() > 1 {
bail!(
"the settings file lists {} databases; give one with --db",
all.len()
);
}
Ok(all.remove(0))
}
fn extract_paths(paths: &[PathBuf], config: &Config) -> Result<Vec<FileFeatures>> {
let mut results = Vec::new();
for path in paths {
let files = discover(path).with_context(|| format!("cannot scan {}", path.display()))?;
let prefix = (paths.len() > 1 && path.is_dir())
.then(|| path.canonicalize().ok()?.file_name().map(PathBuf::from))
.flatten();
for result in extract_all(&files, config) {
match result {
Ok(mut file) => {
if let Some(prefix) = &prefix {
file.path = slash_path(&prefix.join(&file.path));
}
results.push(file);
}
Err(e) => eprintln!("warning: {e}"),
}
}
}
Ok(results)
}
fn database_size(path: &Path) -> u64 {
let size = |p: &Path| std::fs::metadata(p).map(|m| m.len()).unwrap_or(0);
let mut wal = path.as_os_str().to_owned();
wal.push("-wal");
size(path) + size(Path::new(&wal))
}
fn snapshot(args: SnapshotArgs, settings: Option<&settings::Loaded>) -> Result<()> {
let db_path = database(&args.db, settings)?;
if !db_path.exists() {
bail!("{}: no such database", db_path.display());
}
if args.out.exists() {
if !args.force {
bail!(
"{}: already exists (use --force to replace it)",
args.out.display()
);
}
std::fs::remove_file(&args.out)
.with_context(|| format!("cannot remove {}", args.out.display()))?;
}
let db = Database::open_unchecked(&db_path)?;
let options = pelicode_db::SnapshotOptions {
ecosystems: args.ecosystems,
latest_only: args.latest,
};
let summary = db
.snapshot(&args.out, &options)
.with_context(|| format!("cannot write {}", args.out.display()))?;
match args.format {
SummaryFormat::Text => println!(
"{}: {} packages, {} versions, {} functions, {:.1} MB",
args.out.display(),
summary.packages,
summary.versions,
summary.functions,
summary.bytes as f64 / 1024.0 / 1024.0
),
SummaryFormat::Json => println!("{}", serde_json::to_string_pretty(&summary)?),
}
if let Some(path) = args.annotations {
let file = serde_json::json!({ "$manifest": oci::annotations(&summary) });
std::fs::write(&path, serde_json::to_string_pretty(&file)? + "\n")
.with_context(|| format!("cannot write {}", path.display()))?;
}
Ok(())
}
fn pull(args: PullArgs) -> Result<()> {
let reference: oci::Reference = args.reference.parse()?;
if args.info {
let info = oci::info(&reference)?;
match args.format {
SummaryFormat::Text => {
println!("{reference}");
println!(" digest: {}", info.digest);
println!(" size: {:.1} MB", info.size as f64 / 1024.0 / 1024.0);
for (key, value) in &info.annotations {
let key = key.strip_prefix(oci::ANNOTATION_PREFIX).unwrap_or(key);
println!(" {key}: {value}");
}
}
SummaryFormat::Json => println!("{}", serde_json::to_string_pretty(&info)?),
}
return Ok(());
}
let fetched = oci::fetch(&reference, &oci::cache_dir()?)?;
eprintln!(
"{} {reference} ({})",
if fetched.downloaded {
"fetched"
} else {
"up to date:"
},
fetched.digest
);
let path = match args.out {
Some(out) => {
std::fs::copy(&fetched.path, &out)
.with_context(|| format!("cannot write {}", out.display()))?;
out
}
None => fetched.path,
};
println!("{}", path.display());
Ok(())
}
fn clean(args: CleanArgs, settings: Option<&settings::Loaded>) -> Result<()> {
let db_path = database(&args.db, settings)?;
if !db_path.exists() {
bail!("{}: no such database", db_path.display());
}
let mut db = Database::open_unchecked(&db_path)?;
let before = database_size(&db_path);
let removed = if args.dry_run {
db.unreferenced()?
} else {
let removed = db.clean()?;
if !args.no_vacuum {
db.vacuum()?;
}
removed
};
let after = database_size(&db_path);
match args.format {
SummaryFormat::Text => {
let verb = if args.dry_run {
"would remove"
} else {
"removed"
};
println!(
"{verb} {} files and {} functions no longer referenced",
removed.files, removed.functions
);
if !args.dry_run {
let mb = |b: u64| b as f64 / 1024.0 / 1024.0;
println!("database: {:.1} MB -> {:.1} MB", mb(before), mb(after));
}
}
SummaryFormat::Json => {
let value = serde_json::json!({
"dry_run": args.dry_run,
"removed": removed,
"size_before": before,
"size_after": after,
});
println!("{}", serde_json::to_string_pretty(&value)?);
}
}
Ok(())
}
fn index(args: IndexArgs, settings: Option<&settings::Loaded>) -> Result<()> {
let config = args.features.config(false)?;
let db_path = database(&args.db, settings)?;
let name = match &args.name {
Some(name) => name.clone(),
None => default_name(&args.paths[0])?,
};
let files = extract_paths(&args.paths, &config)?;
let mut db = Database::open(&db_path, &config)
.with_context(|| format!("cannot open {}", db_path.display()))?;
let package = PackageInfo {
ecosystem: args.ecosystem,
name,
version: args.version,
license_spdx: args.license,
repository_url: args.repository,
..PackageInfo::default()
};
let summary = db.index(&package, &files)?;
if args.refresh_stats {
db.refresh_hash_stats()?;
}
let stats = db.stats()?;
match args.format {
SummaryFormat::Text => {
println!(
"indexed {}:{}@{} into {}",
package.ecosystem,
package.name,
package.version,
db_path.display()
);
println!(
" files: {} ({} new), functions: {} new ({} with fingerprints)",
summary.files, summary.new_files, summary.functions, summary.indexed_functions
);
println!(
" database: {} packages, {} versions, {} files, {} functions",
stats.packages, stats.versions, stats.source_files, stats.functions
);
}
SummaryFormat::Json => {
let value = serde_json::json!({
"package": {
"ecosystem": package.ecosystem,
"name": package.name,
"version": package.version,
},
"summary": summary,
"database": stats,
});
println!("{}", serde_json::to_string_pretty(&value)?);
}
}
Ok(())
}
fn default_name(path: &Path) -> Result<String> {
let path = path
.canonicalize()
.with_context(|| format!("cannot access {}", path.display()))?;
let dir = if path.is_file() {
path.parent().unwrap_or(&path)
} else {
&path
};
match dir.file_name() {
Some(name) => Ok(name.to_string_lossy().into_owned()),
None => bail!(
"cannot infer a package name from {}; use --name",
path.display()
),
}
}
fn extract(args: ExtractArgs) -> Result<()> {
let config = args.features.config(args.tokens.is_some())?;
let files = extract_paths(&args.paths, &config)?;
let mut out = open_output(args.output.as_deref())?;
match args.format {
Format::Text => output::extract_text(&mut out, &files, args.tokens)?,
Format::Json => output::json(&mut out, &files)?,
Format::Csv => output::extract_csv(&mut out, &files, args.tokens)?,
}
out.flush()?;
Ok(())
}
fn export(args: ExportArgs, settings: Option<&settings::Loaded>) -> Result<()> {
let db_path = oci::resolve(&database(&args.db, settings)?)?;
if !db_path.exists() {
bail!("{}: no such database", db_path.display());
}
let db = Database::open_unchecked(&db_path)?;
let rows = db.functions()?;
let mut out = open_output(args.output.as_deref())?;
match args.format {
Format::Text => output::export_text(&mut out, &rows)?,
Format::Json => output::json(&mut out, &rows)?,
Format::Csv => output::export_csv(&mut out, &rows)?,
}
out.flush()?;
Ok(())
}
fn open_output(path: Option<&Path>) -> Result<Box<dyn Write>> {
Ok(match path {
Some(path) => Box::new(BufWriter::new(
File::create(path).with_context(|| format!("cannot create {}", path.display()))?,
)),
None => Box::new(BufWriter::new(io::stdout().lock())),
})
}