#![doc = include_str!("../README.md")]
use std::path::{Path, PathBuf};
use clap::ArgMatches;
mod cache;
mod dispatch;
mod format;
mod man;
fn read_aux(path: &Path) -> rustyfi_lang::crossref::AuxTable {
let Ok(text) = std::fs::read_to_string(path) else {
return Default::default();
};
match serde_json::from_str::<std::collections::BTreeMap<String, String>>(&text) {
Ok(t) => t,
Err(_) => Default::default(),
}
}
fn write_aux(path: &Path, aux: &rustyfi_lang::crossref::AuxTable) {
if let Ok(text) = serde_json::to_string(aux) {
let _ = std::fs::write(path, text);
}
}
fn main() {
let code = std::thread::Builder::new()
.name("rustyfi-main".into())
.stack_size(256 * 1024 * 1024)
.spawn(run)
.expect("failed to spawn the main worker thread")
.join()
.expect("the main worker thread panicked");
std::process::exit(code);
}
fn run() -> i32 {
let matches = dispatch::get_matches();
match matches.subcommand() {
Some(("rustyfi", m)) => match m.subcommand() {
Some((name, sm)) if is_package_command(name) => run_package(name, sm),
Some(("multicall", sm)) => run_multicall(sm),
Some(("lsp", sm)) => run_lsp(sm),
Some(("man", _)) => match man::render(&mut std::io::stdout().lock()) {
Ok(()) => 0,
Err(e) => {
eprintln!("error: writing the man page: {e}");
1
}
},
_ => run_compile(m),
},
Some(("satyrographos", sm)) => run_satyrographos(sm),
_ => {
eprintln!("error: no command given");
2
}
}
}
fn run_lsp(m: &ArgMatches) -> i32 {
let lang = match parse_lang_flag(m.get_one::<String>("lang").map(String::as_str)) {
Ok(lang) => lang,
Err(e) => {
eprintln!("error: {e}");
return 2;
}
};
let lib_root = m.get_one::<PathBuf>("lib_root").cloned();
let project = (!m.get_flag("no_typecheck")).then(|| {
let named = lib_root
.clone()
.or_else(|| std::env::var_os("RUSTYFI_LIB_ROOT").map(PathBuf::from));
rustyfi_lsp::project::CheckOptions {
lang,
lib_roots: named.into_iter().collect(),
discover_roots: Some(sg::roots::discover_all),
check_libraries: m.get_flag("check_libraries"),
}
});
let stdin = std::io::stdin();
let stdout = std::io::stdout();
let mut input = stdin.lock();
let mut output = stdout.lock();
let opts = rustyfi_lsp::server::Options {
lang,
lib_root,
project,
};
match rustyfi_lsp::server::run(&mut input, &mut output, opts) {
Ok(code) => code,
Err(e) => {
eprintln!("error: language server I/O: {e}");
1
}
}
}
fn run_compile(m: &ArgMatches) -> i32 {
match cmd_compile(m) {
Ok(()) => 0,
Err(e) => {
eprintln!("Error: {e:#}");
1
}
}
}
fn cmd_compile(m: &ArgMatches) -> anyhow::Result<()> {
use anyhow::Context as _;
let input = m
.get_one::<PathBuf>("input")
.expect("input is required by clap")
.clone();
let format: format::OutputFormat = m
.get_one::<String>("format")
.expect("--format has a clap default")
.parse()
.map_err(|e: String| anyhow::anyhow!(e))?;
let output = m
.get_one::<PathBuf>("output")
.cloned()
.unwrap_or_else(|| input.with_extension(format.extension()));
let named = m
.get_one::<PathBuf>("lib_root")
.cloned()
.or_else(|| std::env::var_os("RUSTYFI_LIB_ROOT").map(PathBuf::from));
let (lib_root, fallback_roots) = match named {
Some(root) => (Some(root), Vec::new()),
None => {
let mut chain = discover_lib_roots(&input).into_iter();
(chain.next(), chain.collect())
}
};
let lang = m.get_one::<String>("lang").map(String::as_str);
let deps_flag = m.get_one::<PathBuf>("deps").map(PathBuf::as_path);
let (version, mode) = resolve_version_and_mode(lang, deps_flag, &input)?;
let is_envelopes_mode = matches!(mode, rustyfi_loader::LoadMode::Envelopes { .. });
let timing = m.get_flag("timing") || std::env::var_os("RUSTYFI_TIMING").is_some();
if timing {
std::env::set_var("RUSTYFI_TIMING", "1");
}
let t_load = std::time::Instant::now();
let program = rustyfi_loader::load(
&input,
&rustyfi_loader::LoadOptions {
lib_root: lib_root.clone(),
fallback_roots: fallback_roots.clone(),
version,
mode,
..Default::default()
},
)
.map_err(|e| anyhow::anyhow!("{e}"))?;
if timing {
eprintln!(
"TIMING load(lex+parse) {:>8.1}ms",
t_load.elapsed().as_secs_f64() * 1e3
);
}
let font_store = resolve_font_store(m, lib_root.as_deref())?;
let deps_lock_digest: Option<String> = is_envelopes_mode
.then(|| discover_deps_lock_digest(&input))
.flatten();
let cache = if m.get_flag("no_cache") || timing {
None
} else {
cache::Cache::open(m.get_one::<PathBuf>("cache_dir").cloned())
};
let cache_key = cache.as_ref().and_then(|_| {
cache::compute_key(
&program,
env!("CARGO_PKG_VERSION"),
version,
&input,
font_store.as_ref(),
format,
deps_lock_digest.as_deref(),
)
});
if let (Some(cache), Some(key)) = (&cache, &cache_key) {
if let Some(hit) = cache.get(key) {
std::fs::write(&output, &hit.pdf)
.with_context(|| format!("cannot write {}", output.display()))?;
eprintln!(
" ---- ---- ---- ----\n \
output written on {} ({} page(s), {} line(s)) (cached).",
output.display(),
hit.pages,
hit.lines
);
return Ok(());
}
}
let base14 = rustyfi_pdf::Base14Metrics;
let metrics: &dyn rustyfi_backend::FontMetrics = match &font_store {
Some(store) => store,
None => &base14,
};
let aux_path: Option<PathBuf> = if m.get_flag("no_aux") {
None
} else {
Some(
m.get_one::<PathBuf>("aux_file")
.cloned()
.unwrap_or_else(|| input.with_extension("satysfi-aux")),
)
};
let mut aux = aux_path.as_deref().map(read_aux).unwrap_or_default();
let t_compile = std::time::Instant::now();
let doc = match version {
rustyfi_syntax::RustyfiVersion::V0_1 => {
rustyfi_lang::compile_document_v1_with_aux(&program.files, metrics, &mut aux)
.map_err(|e| anyhow::anyhow!("{}: {e}", input.display()))?
.0
}
_ => {
let has_v01_dep = program
.files
.iter()
.any(|f| matches!(f.cst, rustyfi_loader::LoadedCst::V0_1(_)));
if has_v01_dep {
rustyfi_lang::compile_document_v006_xver_with_aux(&program.files, metrics, &mut aux)
.map_err(|e| anyhow::anyhow!("{}: {e}", input.display()))?
.0
} else {
let (merged, stages) = rustyfi_lang::merge_v006_program(program);
rustyfi_lang::compile_document_cst_with_stages(&merged, metrics, &mut aux, &stages)
.map_err(|e| anyhow::anyhow!("{}: {e}", input.display()))?
.0
}
}
};
if timing {
eprintln!(
"TIMING compile(elab+eval) {:>8.1}ms ({} pages)",
t_compile.elapsed().as_secs_f64() * 1e3,
doc.pages.len()
);
}
let t_render = std::time::Instant::now();
let bytes: Vec<u8> = match format {
format::OutputFormat::Pdf => match &font_store {
Some(store) => rustyfi_pdf::render_pdf_ttf_with(
&doc.geometry,
&doc.pages,
store,
&doc.images,
&doc.extras,
)?,
None => {
rustyfi_pdf::render_pdf_with(&doc.geometry, &doc.pages, &doc.images, &doc.extras)?
}
},
};
if timing {
eprintln!(
"TIMING render(pdf) {:>8.1}ms",
t_render.elapsed().as_secs_f64() * 1e3
);
}
std::fs::write(&output, &bytes)
.with_context(|| format!("cannot write {}", output.display()))?;
let line_count: usize = doc.pages.iter().map(|p| p.lines.len()).sum();
if let Some(path) = &aux_path {
write_aux(path, &aux);
}
if let (Some(cache), Some(key)) = (&cache, &cache_key) {
let _ = cache.put(key, &bytes, doc.pages.len(), line_count);
}
eprintln!(
" ---- ---- ---- ----\n output written on {} ({} page(s), {} line(s)).",
output.display(),
doc.pages.len(),
line_count
);
Ok(())
}
fn resolve_font_store(
m: &ArgMatches,
lib_root: Option<&Path>,
) -> anyhow::Result<Option<rustyfi_pdf::TtfFontStore>> {
let font_dir = m
.get_one::<PathBuf>("font_dir")
.cloned()
.or_else(|| std::env::var_os("RUSTYFI_FONT_DIR").map(PathBuf::from));
let flags = rustyfi_pdf::FontFlags {
regular: m.get_one::<PathBuf>("font").cloned(),
bold: m.get_one::<PathBuf>("font_bold").cloned(),
oblique: m.get_one::<PathBuf>("font_oblique").cloned(),
};
let registry = rustyfi_pdf::FontRegistry::discover(lib_root, font_dir.as_deref(), &flags)
.map_err(|e| anyhow::anyhow!("font configuration: {e}"))?;
let Some(registry) = registry else {
return Ok(None);
};
let store = registry
.build_store()
.map_err(|e| anyhow::anyhow!("font configuration: {e}"))?;
Ok(Some(store))
}
fn discover_lib_roots(input: &std::path::Path) -> Vec<PathBuf> {
input
.parent()
.map(sg::roots::discover_all)
.unwrap_or_default()
}
fn discover_deps_lock_digest(input: &std::path::Path) -> Option<String> {
let dir = input.parent()?;
let manifest_path = rustyfi_satyrographos::find_upward(dir)?;
let lock_path = rustyfi_satyrographos::lockfile::lock_path_for(&manifest_path);
let lock = rustyfi_satyrographos::lockfile::read(&lock_path).ok()?;
if lock.libraries.is_empty() {
return None;
}
Some(lock.digest())
}
fn parse_lang_flag(flag: Option<&str>) -> anyhow::Result<Option<rustyfi_syntax::RustyfiVersion>> {
flag.map(str::parse::<rustyfi_syntax::RustyfiVersion>)
.transpose()
.map_err(|e| anyhow::anyhow!("--lang: {e}"))
}
fn resolve_version_and_mode(
flag: Option<&str>,
deps_flag: Option<&Path>,
input: &Path,
) -> anyhow::Result<(rustyfi_syntax::RustyfiVersion, rustyfi_loader::LoadMode)> {
use rustyfi_syntax::RustyfiVersion;
let flag = parse_lang_flag(flag)?;
let sniff = std::fs::read_to_string(input)
.ok()
.map(|src| rustyfi_syntax::sniff_headers(&src))
.unwrap_or_default();
let version = match (flag, sniff.version) {
(Some(v), Some(s)) if s != v => {
eprintln!(
"warning: {} looks like a SATySFi {s} document, but --lang {v} \
was given; proceeding as {v}",
input.display()
);
v
}
(Some(v), _) => v,
(None, Some(s)) if !s.is_implemented() => {
return Err(anyhow::anyhow!(
"{}: SATySFi {s} documents are not supported yet; supported: 0.0, 0.1 \
(detected a 0.1-style `use` header; pass `--lang 0.0` to \
force 0.0.6 interpretation)",
input.display()
));
}
(None, _) => RustyfiVersion::DEFAULT,
};
let mode = if let Some(deps) = deps_flag {
rustyfi_loader::LoadMode::Envelopes {
deps: Some(deps.to_path_buf()),
}
} else if sniff.envelope_headers {
rustyfi_loader::LoadMode::Envelopes { deps: None }
} else {
rustyfi_loader::LoadMode::Legacy
};
if matches!(mode, rustyfi_loader::LoadMode::Envelopes { .. })
&& !matches!(version, RustyfiVersion::V0_1)
{
let axis_b = if deps_flag.is_some() {
"--deps"
} else {
"a `use` header"
};
return Err(anyhow::anyhow!(
"{}: {axis_b} selects Envelopes packaging mode, which requires SATySFi 0.1, \
but the language version resolved to {version}; pass --lang 0.1, \
or drop --deps / the `use` header",
input.display()
));
}
Ok((version, mode))
}
use rustyfi_satyrographos as sg;
fn sg_exit_code(err: &sg::Error) -> i32 {
use sg::Error::*;
match err {
RootResolution | ManifestNotFound | NoRegistry => 3,
AlreadyInstalled { .. }
| NotInstalled { .. }
| PackageNotFound { .. }
| VersionNotFound { .. }
| Unsatisfiable { .. }
| VersionConflict { .. } => 4,
LibraryFilter { .. } | AmbiguousLibrary { .. } | AmbiguousDoc { .. } | DocFilter { .. } => 2,
NoDocTarget => 3,
DocBuild { .. } | OpamBuild { .. } => 1,
Io { .. }
| Manifest { .. }
| Config { .. }
| Receipt { .. }
| UnmanagedCollision { .. }
| PathTraversal { .. }
| UnknownSource { .. }
| EmptySource { .. }
| Archive(_)
| MissingDst { .. }
| Satyristes { .. }
| AmbiguousSource { .. }
| ProjectManifest { .. }
| Lockfile { .. }
| UnsupportedSource { .. }
| GitFailed { .. }
| RegistryIndex { .. }
| ChecksumMismatch { .. }
| HttpDisabled { .. }
| HttpFailed { .. }
| InvalidVersion { .. }
| HashFile { .. }
| HashKeyConflict { .. }
| Offline { .. } => 5,
}
}
fn finish<E: std::fmt::Display>(result: Result<(), E>, code: impl FnOnce(&E) -> i32) -> i32 {
match result {
Ok(()) => 0,
Err(e) => {
eprintln!("error: {e}");
code(&e)
}
}
}
fn is_package_command(name: &str) -> bool {
matches!(
name,
"install" | "uninstall" | "build" | "list" | "status" | "search" | "update"
)
}
fn run_package(name: &str, sm: &ArgMatches) -> i32 {
let result = match name {
"install" => cmd_install(sm),
"uninstall" => cmd_uninstall(sm),
"build" => cmd_build(sm),
"list" => cmd_list(sm),
"status" => return cmd_status(sm),
"search" => cmd_search(sm),
"update" => cmd_update(sm),
other => {
eprintln!("error: unknown command `{other}`");
return 2;
}
};
finish(result, sg_exit_code)
}
fn run_satyrographos(m: &ArgMatches) -> i32 {
match m.subcommand() {
Some((name, sm)) => run_package(name, sm),
None => {
eprintln!("error: no satyrographos subcommand given");
2
}
}
}
fn cmd_build(m: &ArgMatches) -> Result<(), sg::Error> {
let source = m
.get_one::<PathBuf>("path")
.cloned()
.unwrap_or_else(|| PathBuf::from("."));
let install = m.get_flag("install").then(|| root_options(m));
let opts = sg::BuildOptions {
lang: m.get_one::<String>("lang").and_then(|s| sg::Lang::parse(s)),
docs: m
.get_many::<String>("doc")
.map(|v| v.cloned().collect())
.unwrap_or_default(),
typesetter: std::env::current_exe().ok(),
verbose: !m.get_flag("quiet"),
lib_root: m.get_one::<PathBuf>("lib_root").cloned(),
install,
};
for report in sg::build(&source, &opts)? {
println!("built {} ({} command(s))", report.name, report.commands.len());
for (product, present) in &report.products {
println!(" {} {}", if *present { "->" } else { "!!" }, product);
}
for path in &report.installed {
println!(" installed {}", path.display());
}
}
Ok(())
}
fn root_options(m: &ArgMatches) -> sg::RootOptions {
sg::RootOptions {
lib_root: m.get_one::<PathBuf>("lib_root").cloned(),
dest: m.get_one::<PathBuf>("dest").cloned(),
}
}
fn registry_options(m: &ArgMatches) -> Result<sg::RegistryOptions, sg::Error> {
let fallback = registry_fallback(m)?;
Ok(sg::RegistryOptions {
url: m.get_one::<String>("registry").cloned(),
offline: m.get_flag("offline"),
mirrors: fallback
.as_ref()
.map(|f| f.mirrors.clone())
.unwrap_or_default(),
kind: fallback.as_ref().and_then(|f| f.kind),
..Default::default()
})
}
fn registry_fallbacks(m: &ArgMatches) -> Result<Vec<sg::RegistryConfig>, sg::Error> {
if let Some(cfg) = std::env::current_dir()
.ok()
.and_then(|cwd| sg::find_upward(&cwd))
.and_then(|manifest| sg::satyristes::read_project(&manifest).ok())
.and_then(|project| project.registry)
{
return Ok(vec![cfg]);
}
let config = match m.get_one::<PathBuf>("config") {
Some(path) => sg::config::read(path)?,
None => sg::config::load()?,
};
Ok(config.registries().to_vec())
}
fn registry_fallback(m: &ArgMatches) -> Result<Option<sg::RegistryConfig>, sg::Error> {
Ok(registry_fallbacks(m)?.into_iter().next())
}
fn find_manifest() -> Option<PathBuf> {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
sg::find_upward(&cwd)
}
fn cmd_install(m: &ArgMatches) -> Result<(), sg::Error> {
let Some(args) = m.get_many::<String>("path") else {
return cmd_install_manifest(m);
};
for arg in args {
install_one(arg, m)?;
}
Ok(())
}
fn install_one(arg: &str, m: &ArgMatches) -> Result<(), sg::Error> {
let libraries: Option<Vec<String>> = m
.get_many::<String>("library")
.map(|vals| vals.cloned().collect());
let sg::RootOptions { lib_root, dest } = root_options(m);
let opts = sg::InstallOptions {
lib_root,
dest,
prefer_library: None,
offline: m.get_flag("offline"),
verbose: true,
libraries,
lang: m
.get_one::<String>("lang")
.and_then(|s| sg::Lang::parse(s)),
force: m.get_flag("force"),
};
let report = if sg::is_url(arg) {
let (url, sha256) = split_url_checksum(arg);
if sha256.is_none() {
eprintln!("warning: nothing verifies {url} (append `#sha256=...` to require a digest)");
}
sg::install_url(url, sha256, &opts)?
} else if Path::new(arg).exists() {
sg::install(Path::new(arg), &opts)?
} else {
let (name, version) = split_name_version(arg);
let reg_opts = registry_options(m)?;
let opts = sg::InstallOptions {
prefer_library: Some(name.to_string()),
..opts
};
let repos = registry_fallbacks(m)?;
let urls: Vec<Option<String>> = if reg_opts.url.is_some() || repos.is_empty() {
vec![None]
} else {
repos.iter().map(|r| r.url.clone()).collect()
};
let mut last: Option<sg::Error> = None;
let mut done = None;
for url in urls {
match sg::install_registry(name, version, &opts, ®_opts, url.as_deref()) {
Ok((report, resolved)) => {
let from = url.unwrap_or_else(|| "the registry".to_string());
eprintln!("fetched {name} {} from {from}", resolved.version);
done = Some(report);
break;
}
Err(e) => last = Some(e),
}
}
match done {
Some(report) => report,
None => return Err(last.unwrap_or(sg::Error::NoRegistry)),
}
};
println!(
"installed {} {} ({} path(s)):",
report.name,
report.version,
report.files.len()
);
for file in &report.files {
println!(" {}", file.display());
}
Ok(())
}
fn split_url_checksum(arg: &str) -> (&str, Option<&str>) {
match arg.split_once("#sha256=") {
Some((url, digest)) if !digest.is_empty() => (url, Some(digest)),
Some((url, _)) => (url, None),
None => (arg, None),
}
}
fn split_name_version(arg: &str) -> (&str, Option<&str>) {
match arg.split_once('@') {
Some((name, version)) if !version.is_empty() => (name, Some(version)),
Some((name, _)) => (name, None),
None => (arg, None),
}
}
fn cmd_install_manifest(m: &ArgMatches) -> Result<(), sg::Error> {
let manifest = find_manifest().ok_or(sg::Error::ManifestNotFound)?;
let sg::RootOptions { mut lib_root, dest } = root_options(m);
if lib_root.is_none() && dest.is_none() && std::env::var_os("RUSTYFI_LIB_ROOT").is_none() {
if let Some(dir) = manifest.parent() {
let sibling = dir.join("lib-rustyfi");
if sibling.is_dir() {
lib_root = Some(sibling);
}
}
}
let opts = sg::RootOptions { lib_root, dest };
let repos = registry_fallbacks(m)?;
let report = sg::install_manifest_reg_multi(&manifest, &opts, ®istry_options(m)?, &repos)?;
println!("reconciled {}", manifest.display());
for ir in &report.installed {
println!(" installed {} {}", ir.name, ir.version);
}
for name in &report.skipped {
println!(" unchanged {name}");
}
for name in &report.removed {
println!(" dropped {name} (left installed; not pruned)");
}
for (url, e) in &report.unreachable_registries {
eprintln!("warning: registry `{url}` could not be reached: {e}");
}
if report.installed.is_empty() && report.skipped.is_empty() {
println!(" (no libraries declared)");
}
Ok(())
}
fn cmd_uninstall(m: &ArgMatches) -> Result<(), sg::Error> {
let names = m
.get_many::<String>("name")
.expect("NAME is required by clap");
let opts = root_options(m);
for name in names {
sg::uninstall(name, &opts)?;
println!("uninstalled {name}");
}
Ok(())
}
fn cmd_list(m: &ArgMatches) -> Result<(), sg::Error> {
let packages = sg::list(&root_options(m))?;
if packages.is_empty() {
println!("(no packages installed)");
} else {
for pkg in &packages {
println!(
"{} {} (lang {}, {} files)\n {}",
pkg.name,
pkg.version,
pkg.lang.as_str(),
pkg.file_count,
pkg.path.display()
);
}
}
Ok(())
}
fn urls_len(repos: &[sg::RegistryConfig], opts: &sg::RegistryOptions) -> usize {
if opts.url.is_some() || repos.is_empty() {
1
} else {
repos.len()
}
}
fn cmd_search(m: &ArgMatches) -> Result<(), sg::Error> {
let terms: Vec<&str> = m
.get_many::<String>("term")
.expect("KEYWORD is required by clap")
.map(String::as_str)
.collect();
let opts = registry_options(m)?;
let repos = registry_fallbacks(m)?;
let urls: Vec<Option<String>> = if opts.url.is_some() || repos.is_empty() {
vec![None]
} else {
repos.iter().map(|r| r.url.clone()).collect()
};
let mut any = false;
let mut failures: Vec<(String, sg::Error)> = Vec::new();
for url in urls {
let label = url.clone().unwrap_or_default();
match sg::search(&terms, &opts, url.as_deref()) {
Ok(hits) => {
for hit in &hits {
any = true;
let where_ = if repos.len() > 1 {
format!(" [{label}]")
} else {
String::new()
};
let raw = hit
.registry_name
.as_deref()
.map(|r| format!(" ({r})"))
.unwrap_or_default();
match &hit.description {
Some(desc) => println!("{}{raw} {} — {desc}{where_}", hit.name, hit.version),
None => println!("{}{raw} {}{where_}", hit.name, hit.version),
}
}
}
Err(e) => failures.push((label, e)),
}
}
for (label, e) in &failures {
eprintln!("warning: registry `{label}` could not be searched: {e}");
}
if !any {
if failures.len() == urls_len(&repos, &opts) {
return Err(failures.into_iter().next().map(|(_, e)| e).unwrap_or(sg::Error::NoRegistry));
}
println!("(no matching packages)");
}
Ok(())
}
fn cmd_update(m: &ArgMatches) -> Result<(), sg::Error> {
let manifest = find_manifest().ok_or(sg::Error::ManifestNotFound)?;
let repos = registry_fallbacks(m)?;
let report = sg::update_multi(&manifest, ®istry_options(m)?, &repos)?;
if let Some(commit) = &report.commit {
println!("index at {commit}");
}
if report.upgrades.is_empty() {
println!("all registry dependencies up to date");
} else {
for up in &report.upgrades {
println!("{}: {} -> {} available", up.name, up.current, up.latest);
}
}
for (url, e) in &report.unreachable {
eprintln!("warning: registry `{url}` could not be refreshed: {e}");
}
Ok(())
}
fn cmd_status(m: &ArgMatches) -> i32 {
let name = m.get_one::<String>("name").map(String::as_str);
let report = match sg::status(name, &root_options(m)) {
Ok(r) => r,
Err(e) => {
eprintln!("error: {e}");
return sg_exit_code(&e);
}
};
if name.is_some() {
for pkg in &report.packages {
println!("{} {}", pkg.name, pkg.version);
for path in &pkg.missing_files {
println!(" MISSING: {}", path.display());
}
if pkg.missing_files.is_empty() {
println!(" {} file(s) present", pkg.total_files);
}
}
} else {
for pkg in &report.packages {
println!(
"{}: {}/{} files present",
pkg.name,
pkg.present_files(),
pkg.total_files
);
}
if report.packages.is_empty() {
println!("(no packages installed)");
}
}
if report.any_missing() {
1
} else {
0
}
}
fn run_multicall(m: &ArgMatches) -> i32 {
match m.subcommand() {
Some(("install", sm)) => finish(multicall_install(sm), |_| 1),
_ => {
eprintln!("error: no multicall subcommand given");
2
}
}
}
fn multicall_install(m: &ArgMatches) -> anyhow::Result<()> {
use anyhow::Context as _;
let dir = m
.get_one::<PathBuf>("dir")
.expect("--dir is required by clap");
let exe = std::env::current_exe().context("cannot locate the current executable")?;
std::fs::create_dir_all(dir).with_context(|| format!("cannot create {}", dir.display()))?;
for alias in ["rustyfi", "satyrographos"] {
let target = dir.join(alias);
if target.exists() {
if !is_same_file(&exe, &target) {
anyhow::bail!(
"{} already exists and is not this executable; refusing to overwrite",
target.display()
);
}
std::fs::remove_file(&target)
.with_context(|| format!("cannot replace {}", target.display()))?;
}
link_or_copy(&exe, &target)
.with_context(|| format!("cannot install alias {}", target.display()))?;
println!("installed alias {}", target.display());
}
Ok(())
}
fn is_same_file(a: &Path, b: &Path) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt as _;
if let (Ok(ma), Ok(mb)) = (std::fs::metadata(a), std::fs::metadata(b)) {
return ma.dev() == mb.dev() && ma.ino() == mb.ino();
}
false
}
#[cfg(not(unix))]
{
match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
(Ok(ca), Ok(cb)) => ca == cb,
_ => false,
}
}
}
fn link_or_copy(from: &Path, to: &Path) -> std::io::Result<()> {
match std::fs::hard_link(from, to) {
Ok(()) => Ok(()),
Err(_) => std::fs::copy(from, to).map(|_| ()),
}
}
#[cfg(test)]
mod arg_tests {
use super::*;
#[test]
fn a_url_carries_its_own_checksum() {
let (url, sha) = split_url_checksum("https://x/p.tar.gz#sha256=abc123");
assert_eq!(url, "https://x/p.tar.gz");
assert_eq!(sha, Some("abc123"));
}
#[test]
fn a_url_without_one_keeps_its_whole_self() {
assert_eq!(
split_url_checksum("https://x/p.tar.gz"),
("https://x/p.tar.gz", None)
);
assert_eq!(
split_url_checksum("https://x/p.tar.gz#sha256="),
("https://x/p.tar.gz", None)
);
}
#[test]
fn a_registry_name_with_a_version_is_unaffected() {
assert_eq!(split_name_version("xpath@0.3.0"), ("xpath", Some("0.3.0")));
assert_eq!(split_name_version("xpath"), ("xpath", None));
}
}