use anyhow::Context as _;
use clap::Parser;
use directories_next::BaseDirs;
use std::{
ffi::OsString,
path::{Path, PathBuf},
};
use tera::{Context, Tera};
fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt::init();
let opts: Opts = Opts::parse();
match opts {
Opts::Install(opts) => run_install(opts),
}
}
fn run_install(opts: InstallOpts) -> anyhow::Result<()> {
let context = load_context()?;
for entry in walkdir::WalkDir::new(&opts.root)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_name() == "install.toml")
{
let install_file_path = entry.path().canonicalize()?;
let mut install_base = install_file_path.clone();
install_base.pop();
tracing::debug!("reading install file at {install_file_path:?}");
let install_config: String =
std::fs::read_to_string(entry.path()).context("reading install file")?;
let install_config: InstallConfig = InstallConfig::render_from(&install_config, &context)?;
install_config.install_links(&install_base, opts.overwrite, &context)?;
}
Ok(())
}
pub fn load_context() -> anyhow::Result<Context> {
fn path_to_str(path: &Path) -> anyhow::Result<&str> {
path.to_str()
.ok_or_else(|| anyhow::anyhow!("paths must currently be UTF-8"))
}
let base_dirs: BaseDirs = match BaseDirs::new() {
Some(dirs) => dirs,
None => anyhow::bail!("couldn't get base directories"),
};
let hostname_os: OsString = gethostname::gethostname();
let hostname = hostname_os
.to_str()
.ok_or_else(|| anyhow::anyhow!("this library currently requires UTF-8 hostnames"))?;
let mut ctx = Context::new();
ctx.insert("HOME", path_to_str(base_dirs.home_dir())?);
ctx.insert("CACHE", path_to_str(base_dirs.cache_dir())?);
ctx.insert("CONFIG", path_to_str(base_dirs.config_dir())?);
ctx.insert("DATA", path_to_str(base_dirs.data_dir())?);
ctx.insert("DATA_LOCAL", path_to_str(base_dirs.data_local_dir())?);
ctx.insert("HOSTNAME", hostname);
Ok(ctx)
}
#[derive(Clone, Debug, serde::Deserialize)]
struct InstallConfig {
#[serde(rename = "link")]
links: Vec<Link>,
}
impl InstallConfig {
fn render_from(template: &str, context: &Context) -> anyhow::Result<Self> {
let rendered_install: String = Tera::one_off(template, context, false)?;
let install_config: InstallConfig = toml::from_str(&rendered_install)?;
Ok(install_config)
}
fn install_links(
&self,
install_path: &Path,
overwrite: bool,
ctx: &Context,
) -> anyhow::Result<()> {
anyhow::ensure!(
install_path.is_dir(),
"install path ({install_path:?}) must be a directory that exists"
);
for link in &self.links {
let file_path: PathBuf = path_rel_to_install(&link.file, install_path)?;
let link_path: PathBuf = path_rel_to_install(&link.link, install_path)?;
let template_path: Option<Result<PathBuf, _>> = link
.template
.as_ref()
.map(|path| path_rel_to_install(path, install_path));
if let Some(template_path) = template_path {
let template_path = template_path?;
match generate_template(&template_path, &file_path, ctx) {
Ok(s) => s,
Err(e) => {
tracing::warn!(
"couldn't render Tera template {template_path:?}: {}\n\t\
(skipping this link)",
display_anyhow_err_chain(e)
);
continue;
}
}
}
let link_loc_exists = !matches!(
link_path.symlink_metadata().map_err(|e| e.kind()),
Err(std::io::ErrorKind::NotFound)
);
if link_loc_exists {
if overwrite {
tracing::info!("deleting file/link at {link_path:?} before writing new link");
std::fs::remove_file(&link_path)?;
} else {
tracing::info!("skipping file/link at {link_path:?} because it already exists");
continue;
}
}
let status_msg = format!("creating link: {file_path:?} -> {link_path:?}");
tracing::info!("{status_msg}");
#[cfg(unix)]
std::os::unix::fs::symlink(&file_path, &link_path).context(status_msg)?;
#[cfg(windows)]
todo!("not really sure how windows works; patches welcome")
}
Ok(())
}
}
fn generate_template(template_path: &Path, file_path: &Path, ctx: &Context) -> anyhow::Result<()> {
let template_contents = std::fs::read_to_string(&template_path)?;
let rendered: String = Tera::one_off(&template_contents, ctx, false)?;
let old_rendered = std::fs::read_to_string(&file_path).unwrap_or_default();
if rendered == old_rendered {
tracing::debug!(
"found template for {file_path:?} \
but rendered contents are unchanged: skipping."
);
} else {
std::fs::write(&file_path, &rendered)?;
tracing::info!(
"found template for {file_path:?} \
and rendered contents were updated"
);
}
Ok(())
}
fn display_anyhow_err_chain(top_level_err: anyhow::Error) -> String {
let mut display_chain = String::new();
for err in top_level_err.chain() {
display_chain.push_str(&format!("\n\t{}", err));
}
display_chain
}
fn path_rel_to_install(path: &Path, install: &Path) -> anyhow::Result<PathBuf> {
if path.is_absolute() {
Ok(path.to_path_buf())
} else {
let mut canon_path = install.canonicalize()?;
canon_path.push(&path);
Ok(canon_path)
}
}
#[derive(Clone, Debug, serde::Deserialize)]
struct Link {
file: PathBuf,
link: PathBuf,
template: Option<PathBuf>,
}
#[derive(Clone, Debug, clap::Parser)]
enum Opts {
Install(InstallOpts),
}
#[derive(Clone, Debug, clap::Parser)]
struct InstallOpts {
#[clap(short, long)]
root: PathBuf,
#[clap(long)]
overwrite: bool,
}