use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{anyhow, bail, Context, Result};
use super::manifest::{ModuleManifest, MANIFEST_FILE};
use super::paths;
pub struct Installed {
pub root: PathBuf,
pub source: String,
pub id: String,
}
pub fn install(spec: &str, git_ref: Option<&str>, yes: bool) -> Result<Installed> {
let (url, slug, sub) = parse_spec(spec)?;
let staging = paths::staging_dir();
if let Some(parent) = staging.parent() {
fs::create_dir_all(parent).context("create managed modules dir")?;
}
let result = install_inner(&url, &slug, &sub, git_ref, yes, &staging, spec);
if result.is_err() {
let _ = fs::remove_dir_all(&staging);
}
result
}
pub fn is_removable(root: &Path) -> bool {
paths::is_managed_git_path(root)
}
fn install_inner(
url: &str,
slug: &str,
sub: &str,
git_ref: Option<&str>,
yes: bool,
staging: &Path,
spec: &str,
) -> Result<Installed> {
git(&["clone", "--depth", "1", url, &staging.to_string_lossy()])
.with_context(|| format!("git clone {url}"))?;
if let Some(r) = git_ref {
git(&[
"-C",
&staging.to_string_lossy(),
"fetch",
"--depth",
"1",
"origin",
r,
])
.with_context(|| format!("fetch ref {r}"))?;
git(&["-C", &staging.to_string_lossy(), "checkout", "FETCH_HEAD"])
.with_context(|| format!("checkout ref {r}"))?;
}
let module_root = if sub.is_empty() {
staging.to_path_buf()
} else {
staging.join(sub)
};
let manifest_path = module_root.join(MANIFEST_FILE);
let before = fs::read(&manifest_path)
.with_context(|| format!("no {MANIFEST_FILE} at {}", module_root.display()))?;
let manifest = ModuleManifest::load(&module_root).map_err(|e| anyhow!("{e}"))?;
let sha = git_capture(&["-C", &staging.to_string_lossy(), "rev-parse", "HEAD"])?;
let sha = sha.trim().to_string();
print_preview(spec, &sha, &manifest);
if !yes && !confirm()? {
bail!("aborted");
}
for b in &manifest.build {
run_build(&module_root, &b.command)
.with_context(|| format!("build step {:?} failed", b.command))?;
}
let after = fs::read(&manifest_path).context("re-read manifest after build")?;
if before != after {
bail!("manifest changed during build — refusing to install");
}
let dest = paths::git_dir(slug, &short(&sha));
if dest.exists() {
let _ = fs::remove_dir_all(&dest);
}
fs::rename(&module_root, &dest).with_context(|| format!("move into {}", dest.display()))?;
if !sub.is_empty() {
let _ = fs::remove_dir_all(staging);
}
Ok(Installed {
root: dest,
source: format!("{spec}@{sha}"),
id: manifest.id,
})
}
fn parse_spec(spec: &str) -> Result<(String, String, String)> {
let spec = spec.trim();
if spec.is_empty() {
bail!("usage: bohay module install owner/repo[/sub]");
}
let is_url_or_path = spec.contains("://")
|| spec.starts_with('/')
|| spec.starts_with('.')
|| spec.starts_with('~');
if is_url_or_path {
let slug = Path::new(spec.trim_end_matches('/'))
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("module")
.trim_end_matches(".git")
.to_string();
return Ok((spec.to_string(), slug, String::new()));
}
let parts: Vec<&str> = spec.split('/').filter(|p| !p.is_empty()).collect();
if parts.len() < 2 {
bail!("expected owner/repo[/sub], got {spec:?}");
}
let (owner, repo) = (parts[0], parts[1]);
let sub = parts[2..].join("/");
let url = format!("https://github.com/{owner}/{repo}.git");
Ok((url, format!("{owner}-{repo}"), sub))
}
fn print_preview(spec: &str, sha: &str, m: &ModuleManifest) {
println!("Install module from {spec}");
println!(" id: {}", m.id);
println!(" name: {} {}", m.name, m.version);
println!(" commit: {}", short(sha));
let mut commands: Vec<String> = Vec::new();
for b in &m.build {
commands.push(format!(" build: {}", b.command.join(" ")));
}
for a in &m.actions {
commands.push(format!(" action {}: {}", a.id, a.command.join(" ")));
}
for p in &m.panes {
commands.push(format!(" pane {}: {}", p.id, p.command.join(" ")));
}
for e in &m.events {
commands.push(format!(" on {}: {}", e.on, e.command.join(" ")));
}
if commands.is_empty() {
println!(" (no commands declared)");
} else {
println!("Commands this module can run:");
for c in commands {
println!("{c}");
}
}
}
fn confirm() -> Result<bool> {
print!("Proceed? [y/N] ");
io::stdout().flush().ok();
let mut line = String::new();
io::stdin().read_line(&mut line)?;
Ok(matches!(line.trim(), "y" | "Y" | "yes"))
}
fn run_build(dir: &Path, argv: &[String]) -> Result<()> {
let Some((program, args)) = argv.split_first() else {
bail!("empty build command");
};
let mut cmd = Command::new(program);
cmd.args(args).current_dir(dir);
cmd.env_clear();
for (k, v) in std::env::vars() {
if !k.starts_with("BOHAY_") {
cmd.env(k, v);
}
}
let status = cmd.status().with_context(|| format!("spawn {program}"))?;
if !status.success() {
bail!("exited with {status}");
}
Ok(())
}
fn git(args: &[&str]) -> Result<()> {
let status = Command::new("git")
.args(args)
.status()
.context("running git (is it installed?)")?;
if !status.success() {
bail!("git {} failed", args.first().copied().unwrap_or(""));
}
Ok(())
}
fn git_capture(args: &[&str]) -> Result<String> {
let out = Command::new("git")
.args(args)
.output()
.context("running git")?;
if !out.status.success() {
bail!("git {} failed", args.first().copied().unwrap_or(""));
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
fn short(sha: &str) -> String {
sha.chars().take(12).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_owner_repo_and_paths() {
let (url, slug, sub) = parse_spec("you/git-status").unwrap();
assert_eq!(url, "https://github.com/you/git-status.git");
assert_eq!(slug, "you-git-status");
assert!(sub.is_empty());
let (_, _, sub) = parse_spec("you/repo/modules/board").unwrap();
assert_eq!(sub, "modules/board");
let (url, slug, sub) = parse_spec("/tmp/local-mod").unwrap();
assert_eq!(url, "/tmp/local-mod");
assert_eq!(slug, "local-mod");
assert!(sub.is_empty());
assert!(parse_spec("nope").is_err());
}
}