#[path = "../build/mod.rs"]
mod build;
use std::path::PathBuf;
use anyhow::Result;
use clap::Parser;
#[derive(Parser, Debug)]
#[command(
name = "installrs",
about = "Build self-contained installer executables"
)]
struct Cli {
#[arg(long, default_value = ".")]
target: PathBuf,
#[arg(long, short, default_value = "./installer")]
output: PathBuf,
#[arg(long, default_value = "lzma")]
compression: String,
#[arg(long, default_value = ".git,.svn,node_modules")]
ignore: String,
#[arg(long)]
target_triple: Option<String>,
#[arg(long = "feature", value_name = "NAME", action = clap::ArgAction::Append)]
features: Vec<String>,
#[arg(long, short, action = clap::ArgAction::Count)]
verbose: u8,
#[arg(long, short)]
quiet: bool,
#[arg(long, short)]
silent: bool,
}
fn main() {
let cli = Cli::parse();
let log_level = if cli.silent {
"off"
} else if cli.quiet {
"error"
} else {
match cli.verbose {
0 => "info",
1 => "debug",
_ => "trace",
}
};
env_logger::Builder::new()
.filter_level(log_level.parse().unwrap())
.format_timestamp(None)
.format_target(false)
.init();
if let Err(e) = run(cli) {
log::error!("{e:#}");
std::process::exit(1);
}
}
fn run(cli: Cli) -> Result<()> {
let target = cli
.target
.canonicalize()
.unwrap_or_else(|_| cli.target.clone());
let build_dir = target.join("build");
let ignore_patterns: Vec<String> = cli
.ignore
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
let mut output_file = cli.output;
if let Some(ref triple) = cli.target_triple {
if triple.contains("windows") && output_file.extension().map(|e| e != "exe").unwrap_or(true)
{
output_file.set_extension("exe");
}
}
let (installer_win_resource, uninstaller_win_resource) =
build::builder::read_win_resource_config(&target)?;
let gui_enabled = build::builder::read_gui_config(&target)?;
if gui_enabled {
log::info!("GUI support enabled");
}
let params = build::builder::BuildParams {
target_dir: target,
build_dir,
output_file,
compression: cli.compression,
ignore_patterns,
target_triple: cli.target_triple,
verbosity: cli.verbose,
installer_win_resource,
uninstaller_win_resource,
gui_enabled,
features: cli.features,
};
build::builder::build(params)
}
#[cfg(test)]
mod tests {
use super::build::compress;
use super::build::scanner;
use std::io::Read;
#[test]
fn validate_accepts_lzma() {
compress::validate_method("lzma").unwrap();
}
#[test]
fn validate_accepts_gzip() {
compress::validate_method("gzip").unwrap();
}
#[test]
fn validate_accepts_bzip2() {
compress::validate_method("bzip2").unwrap();
}
#[test]
fn validate_accepts_none() {
compress::validate_method("none").unwrap();
}
#[test]
fn validate_rejects_unknown() {
assert!(compress::validate_method("zstd")
.unwrap_err()
.to_string()
.contains("unsupported"));
}
#[test]
fn validate_rejects_empty_string() {
assert!(compress::validate_method("").is_err());
}
const SAMPLE: &[u8] = b"round-trip test data for compression algorithms";
#[test]
fn compress_none_is_passthrough() {
assert_eq!(compress::compress(SAMPLE, "none").unwrap(), SAMPLE);
}
#[test]
fn compress_empty_is_passthrough() {
assert_eq!(compress::compress(SAMPLE, "").unwrap(), SAMPLE);
}
#[test]
fn compress_lzma_roundtrip() {
let compressed = compress::compress(SAMPLE, "lzma").unwrap();
let mut out = Vec::new();
lzma_rs::lzma_decompress(&mut std::io::Cursor::new(&compressed), &mut out).unwrap();
assert_eq!(out, SAMPLE);
}
#[test]
fn compress_gzip_roundtrip() {
let compressed = compress::compress(SAMPLE, "gzip").unwrap();
let mut out = Vec::new();
flate2::read::GzDecoder::new(compressed.as_slice())
.read_to_end(&mut out)
.unwrap();
assert_eq!(out, SAMPLE);
}
#[test]
fn compress_bzip2_roundtrip() {
let compressed = compress::compress(SAMPLE, "bzip2").unwrap();
let mut out = Vec::new();
bzip2::read::BzDecoder::new(compressed.as_slice())
.read_to_end(&mut out)
.unwrap();
assert_eq!(out, SAMPLE);
}
#[test]
fn compress_unknown_errors() {
assert!(compress::compress(SAMPLE, "zstd").is_err());
}
fn scan_str(source: &str) -> scanner::ScanResult {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join("lib.rs"), source).unwrap();
scanner::scan_source_dir(tmp.path()).unwrap()
}
#[test]
fn scanner_detects_install_fn() {
let r = scan_str("pub fn install(i: &mut T) -> R { Ok(()) }");
assert!(r.has_install_fn);
assert!(!r.has_uninstall_fn);
}
#[test]
fn scanner_detects_uninstall_fn() {
let r = scan_str("pub fn uninstall(i: &mut T) -> R { Ok(()) }");
assert!(!r.has_install_fn);
assert!(r.has_uninstall_fn);
}
#[test]
fn scanner_detects_both_fns() {
let r = scan_str("pub fn install() {} pub fn uninstall() {}");
assert!(r.has_install_fn);
assert!(r.has_uninstall_fn);
}
#[test]
fn scanner_detects_neither_fn() {
let r = scan_str("fn helper() {}");
assert!(!r.has_install_fn);
assert!(!r.has_uninstall_fn);
}
fn has_path(list: &[scanner::SourceRef], path: &str) -> bool {
list.iter().any(|r| r.path == path)
}
#[test]
fn scanner_detects_source_macro() {
let r = scan_str(
r#"fn install(i: &mut T) { i.file(installrs::source!("cfg.toml"), "dst").install().unwrap(); }"#,
);
assert!(has_path(&r.install_sources, "cfg.toml"));
}
#[test]
fn scanner_detects_unqualified_source_macro() {
let r = scan_str(r#"fn install(i: &mut T) { i.file(source!("data.txt"), "dst"); }"#);
assert!(has_path(&r.install_sources, "data.txt"));
}
#[test]
fn scanner_no_duplicate_sources() {
let r = scan_str(
r#"fn install(i: &mut T) {
i.file(source!("x.txt"), "a");
i.file(source!("x.txt"), "b");
}"#,
);
assert_eq!(
r.install_sources
.iter()
.filter(|p| p.path == "x.txt")
.count(),
1
);
}
#[test]
fn scanner_source_nested_path() {
let r =
scan_str(r#"fn install(i: &mut T) { i.file(source!("vendor/lib.so"), "lib.so"); }"#);
assert!(
has_path(&r.install_sources, "vendor/lib.so"),
"got: {:?}",
r.install_sources
);
}
#[test]
fn scanner_source_in_dir_call() {
let r = scan_str(r#"fn install(i: &mut T) { i.dir(source!("assets/icons"), "icons"); }"#);
assert!(
has_path(&r.install_sources, "assets/icons"),
"got: {:?}",
r.install_sources
);
}
#[test]
fn scanner_source_in_uninstall_goes_to_uninstall() {
let r = scan_str(r#"fn uninstall(i: &mut T) { i.file(source!("cleanup.sh"), "dst"); }"#);
assert!(has_path(&r.uninstall_sources, "cleanup.sh"));
assert!(r.install_sources.is_empty());
}
#[test]
fn scanner_source_outside_install_uninstall_goes_to_both() {
let r = scan_str(r#"fn helper(i: &mut T) { i.file(source!("shared.dat"), "dst"); }"#);
assert!(has_path(&r.install_sources, "shared.dat"));
assert!(has_path(&r.uninstall_sources, "shared.dat"));
}
#[test]
fn scanner_ignores_non_source_macros() {
let r = scan_str(
r#"fn install(i: &mut T) {
println!("{}", "nope.txt");
format!("also-ignored.txt");
i.file(source!("real.txt"), "dst");
}"#,
);
assert_eq!(r.install_sources.len(), 1);
assert_eq!(r.install_sources[0].path, "real.txt");
}
#[test]
fn scanner_parses_ignore_option() {
let r = scan_str(
r#"fn install(i: &mut T) { i.dir(source!("assets", ignore = ["*.bak", "scratch"]), "dst"); }"#,
);
let s = r
.install_sources
.iter()
.find(|s| s.path == "assets")
.expect("missing assets source");
assert_eq!(s.ignore, vec!["*.bak".to_string(), "scratch".to_string()]);
}
#[test]
fn scanner_parses_features_option() {
let r = scan_str(
r#"fn install(i: &mut T) { i.file(source!("docs.tar", features = ["docs", "full"]), "dst"); }"#,
);
let s = r
.install_sources
.iter()
.find(|s| s.path == "docs.tar")
.expect("missing docs.tar source");
assert_eq!(s.features, vec!["docs".to_string(), "full".to_string()]);
}
#[test]
fn scanner_empty_features_wins_over_gated_repeat() {
let r = scan_str(
r#"fn install(i: &mut T) {
i.file(source!("x.dat", features = ["pro"]), "a");
i.file(source!("x.dat"), "b");
}"#,
);
let s = r
.install_sources
.iter()
.find(|s| s.path == "x.dat")
.unwrap();
assert!(
s.features.is_empty(),
"unconditional reference should clear feature gates, got {:?}",
s.features
);
}
#[test]
fn scanner_unions_features_across_invocations() {
let r = scan_str(
r#"fn install(i: &mut T) {
i.file(source!("x.dat", features = ["a"]), "1");
i.file(source!("x.dat", features = ["b"]), "2");
}"#,
);
let s = r
.install_sources
.iter()
.find(|s| s.path == "x.dat")
.unwrap();
assert!(s.features.contains(&"a".to_string()));
assert!(s.features.contains(&"b".to_string()));
}
#[test]
fn scanner_merges_ignore_across_invocations() {
let r = scan_str(
r#"fn install(i: &mut T) {
i.dir(source!("assets", ignore = ["*.bak"]), "a");
i.dir(source!("assets", ignore = ["scratch"]), "b");
}"#,
);
let s = r
.install_sources
.iter()
.find(|s| s.path == "assets")
.unwrap();
assert!(s.ignore.contains(&"*.bak".to_string()));
assert!(s.ignore.contains(&"scratch".to_string()));
}
}