#![allow(clippy::missing_docs_in_private_items)]
use std::fmt::{self, Write};
use std::path::PathBuf;
use std::{
fs,
io::{self},
path::Path,
};
use git2::Repository;
use itertools::Itertools;
use thiserror::Error;
use tokio::time::Instant;
use tracing::{debug, info, span, Level};
use crate::{config::Config, util};
#[derive(Error, Debug)]
pub enum SvgIconError {
#[error(transparent)]
Output(#[from] fmt::Error),
#[error("failed to locate cache dir")]
CacheDir,
#[error(transparent)]
MakeDir(#[from] io::Error),
#[error(transparent)]
Repo(#[from] git2::Error),
#[error("failed to load icon: '{1}' of style '{2}' @ '{3}' ({0})")]
IconLoad(#[source] io::Error, String, String, PathBuf),
#[error("failed to find icon: '{0}' of style '{1}' @ '{2}'")]
IconNotFound(String, String, PathBuf),
}
pub fn svg_icon_id(icon_name: &str, icon_style: &str) -> String {
format!(
"svg-{}",
util::sha1_base32(format!("{icon_name} {icon_style}").as_bytes())
)
}
pub fn build_svg_icons(config: &Config) -> Result<String, SvgIconError> {
let _span = span!(Level::INFO, "svg_icons").entered();
info!("building svg icons");
let sw = Instant::now();
let repo_root = icons_repo()?;
let mut symbol_defs = String::default();
config
.pages
.iter()
.map(|page| (page.icon.clone(), page.icon_style.clone()))
.unique()
.map(|t| load_icon(&repo_root, &t.0, &t.1).map(|src| (src, t.0, t.1)))
.collect::<Result<Vec<(String, String, String)>, SvgIconError>>()?
.iter()
.map(|t| to_symbol_def(&t.0, &t.1, &t.2))
.try_for_each(|sym_def| symbol_defs.write_str(&sym_def))?;
debug!(
elapsed_ms = sw.elapsed().as_millis(),
"finished building svg icons"
);
Ok(format!(
r#"<svg style="display:none"><defs>{symbol_defs}</defs></svg>"#
))
}
fn icons_repo() -> Result<PathBuf, SvgIconError> {
let _span = span!(Level::DEBUG, "repo").entered();
let cache_dir = util::cache_dir().map_err(|_| SvgIconError::CacheDir)?;
let repo_dir = cache_dir.join("material-design-icons");
let repo_url = "https://github.com/marella/material-design-icons.git";
fs::create_dir_all(repo_dir.clone())?;
match Repository::open(repo_dir.clone()) {
Ok(repo) => {
debug!(
repo_url,
repo_dir = repo_dir.to_str(),
"pulling svg icons repo"
);
pull(&repo)?;
}
Err(_) => {
debug!(
repo_url,
repo_dir = repo_dir.to_str(),
"cloning svg icons repo"
);
Repository::clone(repo_url, repo_dir.clone())?;
}
}
Ok(repo_dir)
}
fn load_icon(repo_dir: &Path, name: &str, style: &str) -> Result<String, SvgIconError> {
let svgs_path = repo_dir.join("svg");
let style_path = svgs_path.join(style);
let icon_path = style_path.join(format!("{name}.svg"));
if icon_path.exists() {
fs::read_to_string(icon_path.clone())
.map_err(|e| SvgIconError::IconLoad(e, name.into(), style.into(), icon_path))
} else {
Err(SvgIconError::IconNotFound(
name.into(),
style.into(),
icon_path,
))
}
}
fn to_symbol_def(src: &str, name: &str, style: &str) -> String {
let id = svg_icon_id(name, style);
let remove_start = r#"<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24""#;
let remove_end = "</svg>";
let add_start = format!(r#"<symbol id="{id}""#);
let add_end = "</symbol>";
let middle = src[(remove_start.len())..(src.len() - remove_end.len())].to_string();
format!("{add_start}{middle}{add_end}")
}
fn pull(repo: &Repository) -> Result<(), git2::Error> {
let remote_name = "origin";
let remote_branch = "main";
let mut remote = repo.find_remote(remote_name)?;
let fetch_commit = do_fetch(repo, &[remote_branch], &mut remote)?;
do_merge(repo, remote_branch, fetch_commit)
}
fn do_fetch<'a>(
repo: &'a git2::Repository,
refs: &[&str],
remote: &'a mut git2::Remote,
) -> Result<git2::AnnotatedCommit<'a>, git2::Error> {
let mut cb = git2::RemoteCallbacks::new();
cb.transfer_progress(|stats| {
if stats.received_objects() == stats.total_objects() {
debug!(
"Resolving deltas {}/{}\r",
stats.indexed_deltas(),
stats.total_deltas()
);
} else if stats.total_objects() > 0 {
debug!(
"Received {}/{} objects ({}) in {} bytes\r",
stats.received_objects(),
stats.total_objects(),
stats.indexed_objects(),
stats.received_bytes()
);
}
io::Write::flush(&mut io::stdout()).unwrap();
true
});
let mut fo = git2::FetchOptions::new();
fo.remote_callbacks(cb);
fo.download_tags(git2::AutotagOption::All);
debug!("Fetching {} for repo", remote.name().unwrap());
remote.fetch(refs, Some(&mut fo), None)?;
let stats = remote.stats();
if stats.local_objects() > 0 {
debug!(
"\rReceived {}/{} objects in {} bytes (used {} local \
objects)",
stats.indexed_objects(),
stats.total_objects(),
stats.received_bytes(),
stats.local_objects()
);
} else {
debug!(
"\rReceived {}/{} objects in {} bytes",
stats.indexed_objects(),
stats.total_objects(),
stats.received_bytes()
);
}
let fetch_head = repo.find_reference("FETCH_HEAD")?;
repo.reference_to_annotated_commit(&fetch_head)
}
fn fast_forward(
repo: &Repository,
lb: &mut git2::Reference,
rc: &git2::AnnotatedCommit,
) -> Result<(), git2::Error> {
let name = match lb.name() {
Some(s) => s.to_string(),
None => String::from_utf8_lossy(lb.name_bytes()).to_string(),
};
let msg = format!("Fast-Forward: Setting {} to id: {}", name, rc.id());
debug!("{}", msg);
lb.set_target(rc.id(), &msg)?;
repo.set_head(&name)?;
repo.checkout_head(Some(
git2::build::CheckoutBuilder::default()
.force(),
))?;
Ok(())
}
fn normal_merge(
repo: &Repository,
local: &git2::AnnotatedCommit,
remote: &git2::AnnotatedCommit,
) -> Result<(), git2::Error> {
let local_tree = repo.find_commit(local.id())?.tree()?;
let remote_tree = repo.find_commit(remote.id())?.tree()?;
let ancestor = repo
.find_commit(repo.merge_base(local.id(), remote.id())?)?
.tree()?;
let mut idx = repo.merge_trees(&ancestor, &local_tree, &remote_tree, None)?;
if idx.has_conflicts() {
debug!("Merge conficts detected...");
repo.checkout_index(Some(&mut idx), None)?;
return Ok(());
}
let result_tree = repo.find_tree(idx.write_tree_to(repo)?)?;
let msg = format!("Merge: {} into {}", remote.id(), local.id());
let sig = repo.signature()?;
let local_commit = repo.find_commit(local.id())?;
let remote_commit = repo.find_commit(remote.id())?;
let _merge_commit = repo.commit(
Some("HEAD"),
&sig,
&sig,
&msg,
&result_tree,
&[&local_commit, &remote_commit],
)?;
repo.checkout_head(None)?;
Ok(())
}
fn do_merge<'a>(
repo: &'a Repository,
remote_branch: &str,
fetch_commit: git2::AnnotatedCommit<'a>,
) -> Result<(), git2::Error> {
let analysis = repo.merge_analysis(&[&fetch_commit])?;
if analysis.0.is_fast_forward() {
debug!("Doing a fast forward");
let refname = format!("refs/heads/{}", remote_branch);
match repo.find_reference(&refname) {
Ok(mut r) => {
fast_forward(repo, &mut r, &fetch_commit)?;
}
Err(_) => {
repo.reference(
&refname,
fetch_commit.id(),
true,
&format!("Setting {} to {}", remote_branch, fetch_commit.id()),
)?;
repo.set_head(&refname)?;
repo.checkout_head(Some(
git2::build::CheckoutBuilder::default()
.allow_conflicts(true)
.conflict_style_merge(true)
.force(),
))?;
}
};
} else if analysis.0.is_normal() {
let head_commit = repo.reference_to_annotated_commit(&repo.head()?)?;
normal_merge(repo, &head_commit, &fetch_commit)?;
} else {
debug!("Nothing to do...");
}
Ok(())
}