use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use crate::hooks::Hooks;
use crate::plugin::describe;
use crate::registry::Registry;
const BIN: &str = "config/plugins/bin";
pub const DEFAULT_PATH: &str = "config";
pub type Referenced = BTreeMap<String, BTreeSet<String>>;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct Summary {
pub added: usize,
pub deleted: usize,
}
impl fmt::Display for Summary {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} added / {} deleted", self.added, self.deleted)
}
}
#[derive(Debug)]
pub enum InstallError {
Unsupported { name: String, reason: String },
Io(io::Error),
}
impl fmt::Display for InstallError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
InstallError::Unsupported { name, reason } => {
write!(f, "install: refusing to link {name}: {reason}")
}
InstallError::Io(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for InstallError {}
impl From<io::Error> for InstallError {
fn from(e: io::Error) -> Self {
InstallError::Io(e)
}
}
pub fn install(path: &str, from: &Path, to: &Path) -> io::Result<Summary> {
if path.contains('*') {
return union_glob(path, from, to);
}
let src = from.join(path);
let dst = to.join(path);
if src.is_dir() || (!src.exists() && dst.is_dir()) {
mirror(&src, &dst, &from.join(BIN), &to.join(BIN))
} else {
union_file(&src, &dst)
}
}
fn mirror(src: &Path, dst: &Path, src_bin: &Path, dst_bin: &Path) -> io::Result<Summary> {
let mut deleted = 0;
for file in walk(dst, dst_bin)? {
let rel = file.strip_prefix(dst).expect("walk yields dst-rooted paths");
if !src.join(rel).is_file() {
fs::remove_file(&file)?;
deleted += 1;
}
}
let mut added = 0;
for file in walk(src, src_bin)? {
let rel = file.strip_prefix(src).expect("walk yields src-rooted paths");
copy_file(&file, &dst.join(rel))?;
added += 1;
}
Ok(Summary { added, deleted })
}
fn union_file(src: &Path, dst: &Path) -> io::Result<Summary> {
if src.is_file() {
copy_file(src, dst)?;
return Ok(Summary { added: 1, deleted: 0 });
}
Ok(Summary::default())
}
fn union_glob(path: &str, from: &Path, to: &Path) -> io::Result<Summary> {
let (dir, pattern) = path.rsplit_once('/').unwrap_or(("", path));
let src_dir = from.join(dir);
let dst_dir = to.join(dir);
let mut added = 0;
if src_dir.is_dir() {
for entry in fs::read_dir(&src_dir)? {
let entry = entry?;
let name = entry.file_name();
if entry.file_type()?.is_file() && matches(pattern, &name.to_string_lossy()) {
copy_file(&entry.path(), &dst_dir.join(&name))?;
added += 1;
}
}
}
Ok(Summary { added, deleted: 0 })
}
fn walk(root: &Path, skip: &Path) -> io::Result<Vec<PathBuf>> {
let mut out = Vec::new();
if root.is_dir() {
walk_into(root, skip, &mut out)?;
}
Ok(out)
}
fn walk_into(dir: &Path, skip: &Path, out: &mut Vec<PathBuf>) -> io::Result<()> {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path == *skip {
continue;
}
if entry.file_type()?.is_dir() {
walk_into(&path, skip, out)?;
} else {
out.push(path);
}
}
Ok(())
}
fn copy_file(src: &Path, dst: &Path) -> io::Result<()> {
fs::create_dir_all(dst.parent().expect("a copy target always has a parent"))?;
fs::copy(src, dst)?;
Ok(())
}
fn matches(pattern: &str, name: &str) -> bool {
match pattern.split_once('*') {
None => pattern == name,
Some((prefix, rest)) => {
let Some(after) = name.strip_prefix(prefix) else {
return false;
};
(0..=after.len()).any(|i| after.is_char_boundary(i) && matches(rest, &after[i..]))
}
}
}
pub fn referenced(landing: &Path) -> io::Result<Referenced> {
let toml = landing.join("config").join("plugins.toml");
if toml.is_file() {
return Ok(Hooks::load_from(&toml)?.referenced());
}
Ok(Referenced::new())
}
pub fn resolve_and_bind(
registry: &Registry,
name: &str,
candidate: &Path,
ops: &BTreeSet<String>,
protocol: u32,
) -> Result<(), InstallError> {
let proto = describe(candidate)?;
if !proto.speaks(protocol) {
return Err(InstallError::Unsupported {
name: name.to_string(),
reason: format!("does not speak protocol {protocol}"),
});
}
if let Some(op) = ops.iter().find(|op| !proto.ops.contains(op)) {
return Err(InstallError::Unsupported {
name: name.to_string(),
reason: format!("does not handle op '{op}'"),
});
}
registry.bind(name, candidate)?;
Ok(())
}
#[path = "install_run.rs"]
mod wiring;
pub use wiring::run;
pub(crate) use wiring::{bind_referenced, seal_copy, Chain};
#[cfg(test)]
#[path = "install_tests.rs"]
mod tests;