use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
const COMPAT_COMMANDS: &[(&str, &str)] = &[
("bean-check", "check"),
("bean-format", "format"),
("bean-query", "query"),
("bean-report", "report"),
("bean-doctor", "doctor"),
("bean-extract", "extract"),
("bean-price", "price"),
];
fn resolve_target_dir(prefix: Option<&Path>) -> Result<PathBuf> {
if let Some(p) = prefix {
return Ok(p.to_path_buf());
}
let exe = std::env::current_exe().context("could not determine rledger binary path")?;
let dir = exe
.parent()
.context("rledger binary has no parent directory")?;
Ok(dir.to_path_buf())
}
#[cfg(unix)]
fn wrapper_content(subcommand: &str) -> String {
format!("#!/bin/sh\nexec rledger {subcommand} \"$@\"\n")
}
#[cfg(windows)]
fn wrapper_content(subcommand: &str) -> String {
format!("@rledger {subcommand} %*\r\n")
}
#[cfg(unix)]
fn wrapper_filename(name: &str) -> String {
name.to_string()
}
#[cfg(windows)]
fn wrapper_filename(name: &str) -> String {
format!("{name}.cmd")
}
fn is_rledger_wrapper(path: &Path) -> bool {
fs::read_to_string(path).is_ok_and(|contents| contents.contains("rledger"))
}
pub fn install(prefix: Option<&Path>) -> Result<()> {
let dir = resolve_target_dir(prefix)?;
if !dir.exists() {
bail!(
"target directory does not exist: {}\n hint: create it first or use --prefix",
dir.display()
);
}
let mut installed = 0;
for (name, subcommand) in COMPAT_COMMANDS {
let filename = wrapper_filename(name);
let path = dir.join(&filename);
if path.exists() && !is_rledger_wrapper(&path) {
eprintln!(
" skip: {} (exists and is not an rledger wrapper)",
path.display()
);
continue;
}
let content = wrapper_content(subcommand);
fs::write(&path, &content)
.with_context(|| format!("failed to write {}", path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&path, fs::Permissions::from_mode(0o755))
.with_context(|| format!("failed to set permissions on {}", path.display()))?;
}
println!(" installed: {}", path.display());
installed += 1;
}
if installed > 0 {
println!("\n{installed} wrapper(s) installed to {}", dir.display());
if let Ok(path_var) = std::env::var("PATH")
&& !std::env::split_paths(&path_var).any(|p| p == dir)
{
println!(" note: {} may not be on your PATH", dir.display());
}
} else {
println!("nothing to install (all wrappers already exist)");
}
Ok(())
}
pub fn uninstall(prefix: Option<&Path>) -> Result<()> {
let dir = resolve_target_dir(prefix)?;
let mut removed = 0;
for (name, _) in COMPAT_COMMANDS {
let filename = wrapper_filename(name);
let path = dir.join(&filename);
if !path.exists() {
continue;
}
if !is_rledger_wrapper(&path) {
eprintln!(" skip: {} (not an rledger wrapper)", path.display());
continue;
}
fs::remove_file(&path).with_context(|| format!("failed to remove {}", path.display()))?;
println!(" removed: {}", path.display());
removed += 1;
}
if removed > 0 {
println!("\n{removed} wrapper(s) removed from {}", dir.display());
} else {
println!("nothing to remove");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_compat_commands_mapping() {
assert_eq!(COMPAT_COMMANDS.len(), 7);
assert!(
COMPAT_COMMANDS
.iter()
.all(|(name, _)| name.starts_with("bean-"))
);
}
#[cfg(unix)]
#[test]
fn test_wrapper_content_unix() {
let content = wrapper_content("check");
assert!(content.starts_with("#!/bin/sh\n"));
assert!(content.contains("rledger check"));
assert!(content.contains("\"$@\""));
}
#[test]
fn test_install_and_uninstall() {
let dir = tempfile::tempdir().unwrap();
install(Some(dir.path())).unwrap();
for (name, subcommand) in COMPAT_COMMANDS {
let path = dir.path().join(wrapper_filename(name));
assert!(path.exists(), "{} should exist", path.display());
let contents = fs::read_to_string(&path).unwrap();
assert!(contents.contains("rledger"));
assert!(contents.contains(subcommand));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = fs::metadata(&path).unwrap().permissions();
assert_eq!(perms.mode() & 0o111, 0o111, "{name} should be executable");
}
}
uninstall(Some(dir.path())).unwrap();
for (name, _) in COMPAT_COMMANDS {
let path = dir.path().join(wrapper_filename(name));
assert!(!path.exists(), "{} should not exist", path.display());
}
}
#[test]
fn test_install_skips_non_rledger_files() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(wrapper_filename("bean-check"));
fs::write(
&path,
"#!/bin/sh\npython3 -m beancount.scripts.check \"$@\"\n",
)
.unwrap();
install(Some(dir.path())).unwrap();
let contents = fs::read_to_string(&path).unwrap();
assert!(
contents.contains("python3"),
"should not overwrite non-rledger wrapper"
);
}
#[test]
fn test_install_skips_non_utf8_files() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(wrapper_filename("bean-check"));
fs::write(&path, b"\x80\x81\x82\xff").unwrap();
install(Some(dir.path())).unwrap();
let contents = fs::read(&path).unwrap();
assert_eq!(
contents, b"\x80\x81\x82\xff",
"should not overwrite non-UTF-8 file"
);
}
#[test]
fn test_install_overwrites_existing_rledger_wrappers() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(wrapper_filename("bean-check"));
fs::write(&path, "#!/bin/sh\nrledger check-old \"$@\"\n").unwrap();
install(Some(dir.path())).unwrap();
let contents = fs::read_to_string(&path).unwrap();
assert!(
contents.contains("rledger check"),
"should overwrite old rledger wrapper"
);
}
#[test]
fn test_uninstall_skips_non_rledger_files() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(wrapper_filename("bean-check"));
fs::write(&path, "#!/bin/sh\npython3 bean-check \"$@\"\n").unwrap();
uninstall(Some(dir.path())).unwrap();
assert!(path.exists(), "should not remove non-rledger file");
}
}