From bfcd8ca41f5ccbcd25c5a4553b0d39701d8f669b Mon Sep 17 00:00:00 2001 From: Trevor Bentley Date: Thu, 12 Jan 2023 17:33:18 +0100 Subject: [PATCH] refactor into modules --- src/generate.rs | 513 +++++++++++++++++ src/git.rs | 502 +++++++++++++++++ src/main.rs | 1395 +---------------------------------------------- src/settings.rs | 305 +++++++++++ src/template.rs | 105 ++++ src/util.rs | 110 ++++ 6 files changed, 1547 insertions(+), 1383 deletions(-) create mode 100644 src/generate.rs create mode 100644 src/git.rs create mode 100644 src/settings.rs create mode 100644 src/template.rs create mode 100644 src/util.rs diff --git a/src/generate.rs b/src/generate.rs new file mode 100644 index 0000000..e412fe2 --- /dev/null +++ b/src/generate.rs @@ -0,0 +1,513 @@ +use crate::{ + error, normal, normal_noln, loud, louder, loudest, + git::{ + dir_listing, + GitFile, + GitRepo, + GitsyMetadata, + parse_repo, + }, + settings::{ + GitsyCli, + GitsyRepoDescriptions, + GitsySettings, + GitsySettingsRepo, + }, + template::{ + DirFilter, + FileFilter, + TsDateFn, + TsTimestampFn, + }, + util::GitsyError, +}; +use git2::{Repository, Error}; +use rayon::prelude::*; +use std::cmp; +use std::fs::{File, create_dir}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Instant; +use tera::{Context, Tera}; + +#[cfg(feature = "markdown")] +use pulldown_cmark::{html, Options, Parser as MdParser}; + +#[cfg(any(feature = "highlight", feature = "highlight_fast"))] +use syntect::{ + html::{ClassedHTMLGenerator, ClassStyle, css_for_theme_with_class_style}, + parsing::SyntaxSet, + highlighting::ThemeSet, + util::LinesWithEndings, +}; + +macro_rules! size_check { + ($settings:ident, $cur:expr, $total:expr, $action:expr) => { + let cur: usize = $cur; + if cur > $settings.limit_repo_size.unwrap_or(usize::MAX) { + $action; + } + let total: usize = $total; + if total.saturating_add($cur) > $settings.limit_total_size.unwrap_or(usize::MAX) { + $action; + } + } +} + +pub struct GitsyGenerator { + cli: GitsyCli, + settings: GitsySettings, + repo_descriptions: GitsyRepoDescriptions, +} + +impl GitsyGenerator { + pub fn new(cli: GitsyCli, settings: GitsySettings, + repo_descriptions: GitsyRepoDescriptions) -> GitsyGenerator { + GitsyGenerator { + cli, + settings, + repo_descriptions, + } + } + #[cfg(feature = "markdown")] + fn parse_markdown(contents: &str) -> String { + let mut options = Options::empty(); + options.insert(Options::ENABLE_STRIKETHROUGH); + options.insert(Options::ENABLE_TABLES); + let parser = MdParser::new_ext(contents, options); + let mut html_output: String = String::with_capacity(contents.len() * 3 / 2); + html::push_html(&mut html_output, parser); + html_output + } + + #[cfg(any(feature = "highlight", feature = "highlight_fast"))] + fn syntax_highlight(contents: &str, extension: &str) -> String { + let syntax_set = SyntaxSet::load_defaults_newlines(); + let syntax = match syntax_set.find_syntax_by_extension(extension) { + Some(s) => s, + _ => { return contents.to_string(); }, + }; + let mut html_generator = ClassedHTMLGenerator::new_with_class_style(syntax, &syntax_set, ClassStyle::Spaced); + for line in LinesWithEndings::from(contents) { + match html_generator.parse_html_for_line_which_includes_newline(line) { + Ok(_) => {}, + Err(_) => { + error!("Warning: failed to apply syntax highlighting."); + return contents.to_string(); + }, + } + } + html_generator.finalize() + } + + fn fill_file_contents(repo: &Repository, file: &GitFile, settings: &GitsySettingsRepo) -> Result { + let mut file = file.clone(); + if file.kind == "file" { + let blob = repo.find_blob(git2::Oid::from_str(&file.id)?)?; + file.contents = match blob.is_binary() { + false => { + let path = Path::new(&file.path); + let cstr = String::from_utf8_lossy(blob.content()).to_string(); + let (content, rendered, pre) = match path.extension() { + #[cfg(feature = "markdown")] + Some(x) if settings.render_markdown.unwrap_or(false) && x == "md" => { + loudest!(" - rendering Markdown in {}", path.display()); + let (cstr, rendered, pre) = (GitsyGenerator::parse_markdown(&cstr), true, false); + (cstr, rendered, pre) + }, + #[cfg(any(feature = "highlight", feature = "highlight_fast"))] + Some(x) if settings.syntax_highlight.unwrap_or(false) => { + loudest!(" - syntax highlighting {}", path.display()); + (GitsyGenerator::syntax_highlight(&cstr, x.to_string_lossy().to_string().as_str()), true, true) + }, + _ => (cstr, false, true), + }; + file.contents_safe = rendered; + file.contents_preformatted = pre; + Some(content) + }, + true => Some(format!("[Binary data ({} bytes)]", blob.content().len())), + }; + } + Ok(file) + } + + fn write_rendered(path: &str, rendered: &str) -> usize { + let mut file = File::create(path) + .expect(&format!("Unable to write to output path: {}", path)); + file.write(rendered.as_bytes()) + .expect(&format!("Failed to save rendered html to path: {}", path)); + louder!(" - wrote file: {}", path); + rendered.as_bytes().len() + } + + fn tera_init(&self) -> Result { + let mut template_path = self.settings.templates.path.clone(); + template_path.push("**"); + template_path.push("*.html"); + let mut tera = Tera::new(&template_path.to_string_lossy().to_string())?; + tera.register_filter("only_files", FileFilter{}); + tera.register_filter("only_dirs", DirFilter{}); + tera.register_function("ts_to_date", TsDateFn{}); + tera.register_function("ts_to_git_timestamp", TsTimestampFn{}); + Ok(tera) + } + + pub fn generate(&self) -> Result<(), GitsyError> { + let start_all = Instant::now(); + let tera = self.tera_init()?; + + // Create output directory + let _ = create_dir(self.settings.outputs.path.to_str().expect("Output path invalid.")); + + let generated_dt = chrono::offset::Local::now(); + let mut global_bytes = 0; + let mut total_bytes = 0; + let mut repos: Vec = vec!(); + + if self.repo_descriptions.len() == 0 { + panic!("No Git repositories defined! Please check your configuration file ({})", + self.cli.path.display()); + } + + // Sort the repositories by name + let mut repo_vec: Vec = self.repo_descriptions.iter().cloned().collect(); + repo_vec.sort_by(|x,y| x.name.as_deref().map(|n| n.cmp(&y.name.as_deref().unwrap_or_default())) + .unwrap_or(cmp::Ordering::Equal)); + // Find the one with the longest name, for pretty printing + let global_name = "repo list"; + let longest_repo_name = repo_vec.iter().fold(0, |acc, x| { + cmp::max(acc, x.name.as_deref().map(|n| n.len()).unwrap_or(0)) + }).max(global_name.len()); + + loudest!("Global settings:\n{:#?}", &self.settings); + + // Iterate over each repository, generating outputs + for repo_desc in &repo_vec { + loudest!("Repo settings:\n{:#?}", &repo_desc); + let start_repo = Instant::now(); + let mut repo_bytes = 0; + let name = repo_desc.name.as_deref().expect("A configured repository has no name!"); + + let repo_path = match &repo_desc.path { + url if url.starts_with("https://") || + url.to_str().unwrap_or_default().contains("@") => { + if self.settings.outputs.cloned_repos.is_none() { + error!("ERROR: Found remote repo [{}], but `cloned_repos` directory not configured.", name); + continue; + }; + let clone_path: PathBuf = [self.settings.outputs.cloned_repos.as_deref().unwrap(), + name].iter().collect(); + match Repository::open(&clone_path) { + Ok(r) => { + // Repo already cloned, so update all refs + let refs: Vec = r.references() + .expect(&format!("Unable to enumerate references for repo [{}]", name)) + .map(|x| x.expect(&format!("Found invalid reference in repo [{}]", name)) + .name() + .expect(&format!("Found unnamed reference in repo: [{}]", name)) + .to_string()).collect(); + r.find_remote("origin") + .expect(&format!("Clone of repo [{}] missing `origin` remote.", name)) + .fetch(&refs, None, None) + .expect(&format!("Failed to fetch updates from remote repo [{}]", name)); + clone_path.to_string_lossy().to_string() + }, + Err(_) => { + let mut builder = git2::build::RepoBuilder::new(); + + // TODO: git2-rs's ssh support just doesn't seem to + // work. It finds the repo, but fails to either + // decrypt or use the private key. + // + //if !url.starts_with("https://") { + // use secrecy::ExposeSecret; + // // this must be SSH, which needs credentials. + // let mut callbacks = git2::RemoteCallbacks::new(); + // callbacks.credentials(|_url, username_from_url, _allowed_types| { + // //git2::Cred::ssh_key_from_agent(username_from_url.unwrap()) + // + // let keyfile = format!("{}/.ssh/id_rsa", std::env::var("HOME").unwrap()); + // let passphrase = pinentry::PassphraseInput::with_default_binary().unwrap() + // .with_description(&format!("Enter passphrase for SSH key {} (repo: {})", + // keyfile, url.display())) + // .with_prompt("Passphrase:") + // .interact().unwrap(); + // git2::Cred::ssh_key( + // username_from_url.unwrap(), + // None, + // Path::new(&keyfile), + // Some(passphrase.expose_secret()), + // ) + // }); + // let mut options = git2::FetchOptions::new(); + // options.remote_callbacks(callbacks); + // builder.fetch_options(options); + //} + builder + .bare(true) + .clone(&url.to_string_lossy().to_string(), &clone_path) + .expect(&format!("Failed to clone remote repo [{}]", name)); + clone_path.to_string_lossy().to_string() + } + } + } + dir => { + match dir.metadata() { + Ok(m) if m.is_dir() => {}, + _ => { + error!("ERROR: local repository [{}]: directory not found: {}", name, dir.display()); + continue; + }, + } + dir.to_string_lossy().to_string() + }, + }; + let repo = Repository::open(&repo_path).expect("Unable to find git repository."); + let metadata = GitsyMetadata { + full_name: repo_desc.name.clone(), + description: repo_desc.description.clone(), + website: repo_desc.website.clone(), + clone: None, + attributes: repo_desc.attributes.clone().unwrap_or_default(), + }; + normal_noln!("[{}{}]... ", name, " ".repeat(longest_repo_name - name.len())); + let summary = parse_repo(&repo, &name, &repo_desc, metadata).expect("Failed to analyze repo HEAD."); + + let mut local_ctx = Context::from_serialize(&summary).unwrap(); + if let Some(extra) = &self.settings.extra { + local_ctx.try_insert("extra", extra).expect("Failed to add extra settings to template engine."); + } + if let Some(site_name) = &self.settings.site_name { + local_ctx.insert("site_name", site_name); + } + if let Some(site_url) = &self.settings.site_url { + local_ctx.insert("site_url", site_url); + } + if let Some(site_description) = &self.settings.site_description { + local_ctx.insert("site_description", site_description); + } + local_ctx.insert("site_generated_ts", &generated_dt.timestamp()); + local_ctx.insert("site_generated_offset", &generated_dt.offset().local_minus_utc()); + + if let Some(templ_file) = self.settings.templates.repo_summary.as_deref() { + match tera.render(templ_file, &local_ctx) { + Ok(rendered) => { + repo_bytes += GitsyGenerator::write_rendered(&self.settings.outputs.repo_summary(Some(&summary), None), &rendered); + }, + Err(x) => match x.kind { + _ => error!("ERROR: {:?}", x), + }, + } + } + + for branch in &summary.branches { + size_check!(repo_desc, repo_bytes, total_bytes, break); + local_ctx.insert("branch", branch); + if let Some(templ_file) = self.settings.templates.branch.as_deref() { + match tera.render(templ_file, &local_ctx) { + Ok(rendered) => { + repo_bytes += GitsyGenerator::write_rendered(&self.settings.outputs.branch(Some(&summary), Some(branch)), &rendered); + }, + Err(x) => match x.kind { + _ => error!("ERROR: {:?}", x), + }, + } + } + local_ctx.remove("branch"); + } + + for tag in &summary.tags { + size_check!(repo_desc, repo_bytes, total_bytes, break); + local_ctx.insert("tag", tag); + if let Some(tagged_id) = tag.tagged_id.as_ref() { + if let Some(commit) = summary.commits.get(tagged_id) { + local_ctx.insert("commit", &commit); + } + } + if let Some(templ_file) = self.settings.templates.tag.as_deref() { + match tera.render(templ_file, &local_ctx) { + Ok(rendered) => { + repo_bytes += GitsyGenerator::write_rendered(&self.settings.outputs.tag(Some(&summary), Some(tag)), &rendered); + }, + Err(x) => match x.kind { + _ => error!("ERROR: {:?}", x), + }, + } + } + local_ctx.remove("tag"); + local_ctx.remove("commit"); + } + + for (_id, commit) in &summary.commits { + size_check!(repo_desc, repo_bytes, total_bytes, break); + local_ctx.try_insert("commit", &commit).expect("Failed to add commit to template engine."); + if let Some(templ_file) = self.settings.templates.commit.as_deref() { + match tera.render(templ_file, &local_ctx) { + Ok(rendered) => { + repo_bytes += GitsyGenerator::write_rendered(&self.settings.outputs.commit(Some(&summary), Some(commit)), &rendered); + }, + Err(x) => match x.kind { + _ => error!("ERROR: {:?}", x), + }, + } + } + local_ctx.remove("commit"); + } + + #[cfg(any(feature = "highlight", feature = "highlight_fast"))] + if self.settings.templates.file.is_some() { + let ts = ThemeSet::load_defaults(); + let theme = ts.themes.get(repo_desc.syntax_highlight_theme.as_deref() + .unwrap_or("base16-ocean.light")).expect("Invalid syntax highlighting theme specified."); + let css: String = css_for_theme_with_class_style(theme, syntect::html::ClassStyle::Spaced) + .expect("Invalid syntax highlighting theme specified."); + repo_bytes += GitsyGenerator::write_rendered(&self.settings.outputs.syntax_css(Some(&summary), None), css.as_str()); + } + + + // TODO: parallelize the rest of the processing steps. This one is + // done first because syntax highlighting is very slow. + let files: Vec<&GitFile> = summary.all_files.iter().filter(|x| x.kind == "file").collect(); + let atomic_bytes: AtomicUsize = AtomicUsize::new(repo_bytes); + let _ = files.par_iter().fold(|| Some(0), |acc, file| { + // These two have to be recreated. Cloning the Tera context is expensive. + let repo = Repository::open(&repo_path).expect("Unable to find git repository."); + let mut local_ctx = local_ctx.clone(); + + let mut local_bytes = 0; + let cur_repo_bytes = atomic_bytes.load(Ordering::Relaxed); + size_check!(repo_desc, cur_repo_bytes, total_bytes, return None); + let file = match file.size < repo_desc.limit_file_size.unwrap_or(usize::MAX) { + true => GitsyGenerator::fill_file_contents(&repo, &file, &repo_desc).expect("Failed to parse file."), + false => (*file).clone(), + }; + local_ctx.try_insert("file", &file).expect("Failed to add file to template engine."); + if let Some(templ_file) = self.settings.templates.file.as_deref() { + match tera.render(templ_file, &local_ctx) { + Ok(rendered) => { + local_bytes = GitsyGenerator::write_rendered(&self.settings.outputs.file(Some(&summary), Some(&file)), &rendered); + atomic_bytes.fetch_add(local_bytes, Ordering::Relaxed); + }, + Err(x) => match x.kind { + _ => error!("ERROR: {:?}", x), + }, + } + } + local_ctx.remove("file"); + Some(acc.unwrap() + local_bytes)}) + .while_some() // allow short-circuiting if size limit is reached + .sum::(); + repo_bytes = atomic_bytes.load(Ordering::Relaxed); + + for dir in summary.all_files.iter().filter(|x| x.kind == "dir") { + size_check!(repo_desc, repo_bytes, total_bytes, break); + if dir.tree_depth >= repo_desc.limit_tree_depth.unwrap_or(usize::MAX) - 1 { + continue; + } + let listing = dir_listing(&repo, &dir).expect("Failed to parse file."); + local_ctx.try_insert("files", &listing).expect("Failed to add dir to template engine."); + if let Some(templ_file) = self.settings.templates.dir.as_deref() { + match tera.render(templ_file, &local_ctx) { + Ok(rendered) => { + repo_bytes += GitsyGenerator::write_rendered(&self.settings.outputs.dir(Some(&summary), Some(dir)), &rendered); + }, + Err(x) => match x.kind { + _ => error!("ERROR: {:?}", x), + }, + } + } + local_ctx.remove("files"); + } + + if repo_desc.asset_files.is_some() { + let target_dir = self.settings.outputs.repo_assets(Some(&summary), None); + for src_file in repo_desc.asset_files.as_ref().unwrap() { + let src_file = PathBuf::from(repo_path.to_owned() + "/" + src_file); + let mut dst_file = PathBuf::from(&target_dir); + dst_file.push(src_file.file_name() + .expect(&format!("Failed to copy repo asset file: {} ({})", + src_file.display(), repo_desc.name.as_deref().unwrap_or_default()))); + std::fs::copy(&src_file, &dst_file) + .expect(&format!("Failed to copy repo asset file: {} ({})", + src_file.display(), repo_desc.name.as_deref().unwrap_or_default())); + if let Ok(meta) = std::fs::metadata(dst_file) { + repo_bytes += meta.len() as usize; + } + } + } + + repos.push(summary); + normal!("{}done in {:.2}s ({} bytes)", + match crate::util::VERBOSITY.load(Ordering::Relaxed) > 1 { + true => " - ", + _ => "", + }, + start_repo.elapsed().as_secs_f32(), repo_bytes); + total_bytes += repo_bytes; + size_check!(repo_desc, 0, total_bytes, break); // break if total is exceeded + } + + let start_global = Instant::now(); + normal_noln!("[{}{}]... ", global_name, " ".repeat(longest_repo_name - global_name.len())); + let mut global_ctx = Context::new(); + global_ctx.try_insert("repos", &repos).expect("Failed to add repo to template engine."); + if let Some(extra) = &self.settings.extra { + global_ctx.try_insert("extra", extra).expect("Failed to add extra settings to template engine."); + } + if let Some(site_name) = &self.settings.site_name { + global_ctx.insert("site_name", site_name); + } + if let Some(site_url) = &self.settings.site_url { + global_ctx.insert("site_url", site_url); + } + if let Some(site_description) = &self.settings.site_description { + global_ctx.insert("site_description", site_description); + } + global_ctx.insert("site_generated_ts", &generated_dt.timestamp()); + global_ctx.insert("site_generated_offset", &generated_dt.offset().local_minus_utc()); + + if let Some(templ_file) = self.settings.templates.repo_list.as_deref() { + match tera.render(templ_file, &global_ctx) { + Ok(rendered) => { + global_bytes += GitsyGenerator::write_rendered(&self.settings.outputs.repo_list(None, None), &rendered); + }, + Err(x) => match x.kind { + _ => error!("ERROR: {:?}", x), + }, + } + } + + if let Some(templ_file) = self.settings.templates.error.as_deref() { + match tera.render(templ_file, &global_ctx) { + Ok(rendered) => { + global_bytes += GitsyGenerator::write_rendered(&self.settings.outputs.error(None, None), &rendered); + }, + Err(x) => match x.kind { + _ => error!("ERROR: {:?}", x), + }, + } + } + + if self.settings.asset_files.is_some() { + let target_dir = self.settings.outputs.global_assets(None, None); + for src_file in self.settings.asset_files.as_ref().unwrap() { + let src_file = PathBuf::from(src_file); + let mut dst_file = PathBuf::from(&target_dir); + dst_file.push(src_file.file_name() + .expect(&format!("Failed to copy asset file: {}", src_file.display()))); + std::fs::copy(&src_file, &dst_file) + .expect(&format!("Failed to copy asset file: {}", src_file.display())); + if let Ok(meta) = std::fs::metadata(dst_file) { + global_bytes += meta.len() as usize; + } + } + } + + total_bytes += global_bytes; + normal!("done in {:.2}s ({} bytes)", start_global.elapsed().as_secs_f32(), global_bytes); + loud!("Wrote {} bytes in {:.2}s", total_bytes, start_all.elapsed().as_secs_f32()); + Ok(()) + } +} diff --git a/src/git.rs b/src/git.rs new file mode 100644 index 0000000..a9f73f1 --- /dev/null +++ b/src/git.rs @@ -0,0 +1,502 @@ +use crate::{error, loud, loudest}; +use crate::settings::GitsySettingsRepo; +use git2::{DiffOptions, Repository, Error}; +use serde::{Serialize, Deserialize}; +use std::cell::RefCell; +use std::collections::BTreeMap; +use std::rc::Rc; +use std::sync::atomic::Ordering; + +fn first_line(msg: &[u8]) -> String { + let message = String::from_utf8_lossy(msg); + message.lines().next().unwrap_or("[no commit message]").to_owned() +} + +#[derive(Serialize)] +pub struct GitRepo { + pub name: String, + pub metadata: GitsyMetadata, + pub history: Vec, + pub branches: Vec, + pub tags: Vec, + pub root_files: Vec, + pub all_files: Vec, + pub commits: BTreeMap, +} + +#[derive(Serialize, Default)] +pub struct GitsyMetadata { + pub full_name: Option, + pub description: Option, + pub website: Option, + pub clone: Option, + pub attributes: BTreeMap, +} + +#[derive(Serialize, Default)] +pub struct GitAuthor { + pub name: Option, + pub email: Option, +} + +#[derive(Serialize, Default)] +pub struct GitObject { + pub full_hash: String, + pub short_hash: String, + pub ts_utc: i64, + pub ts_offset: i64, + pub author: GitAuthor, + pub committer: GitAuthor, + pub parents: Vec, + pub ref_name: Option, + pub alt_refs: Vec, + pub tagged_id: Option, + pub tree_id: Option, + pub summary: Option, + pub message: Option, + pub stats: Option, + pub diff: Option, +} + +#[derive(Serialize, Default)] +pub struct GitStats { + pub files: usize, + pub additions: usize, + pub deletions: usize, +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct GitFile { + pub id: String, + pub name: String, + pub path: String, + pub mode: i32, + pub kind: String, + pub is_binary: bool, + pub size: usize, + pub tree_depth: usize, + pub contents: Option, + pub contents_safe: bool, + pub contents_preformatted: bool, +} + +#[derive(Serialize, Default)] +pub struct GitDiffCommit { + pub files: Vec, + pub file_count: usize, + pub additions: usize, + pub deletions: usize, +} + +#[derive(Serialize, Default)] +pub struct GitDiffFile { + pub oldfile: String, + pub newfile: String, + pub basefile: String, + pub oldid: String, + pub newid: String, + pub extra: String, + pub additions: usize, + pub deletions: usize, + pub hunks: Vec +} + +#[derive(Serialize, Default)] +pub struct GitDiffHunk { + pub context: String, + pub lines: Vec, +} + +#[derive(Serialize)] +pub struct GitDiffLine { + pub kind: &'static str, + pub prefix: &'static str, + pub text: String, +} + +fn walk_file_tree(repo: &git2::Repository, rev: &str, files: &mut Vec, + depth: usize, max_depth: usize, recurse: bool, prefix: &str) -> Result<(), Error> { + let obj = repo.revparse_single(rev)?; + let tree = obj.peel_to_tree()?; + for entry in tree.iter() { + let name = entry.name().unwrap_or_default().to_string(); + let path = prefix.to_string() + entry.name().unwrap_or_default(); + let kind = match entry.kind() { + Some(git2::ObjectType::Tree) => "dir", + Some(git2::ObjectType::Blob) => "file", + Some(git2::ObjectType::Commit) => "submodule", + _ => "unknown", + }; + let mut is_binary = false; + let mut size = 0; + + if let Ok(blob) = repo.find_blob(entry.id()) { + is_binary = blob.is_binary(); + size = blob.content().len(); + } + + loudest!(" + file: {}", path); + files.push(GitFile { + id: entry.id().to_string(), + name: name.clone(), + path: path.clone(), + kind: kind.to_string(), + mode: entry.filemode(), + is_binary, + size, + tree_depth: depth, + contents: None, + contents_safe: false, + contents_preformatted: true, + }); + if recurse && depth < (max_depth - 1) && entry.kind() == Some(git2::ObjectType::Tree) { + let prefix = path + "/"; + walk_file_tree(repo, &entry.id().to_string(), files, + depth+1, max_depth, true, &prefix)?; + } + } + Ok(()) +} + +pub fn dir_listing(repo: &Repository, file: &GitFile) -> Result, Error> { + let mut files: Vec = vec!(); + walk_file_tree(&repo, &file.id, &mut files, 0, usize::MAX, false, "")?; + Ok(files) +} + +pub fn parse_repo(repo: &Repository, name: &str, settings: &GitsySettingsRepo, metadata: GitsyMetadata) -> Result { + let mut history: Vec = vec!(); + let mut branches: Vec = vec!(); + let mut tags: Vec = vec!(); + let mut commits: BTreeMap = BTreeMap::new(); + let mut commit_count = 0; + let mut history_count = 0; + let mut branch_count = 0; + let mut tag_count = 0; + + // Cache the shortnames of all references + let mut references: BTreeMap> = BTreeMap::new(); + for refr in repo.references()? { + let refr = refr?; + if let (Some(target), Some(name)) = (refr.target(), refr.shorthand()) { + let id = target.to_string(); + match references.contains_key(&id) { + false => { references.insert(target.to_string(), vec!(name.to_string())); }, + true => { references.get_mut(&id).unwrap().push(name.to_string()); }, + } + } + } + + loud!(); + let mut revwalk = repo.revwalk()?; + revwalk.set_sorting(git2::Sort::TOPOLOGICAL)?; + revwalk.push_head()?; + loudest!(" - Parsing history:"); + for oid in revwalk { + let oid = oid?; + if commit_count >= settings.limit_commits.unwrap_or(usize::MAX) || + history_count >= settings.limit_history.unwrap_or(usize::MAX) { + break; + } + commits.insert(oid.to_string(), parse_commit(repo, &oid.to_string())?); + commit_count += 1; + let commit = repo.find_commit(oid)?; + let obj = repo.revparse_single(&commit.id().to_string())?; + let full_hash = commit.id().to_string(); + let short_hash = obj.short_id()?.as_str().unwrap_or_default().to_string(); + + let mut parents: Vec = vec!(); + let a = if commit.parents().len() == 1 { + let parent = commit.parent(0)?; + parents.push(parent.id().to_string()); + Some(parent.tree()?) + } else { + None + }; + let b = commit.tree()?; + let mut diffopts = DiffOptions::new(); + let diff = repo.diff_tree_to_tree(a.as_ref(), Some(&b), Some(&mut diffopts))?; + let stats = diff.stats()?; + let stats = GitStats { + files: stats.files_changed(), + additions: stats.insertions(), + deletions: stats.deletions(), + }; + + let alt_refs: Vec = references.get(&commit.id().to_string()) + .map(|x| x.to_owned()).unwrap_or_default(); + + if history_count < settings.limit_history.unwrap_or(usize::MAX) { + loudest!(" + {} {}", full_hash, first_line(commit.message_bytes())); + // TODO: this is basically a duplicate of the commit + // array, and really should be pointers to that array + // instead. But it's not a quick task to switch to + // self-referential data structures in Rust. + history.push(GitObject { + full_hash, + short_hash, + ts_utc: commit.author().when().seconds(), + ts_offset: (commit.author().when().offset_minutes() as i64) * 60, + parents, + ref_name: None, + alt_refs, + author: GitAuthor { + name: commit.author().name().map(|x| x.to_owned()), + email: commit.author().email().map(|x| x.to_owned()), + }, + summary: Some(first_line(commit.message_bytes())), + stats: Some(stats), + ..Default::default() + }); + history_count += 1; + } + } + loud!(" - parsed {} history entries", history_count); + loud!(" - parsed {} commits", commit_count); + + loudest!(" - Parsing branches:"); + for branch in repo.branches(None)? { + if branch_count >= settings.limit_branches.unwrap_or(usize::MAX) { + break; + } + let (branch, _branch_type) = branch?; + let refr = branch.get(); + let name = branch.name()?.unwrap_or("[unnamed]"); + let obj = repo.revparse_single(name)?; + // Only show direct references, skip symbolic aliases. Maybe + // this is a bad idea? + match refr.kind() { + Some(k) if k == git2::ReferenceType::Symbolic => continue, + _ => {}, + } + let commit = repo.find_commit(obj.id())?; + let full_hash = obj.id().to_string(); + let short_hash = obj.short_id()?.as_str().unwrap_or_default().to_string(); + loudest!(" + {} {}", full_hash, name); + branches.push(GitObject { + full_hash, + short_hash, + ts_utc: commit.author().when().seconds(), + ts_offset: (commit.author().when().offset_minutes() as i64) * 60, + parents: vec!(), + ref_name: Some(name.to_string()), + author: GitAuthor { + name: commit.author().name().map(|x| x.to_owned()), + email: commit.author().email().map(|x| x.to_owned()), + }, + committer: GitAuthor { + name: commit.committer().name().map(|x| x.to_owned()), + email: commit.committer().email().map(|x| x.to_owned()), + }, + summary: Some(first_line(commit.message_bytes())), + message: commit.message().map(|x| x.to_string()), + ..Default::default() + }); + branch_count += 1; + } + loud!(" - parsed {} branches", branch_count); + + loudest!(" - Parsing tags:"); + for tag in repo.tag_names(None)?.iter() { + if tag_count >= settings.limit_tags.unwrap_or(usize::MAX) { + break; + } + let tag = tag.unwrap_or("[unnamed]"); + let obj = repo.revparse_single(tag)?; + let full_hash = obj.id().to_string(); + let short_hash = obj.short_id()?.as_str().unwrap_or_default().to_string(); + let commit = match repo.find_tag(obj.id()) { + Ok(c) => c, + Err(_e) => { + error!("WARNING: tag commit not found for tag: {}", obj.id().to_string()); + tags.push(GitObject { + full_hash, + short_hash, + ref_name: Some(tag.to_string()), + ..Default::default() + }); + tag_count += 1; + continue; + } + }; + let (ts, tz) = match commit.tagger() { + Some(t) => (t.when().seconds(), (t.when().offset_minutes() as i64) * 60), + _ => (0, 0), + }; + let (author,email) = match commit.tagger() { + Some(t) => (t.name().map(|x| x.to_owned()), + t.email().map(|x| x.to_owned())), + _ => (None, None), + }; + let summary = match commit.message_bytes() { + Some(m) => Some(first_line(m)), + _ => None, + }; + loudest!(" + {} {}", full_hash, tag); + tags.push(GitObject { + full_hash, + short_hash, + ts_utc: ts, + ts_offset: tz, + ref_name: Some(tag.to_string()), + author: GitAuthor { + name: author, + email, + }, + tagged_id: Some(commit.target_id().to_string()), + message: commit.message().map(|x| x.to_string()), + summary, + ..Default::default() + }); + tag_count += 1; + } + loud!(" - parsed {} tags", tag_count); + + let mut root_files: Vec = vec!(); + let mut all_files: Vec = vec!(); + let max_depth = settings.limit_tree_depth.unwrap_or(usize::MAX); + if max_depth > 0 { + loudest!(" - Walking root files"); + walk_file_tree(&repo, "HEAD", &mut root_files, 0, usize::MAX, false, "")?; + // TODO: maybe this should be optional? Walking the whole tree + // could be slow on huge repos. + loudest!(" - Walking all files"); + walk_file_tree(&repo, "HEAD", &mut all_files, 0, max_depth, true, "")?; + } + loud!(" - parsed {} files", all_files.len()); + + Ok(GitRepo { + name: name.to_string(), + metadata, + history, + branches, + tags, + root_files, + all_files, + commits, + }) +} + +pub fn parse_commit(repo: &Repository, refr: &str) -> Result { + let obj = repo.revparse_single(refr)?; + let commit = repo.find_commit(obj.id())?; + let mut parents: Vec = vec!(); + + let a = match commit.parents().len() { + x if x == 1 => { + let parent = commit.parent(0).unwrap(); + parents.push(parent.id().to_string()); + Some(parent.tree()?) + }, + x if x > 1 => { + for parent in commit.parents() { + parents.push(parent.id().to_string()); + } + None + }, + _ => { + None + }, + }; + let b = commit.tree()?; + let mut diffopts = DiffOptions::new(); + let diff = repo.diff_tree_to_tree(a.as_ref(), Some(&b), Some(&mut diffopts))?; + let stats = diff.stats()?; + + let mut commit_diff: GitDiffCommit = GitDiffCommit { + file_count: stats.files_changed(), + additions: stats.insertions(), + deletions: stats.deletions(), + ..Default::default() + }; + let files: Rc>> = Rc::new(RefCell::new(vec!())); + + diff.foreach( + &mut |file, _progress| { + let mut file_diff: GitDiffFile = Default::default(); + file_diff.newfile = match file.status() { + git2::Delta::Deleted => "/dev/null".to_owned(), + _ => file.new_file().path().map(|x| "b/".to_string() + &x.to_string_lossy()).unwrap_or("/dev/null".to_string()), + }; + file_diff.oldfile = match file.status() { + git2::Delta::Added => "/dev/null".to_owned(), + _ => file.old_file().path().map(|x| "a/".to_string() + &x.to_string_lossy()).unwrap_or("/dev/null".to_string()), + }; + file_diff.basefile = match file.status() { + git2::Delta::Added => file.new_file().path().map(|x| x.to_string_lossy().to_string()).unwrap_or("/dev/null".to_string()), + _ => file.old_file().path().map(|x| x.to_string_lossy().to_string()).unwrap_or("/dev/null".to_string()), + }; + file_diff.oldid = file.old_file().id().to_string(); + file_diff.newid = file.new_file().id().to_string(); + files.borrow_mut().push(file_diff); + true + }, + None, // TODO: handle binary files? + Some(&mut |_file, hunk| { + let mut files = files.borrow_mut(); + let file_diff: &mut GitDiffFile = files.last_mut().expect("Diff hunk not associated with a file!"); + let mut hunk_diff: GitDiffHunk = Default::default(); + hunk_diff.context = String::from_utf8_lossy(hunk.header()).to_string(); + file_diff.hunks.push(hunk_diff); + true + }), + Some(&mut |_file, _hunk, line| { + let mut files = files.borrow_mut(); + let file_diff: &mut GitDiffFile = files.last_mut().expect("Diff hunk not associated with a file!"); + let hunk_diff: &mut GitDiffHunk = file_diff.hunks.last_mut().expect("Diff line not associated with a hunk!"); + let (kind, prefix) = match line.origin() { + ' ' => ("ctx", " "), + '-' => ("del", "-"), + '+' => ("add", "+"), + _ => ("other", " "), + }; + match line.origin() { + '-' => file_diff.deletions += 1, + '+' => file_diff.additions += 1, + _ => {}, + } + let line_diff = GitDiffLine { + text: String::from_utf8_lossy(line.content()).to_string(), + kind, + prefix, + }; + hunk_diff.lines.push(line_diff); + true + }) + )?; + + match Rc::try_unwrap(files) { + Ok(files) => { + let files: Vec = files.into_inner(); + commit_diff.files = files; + }, + Err(_) => {}, + } + + let tree = obj.peel_to_tree()?; + let summary = GitObject { + full_hash: obj.id().to_string(), + short_hash: obj.short_id()?.as_str().unwrap_or_default().to_string(), + ts_utc: commit.author().when().seconds(), + ts_offset: (commit.author().when().offset_minutes() as i64) * 60, + tagged_id: None, + tree_id: Some(tree.id().to_string()), + parents, + ref_name: None, + alt_refs: vec!(), + author: GitAuthor { + name: commit.author().name().map(|x| x.to_string()), + email: commit.author().email().map(|x| x.to_string()), + }, + committer: GitAuthor { + name: commit.committer().name().map(|x| x.to_string()), + email: commit.committer().email().map(|x| x.to_string()), + }, + summary: Some(first_line(commit.message_bytes())), + message: commit.message().map(|x| x.to_string()), + stats: None, + diff: Some(commit_diff), + }; + + Ok(summary) +} diff --git a/src/main.rs b/src/main.rs index d21f36c..4bdac3e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,1395 +1,24 @@ -use chrono::{ - DateTime, - offset::FixedOffset, - naive::NaiveDateTime, -}; -use clap::Parser; -use git2::{DiffOptions, Repository, Error}; -use rayon::prelude::*; -use serde::{Serialize, Deserialize}; -use std::cell::RefCell; -use std::collections::{BTreeMap, HashMap, HashSet}; -use std::cmp; -use std::fs::{File, create_dir, create_dir_all, read_dir, read_to_string}; -use std::hash::{Hash, Hasher}; -use std::io::Write; -use std::path::{Path, PathBuf}; -use std::rc::Rc; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::time::Instant; -use tera::{Context, Filter, Function, Tera, Value, to_value, try_get_value}; +mod generate; +mod git; +mod settings; +mod template; +mod util; -#[cfg(feature = "markdown")] -use pulldown_cmark::{html, Options, Parser as MdParser}; - -#[cfg(any(feature = "highlight", feature = "highlight_fast"))] -use syntect::{ - html::{ClassedHTMLGenerator, ClassStyle, css_for_theme_with_class_style}, - parsing::SyntaxSet, - highlighting::ThemeSet, - util::LinesWithEndings, -}; - -static VERBOSITY: AtomicUsize = AtomicUsize::new(0); - -#[allow(unused_macros)] -macro_rules! always { - () => { println!() }; - ($($arg:tt)*) => {{ println!($($arg)*); }}; -} -#[allow(unused_macros)] -macro_rules! error { - () => { eprintln!() }; - ($($arg:tt)*) => {{ eprintln!($($arg)*); }}; -} -#[allow(unused_macros)] -macro_rules! normal { - () => { if VERBOSITY.load(Ordering::Relaxed) > 0 { println!() } }; - ($($arg:tt)*) => {{ if VERBOSITY.load(Ordering::Relaxed) > 0 { println!($($arg)*); } }}; -} -#[allow(unused_macros)] -macro_rules! normal_noln { - () => { if VERBOSITY.load(Ordering::Relaxed) > 0 { print!(); let _ = std::io::stdout().flush(); } }; - ($($arg:tt)*) => { if VERBOSITY.load(Ordering::Relaxed) > 0 { {print!($($arg)*);}; let _ = std::io::stdout().flush(); }}; -} -#[allow(unused_macros)] -macro_rules! loud { - () => { if VERBOSITY.load(Ordering::Relaxed) > 1 { println!() } }; - ($($arg:tt)*) => {{ if VERBOSITY.load(Ordering::Relaxed) > 1 { println!($($arg)*); } }}; -} -#[allow(unused_macros)] -macro_rules! louder { - () => { if VERBOSITY.load(Ordering::Relaxed) > 2 { println!() } }; - ($($arg:tt)*) => {{ if VERBOSITY.load(Ordering::Relaxed) > 2 { println!($($arg)*); } }}; -} -#[allow(unused_macros)] -macro_rules! loudest { - () => { if VERBOSITY.load(Ordering::Relaxed) > 3 { println!() } }; - ($($arg:tt)*) => {{ if VERBOSITY.load(Ordering::Relaxed) > 3 { println!($($arg)*); } }}; -} +use generate::GitsyGenerator; +use settings::{GitsyCli, GitsySettings}; // TODO: // // * pagination // * basic, light, dark, and fancy default themes -// * split into modules -// * parallelize output generation +// * better error propagation // * automated tests // * documentation + examples // -fn ts_to_date(ts: i64, offset: Option, format: Option) -> String { - let offset = offset.unwrap_or(0); - let dt = NaiveDateTime::from_timestamp_opt(ts + offset, 0).expect("Invalid timestamp"); - let dt_tz: DateTime = DateTime::from_local(dt, FixedOffset::east_opt(offset as i32).expect("Invalid timezone")); - match format { - Some(f) => dt_tz.format(&f).to_string(), - None => dt_tz.format("%Y-%m-%d").to_string(), - } -} - -fn ts_to_git_timestamp(ts: i64, offset: Option) -> String { - let offset = offset.unwrap_or(0); - let dt = chrono::naive::NaiveDateTime::from_timestamp_opt(ts + offset, 0).expect("invalid timestamp"); - let dt_tz: DateTime = DateTime::from_local(dt, FixedOffset::east_opt(offset as i32).expect("Invalid timezone")); - dt_tz.format("%a %b %e %T %Y %z").to_string() -} - -fn first_line(msg: &[u8]) -> String { - let message = String::from_utf8_lossy(msg); - message.lines().next().unwrap_or("[no commit message]").to_owned() -} - -#[derive(Serialize)] -struct GitRepo { - name: String, - metadata: GitsyMetadata, - history: Vec, - branches: Vec, - tags: Vec, - root_files: Vec, - all_files: Vec, - commits: BTreeMap, -} - -#[derive(Serialize, Default)] -struct GitsyMetadata { - full_name: Option, - description: Option, - website: Option, - clone: Option, - attributes: BTreeMap, -} - -#[derive(Serialize, Default)] -struct GitAuthor { - name: Option, - email: Option, -} - -#[derive(Serialize, Default)] -struct GitObject { - full_hash: String, - short_hash: String, - ts_utc: i64, - ts_offset: i64, - author: GitAuthor, - committer: GitAuthor, - parents: Vec, - ref_name: Option, - alt_refs: Vec, - tagged_id: Option, - tree_id: Option, - summary: Option, - message: Option, - stats: Option, - diff: Option, -} - -#[derive(Serialize, Default)] -struct GitStats { - files: usize, - additions: usize, - deletions: usize, -} - -#[derive(Serialize, Deserialize, Clone)] -struct GitFile { - id: String, - name: String, - path: String, - mode: i32, - kind: String, - is_binary: bool, - size: usize, - tree_depth: usize, - contents: Option, - contents_safe: bool, - contents_preformatted: bool, -} - -#[derive(Serialize, Default)] -struct GitDiffCommit { - files: Vec, - file_count: usize, - additions: usize, - deletions: usize, -} - -#[derive(Serialize, Default)] -struct GitDiffFile { - oldfile: String, - newfile: String, - basefile: String, - oldid: String, - newid: String, - extra: String, - additions: usize, - deletions: usize, - hunks: Vec -} - -#[derive(Serialize, Default)] -struct GitDiffHunk { - context: String, - lines: Vec, -} - -#[derive(Serialize)] -struct GitDiffLine { - kind: &'static str, - prefix: &'static str, - text: String, -} - -fn walk_file_tree(repo: &git2::Repository, rev: &str, files: &mut Vec, - depth: usize, max_depth: usize, recurse: bool, prefix: &str) -> Result<(), Error> { - let obj = repo.revparse_single(rev)?; - let tree = obj.peel_to_tree()?; - for entry in tree.iter() { - let name = entry.name().unwrap_or_default().to_string(); - let path = prefix.to_string() + entry.name().unwrap_or_default(); - let kind = match entry.kind() { - Some(git2::ObjectType::Tree) => "dir", - Some(git2::ObjectType::Blob) => "file", - Some(git2::ObjectType::Commit) => "submodule", - _ => "unknown", - }; - let mut is_binary = false; - let mut size = 0; - - if let Ok(blob) = repo.find_blob(entry.id()) { - is_binary = blob.is_binary(); - size = blob.content().len(); - } - - loudest!(" + file: {}", path); - files.push(GitFile { - id: entry.id().to_string(), - name: name.clone(), - path: path.clone(), - kind: kind.to_string(), - mode: entry.filemode(), - is_binary, - size, - tree_depth: depth, - contents: None, - contents_safe: false, - contents_preformatted: true, - }); - if recurse && depth < (max_depth - 1) && entry.kind() == Some(git2::ObjectType::Tree) { - let prefix = path + "/"; - walk_file_tree(repo, &entry.id().to_string(), files, - depth+1, max_depth, true, &prefix)?; - } - } - Ok(()) -} - -fn parse_repo(repo: &Repository, name: &str, settings: &GitsySettingsRepo, metadata: GitsyMetadata) -> Result { - let mut history: Vec = vec!(); - let mut branches: Vec = vec!(); - let mut tags: Vec = vec!(); - let mut commits: BTreeMap = BTreeMap::new(); - let mut commit_count = 0; - let mut history_count = 0; - let mut branch_count = 0; - let mut tag_count = 0; - - // Cache the shortnames of all references - let mut references: BTreeMap> = BTreeMap::new(); - for refr in repo.references()? { - let refr = refr?; - if let (Some(target), Some(name)) = (refr.target(), refr.shorthand()) { - let id = target.to_string(); - match references.contains_key(&id) { - false => { references.insert(target.to_string(), vec!(name.to_string())); }, - true => { references.get_mut(&id).unwrap().push(name.to_string()); }, - } - } - } - - loud!(); - let mut revwalk = repo.revwalk()?; - revwalk.set_sorting(git2::Sort::TOPOLOGICAL)?; - revwalk.push_head()?; - loudest!(" - Parsing history:"); - for oid in revwalk { - let oid = oid?; - if commit_count >= settings.limit_commits.unwrap_or(usize::MAX) || - history_count >= settings.limit_history.unwrap_or(usize::MAX) { - break; - } - commits.insert(oid.to_string(), parse_commit(repo, &oid.to_string())?); - commit_count += 1; - let commit = repo.find_commit(oid)?; - let obj = repo.revparse_single(&commit.id().to_string())?; - let full_hash = commit.id().to_string(); - let short_hash = obj.short_id()?.as_str().unwrap_or_default().to_string(); - - let mut parents: Vec = vec!(); - let a = if commit.parents().len() == 1 { - let parent = commit.parent(0)?; - parents.push(parent.id().to_string()); - Some(parent.tree()?) - } else { - None - }; - let b = commit.tree()?; - let mut diffopts = DiffOptions::new(); - let diff = repo.diff_tree_to_tree(a.as_ref(), Some(&b), Some(&mut diffopts))?; - let stats = diff.stats()?; - let stats = GitStats { - files: stats.files_changed(), - additions: stats.insertions(), - deletions: stats.deletions(), - }; - - let alt_refs: Vec = references.get(&commit.id().to_string()) - .map(|x| x.to_owned()).unwrap_or_default(); - - if history_count < settings.limit_history.unwrap_or(usize::MAX) { - loudest!(" + {} {}", full_hash, first_line(commit.message_bytes())); - // TODO: this is basically a duplicate of the commit - // array, and really should be pointers to that array - // instead. But it's not a quick task to switch to - // self-referential data structures in Rust. - history.push(GitObject { - full_hash, - short_hash, - ts_utc: commit.author().when().seconds(), - ts_offset: (commit.author().when().offset_minutes() as i64) * 60, - parents, - ref_name: None, - alt_refs, - author: GitAuthor { - name: commit.author().name().map(|x| x.to_owned()), - email: commit.author().email().map(|x| x.to_owned()), - }, - summary: Some(first_line(commit.message_bytes())), - stats: Some(stats), - ..Default::default() - }); - history_count += 1; - } - } - loud!(" - parsed {} history entries", history_count); - loud!(" - parsed {} commits", commit_count); - - loudest!(" - Parsing branches:"); - for branch in repo.branches(None)? { - if branch_count >= settings.limit_branches.unwrap_or(usize::MAX) { - break; - } - let (branch, _branch_type) = branch?; - let refr = branch.get(); - let name = branch.name()?.unwrap_or("[unnamed]"); - let obj = repo.revparse_single(name)?; - // Only show direct references, skip symbolic aliases. Maybe - // this is a bad idea? - match refr.kind() { - Some(k) if k == git2::ReferenceType::Symbolic => continue, - _ => {}, - } - let commit = repo.find_commit(obj.id())?; - let full_hash = obj.id().to_string(); - let short_hash = obj.short_id()?.as_str().unwrap_or_default().to_string(); - loudest!(" + {} {}", full_hash, name); - branches.push(GitObject { - full_hash, - short_hash, - ts_utc: commit.author().when().seconds(), - ts_offset: (commit.author().when().offset_minutes() as i64) * 60, - parents: vec!(), - ref_name: Some(name.to_string()), - author: GitAuthor { - name: commit.author().name().map(|x| x.to_owned()), - email: commit.author().email().map(|x| x.to_owned()), - }, - committer: GitAuthor { - name: commit.committer().name().map(|x| x.to_owned()), - email: commit.committer().email().map(|x| x.to_owned()), - }, - summary: Some(first_line(commit.message_bytes())), - message: commit.message().map(|x| x.to_string()), - ..Default::default() - }); - branch_count += 1; - } - loud!(" - parsed {} branches", branch_count); - - loudest!(" - Parsing tags:"); - for tag in repo.tag_names(None)?.iter() { - if tag_count >= settings.limit_tags.unwrap_or(usize::MAX) { - break; - } - let tag = tag.unwrap_or("[unnamed]"); - let obj = repo.revparse_single(tag)?; - let full_hash = obj.id().to_string(); - let short_hash = obj.short_id()?.as_str().unwrap_or_default().to_string(); - let commit = match repo.find_tag(obj.id()) { - Ok(c) => c, - Err(_e) => { - error!("WARNING: tag commit not found for tag: {}", obj.id().to_string()); - tags.push(GitObject { - full_hash, - short_hash, - ref_name: Some(tag.to_string()), - ..Default::default() - }); - tag_count += 1; - continue; - } - }; - let (ts, tz) = match commit.tagger() { - Some(t) => (t.when().seconds(), (t.when().offset_minutes() as i64) * 60), - _ => (0, 0), - }; - let (author,email) = match commit.tagger() { - Some(t) => (t.name().map(|x| x.to_owned()), - t.email().map(|x| x.to_owned())), - _ => (None, None), - }; - let summary = match commit.message_bytes() { - Some(m) => Some(first_line(m)), - _ => None, - }; - loudest!(" + {} {}", full_hash, tag); - tags.push(GitObject { - full_hash, - short_hash, - ts_utc: ts, - ts_offset: tz, - ref_name: Some(tag.to_string()), - author: GitAuthor { - name: author, - email, - }, - tagged_id: Some(commit.target_id().to_string()), - message: commit.message().map(|x| x.to_string()), - summary, - ..Default::default() - }); - tag_count += 1; - } - loud!(" - parsed {} tags", tag_count); - - let mut root_files: Vec = vec!(); - let mut all_files: Vec = vec!(); - let max_depth = settings.limit_tree_depth.unwrap_or(usize::MAX); - if max_depth > 0 { - loudest!(" - Walking root files"); - walk_file_tree(&repo, "HEAD", &mut root_files, 0, usize::MAX, false, "")?; - // TODO: maybe this should be optional? Walking the whole tree - // could be slow on huge repos. - loudest!(" - Walking all files"); - walk_file_tree(&repo, "HEAD", &mut all_files, 0, max_depth, true, "")?; - } - loud!(" - parsed {} files", all_files.len()); - - Ok(GitRepo { - name: name.to_string(), - metadata, - history, - branches, - tags, - root_files, - all_files, - commits, - }) -} - -fn parse_commit(repo: &Repository, refr: &str) -> Result { - let obj = repo.revparse_single(refr)?; - let commit = repo.find_commit(obj.id())?; - let mut parents: Vec = vec!(); - - let a = match commit.parents().len() { - x if x == 1 => { - let parent = commit.parent(0).unwrap(); - parents.push(parent.id().to_string()); - Some(parent.tree()?) - }, - x if x > 1 => { - for parent in commit.parents() { - parents.push(parent.id().to_string()); - } - None - }, - _ => { - None - }, - }; - let b = commit.tree()?; - let mut diffopts = DiffOptions::new(); - let diff = repo.diff_tree_to_tree(a.as_ref(), Some(&b), Some(&mut diffopts))?; - let stats = diff.stats()?; - - let mut commit_diff: GitDiffCommit = GitDiffCommit { - file_count: stats.files_changed(), - additions: stats.insertions(), - deletions: stats.deletions(), - ..Default::default() - }; - let files: Rc>> = Rc::new(RefCell::new(vec!())); - - diff.foreach( - &mut |file, _progress| { - let mut file_diff: GitDiffFile = Default::default(); - file_diff.newfile = match file.status() { - git2::Delta::Deleted => "/dev/null".to_owned(), - _ => file.new_file().path().map(|x| "b/".to_string() + &x.to_string_lossy()).unwrap_or("/dev/null".to_string()), - }; - file_diff.oldfile = match file.status() { - git2::Delta::Added => "/dev/null".to_owned(), - _ => file.old_file().path().map(|x| "a/".to_string() + &x.to_string_lossy()).unwrap_or("/dev/null".to_string()), - }; - file_diff.basefile = match file.status() { - git2::Delta::Added => file.new_file().path().map(|x| x.to_string_lossy().to_string()).unwrap_or("/dev/null".to_string()), - _ => file.old_file().path().map(|x| x.to_string_lossy().to_string()).unwrap_or("/dev/null".to_string()), - }; - file_diff.oldid = file.old_file().id().to_string(); - file_diff.newid = file.new_file().id().to_string(); - files.borrow_mut().push(file_diff); - true - }, - None, // TODO: handle binary files? - Some(&mut |_file, hunk| { - let mut files = files.borrow_mut(); - let file_diff: &mut GitDiffFile = files.last_mut().expect("Diff hunk not associated with a file!"); - let mut hunk_diff: GitDiffHunk = Default::default(); - hunk_diff.context = String::from_utf8_lossy(hunk.header()).to_string(); - file_diff.hunks.push(hunk_diff); - true - }), - Some(&mut |_file, _hunk, line| { - let mut files = files.borrow_mut(); - let file_diff: &mut GitDiffFile = files.last_mut().expect("Diff hunk not associated with a file!"); - let hunk_diff: &mut GitDiffHunk = file_diff.hunks.last_mut().expect("Diff line not associated with a hunk!"); - let (kind, prefix) = match line.origin() { - ' ' => ("ctx", " "), - '-' => ("del", "-"), - '+' => ("add", "+"), - _ => ("other", " "), - }; - match line.origin() { - '-' => file_diff.deletions += 1, - '+' => file_diff.additions += 1, - _ => {}, - } - let line_diff = GitDiffLine { - text: String::from_utf8_lossy(line.content()).to_string(), - kind, - prefix, - }; - hunk_diff.lines.push(line_diff); - true - }) - )?; - - match Rc::try_unwrap(files) { - Ok(files) => { - let files: Vec = files.into_inner(); - commit_diff.files = files; - }, - Err(_) => {}, - } - - let tree = obj.peel_to_tree()?; - let summary = GitObject { - full_hash: obj.id().to_string(), - short_hash: obj.short_id()?.as_str().unwrap_or_default().to_string(), - ts_utc: commit.author().when().seconds(), - ts_offset: (commit.author().when().offset_minutes() as i64) * 60, - tagged_id: None, - tree_id: Some(tree.id().to_string()), - parents, - ref_name: None, - alt_refs: vec!(), - author: GitAuthor { - name: commit.author().name().map(|x| x.to_string()), - email: commit.author().email().map(|x| x.to_string()), - }, - committer: GitAuthor { - name: commit.committer().name().map(|x| x.to_string()), - email: commit.committer().email().map(|x| x.to_string()), - }, - summary: Some(first_line(commit.message_bytes())), - message: commit.message().map(|x| x.to_string()), - stats: None, - diff: Some(commit_diff), - }; - - Ok(summary) -} - -#[cfg(feature = "markdown")] -fn parse_markdown(contents: &str) -> String { - let mut options = Options::empty(); - options.insert(Options::ENABLE_STRIKETHROUGH); - options.insert(Options::ENABLE_TABLES); - let parser = MdParser::new_ext(contents, options); - let mut html_output: String = String::with_capacity(contents.len() * 3 / 2); - html::push_html(&mut html_output, parser); - html_output -} - -#[cfg(any(feature = "highlight", feature = "highlight_fast"))] -fn syntax_highlight(contents: &str, extension: &str) -> String { - let syntax_set = SyntaxSet::load_defaults_newlines(); - let syntax = match syntax_set.find_syntax_by_extension(extension) { - Some(s) => s, - _ => { return contents.to_string(); }, - }; - let mut html_generator = ClassedHTMLGenerator::new_with_class_style(syntax, &syntax_set, ClassStyle::Spaced); - for line in LinesWithEndings::from(contents) { - match html_generator.parse_html_for_line_which_includes_newline(line) { - Ok(_) => {}, - Err(_) => { - error!("Warning: failed to apply syntax highlighting."); - return contents.to_string(); - }, - } - } - html_generator.finalize() -} - -fn fill_file_contents(repo: &Repository, file: &GitFile, settings: &GitsySettingsRepo) -> Result { - let mut file = file.clone(); - if file.kind == "file" { - let blob = repo.find_blob(git2::Oid::from_str(&file.id)?)?; - file.contents = match blob.is_binary() { - false => { - let path = Path::new(&file.path); - let cstr = String::from_utf8_lossy(blob.content()).to_string(); - let (content, rendered, pre) = match path.extension() { - #[cfg(feature = "markdown")] - Some(x) if settings.render_markdown.unwrap_or(false) && x == "md" => { - loudest!(" - rendering Markdown in {}", path.display()); - let (cstr, rendered, pre) = (parse_markdown(&cstr), true, false); - (cstr, rendered, pre) - }, - #[cfg(any(feature = "highlight", feature = "highlight_fast"))] - Some(x) if settings.syntax_highlight.unwrap_or(false) => { - loudest!(" - syntax highlighting {}", path.display()); - (syntax_highlight(&cstr, x.to_string_lossy().to_string().as_str()), true, true) - }, - _ => (cstr, false, true), - }; - file.contents_safe = rendered; - file.contents_preformatted = pre; - Some(content) - }, - true => Some(format!("[Binary data ({} bytes)]", blob.content().len())), - }; - } - Ok(file) -} - -fn dir_listing(repo: &Repository, file: &GitFile) -> Result, Error> { - let mut files: Vec = vec!(); - walk_file_tree(&repo, &file.id, &mut files, 0, usize::MAX, false, "")?; - Ok(files) -} - -struct FileFilter; -impl Filter for FileFilter { - fn filter(&self, value: &Value, _args: &HashMap - ) -> Result { - let file_list: Vec = try_get_value!("only_files", "value", Vec, value); - let file_list: Vec = file_list.iter().filter_map(|x| match x.kind.as_str() { - "file" => Some(x.clone()), - _ => None, - }).collect(); - Ok(to_value(file_list).unwrap()) - } -} - -struct DirFilter; -impl Filter for DirFilter { - fn filter(&self, value: &Value, _args: &HashMap - ) -> Result { - let file_list: Vec = try_get_value!("only_dirs", "value", Vec, value); - let file_list: Vec = file_list.iter().filter_map(|x| match x.kind.as_str() { - "dir" => Some(x.clone()), - _ => None, - }).collect(); - Ok(to_value(file_list).unwrap()) - } -} - -struct TsDateFn; -impl Function for TsDateFn { - fn call(&self, args: &HashMap) -> Result { - let ts: Option = match args.get("ts") { - Some(ts) => match tera::from_value(ts.clone()) { - Ok(ts) => Some(ts), - _ => None, - }, - _ => None, - }; - let ts = ts.expect("ts_to_date missing a `ts` argument"); - - let tz: Option = match args.get("tz") { - Some(tz) => match tera::from_value(tz.clone()) { - Ok(tz) => Some(tz), - _ => None, - }, - _ => None, - }; - - let fmt: Option = match args.get("fmt") { - Some(fmt) => match tera::from_value(fmt.clone()) { - Ok(fmt) => Some(fmt), - _ => None, - }, - _ => None, - }; - Ok(to_value(ts_to_date(ts, tz, fmt)).unwrap()) - } -} - -struct TsTimestampFn; -impl Function for TsTimestampFn { - fn call(&self, args: &HashMap) -> Result { - let ts: Option = match args.get("ts") { - Some(ts) => match tera::from_value(ts.clone()) { - Ok(ts) => Some(ts), - _ => None, - }, - _ => None, - }; - let ts = ts.expect("ts_to_git_timestamp missing a `ts` argument"); - - let tz: Option = match args.get("tz") { - Some(tz) => match tera::from_value(tz.clone()) { - Ok(tz) => Some(tz), - _ => None, - }, - _ => None, - }; - Ok(to_value(ts_to_git_timestamp(ts, tz)).unwrap()) - } -} - -#[derive(Parser, Debug)] -#[command(author = "Trevor Bentley", version, about, long_about = None)] -#[command(help_template = "\ -{name} v{version}, by {author-with-newline} -{about-with-newline} -{usage-heading} {usage} - -{all-args}{after-help} -")] -struct CliArgs { - #[arg(short, long, value_name = "FILE")] - config: Option, - #[arg(short, long)] - quiet: bool, - #[arg(short, long, action = clap::ArgAction::Count)] - verbose: u8, -} - -#[derive(Deserialize, Debug)] -#[allow(dead_code)] -struct GitsySettings { - recursive_repo_dirs: Option>, - site_name: Option, - site_url: Option, - site_description: Option, - asset_files: Option>, - #[serde(rename(deserialize = "gitsy_templates"))] - templates: GitsySettingsTemplates, - #[serde(rename(deserialize = "gitsy_outputs"))] - outputs: GitsySettingsOutputs, - limit_history: Option, - limit_commits: Option, - limit_branches: Option, - limit_tags: Option, - limit_tree_depth: Option, - limit_file_size: Option, - limit_repo_size: Option, - limit_total_size: Option, - render_markdown: Option, - syntax_highlight: Option, - syntax_highlight_theme: Option, - #[serde(rename(deserialize = "gitsy_extra"))] - extra: Option>, -} - -#[derive(Deserialize, Debug)] -struct GitsySettingsTemplates { - path: PathBuf, - repo_list: Option, - repo_summary: Option, - commit: Option, - branch: Option, - tag: Option, - file: Option, - dir: Option, - error: Option, -} - -#[derive(Deserialize, Debug)] -struct GitsySettingsOutputs { - path: PathBuf, - cloned_repos: Option, - repo_list: Option, - repo_summary: Option, - commit: Option, - branch: Option, - tag: Option, - file: Option, - dir: Option, - error: Option, - syntax_css: Option, - global_assets: Option, - repo_assets: Option, -} - -macro_rules! output_path_fn { - ($var:ident, $obj:ty, $id:ident, $is_dir:expr, $default:expr) => { - pub fn $var(&self, repo: Option<&GitRepo>, obj: Option<&$obj>) -> String { - let tmpl_str = self.$var.as_deref().unwrap_or($default).to_string(); - let tmpl_str = match (tmpl_str.contains("%REPO%"), repo.is_some()) { - (true, true) => { - let name = repo.map(|x| &x.name).unwrap(); - tmpl_str.replace("%REPO%", name) - }, - (true, false) => { - panic!("%REPO% variable not available for output path: {}", tmpl_str); - } - _ => tmpl_str, - }; - let tmpl_str = match (tmpl_str.contains("%ID%"), obj.is_some()) { - (true, true) => { - let name = obj.map(|x| &x.$id).unwrap(); - tmpl_str.replace("%ID%", name) - }, - (true, false) => { - panic!("%ID% variable not available for output path: {}", tmpl_str); - } - _ => tmpl_str, - }; - let tmpl = PathBuf::from(tmpl_str); - let mut path = self.path.clone(); - path.push(tmpl); - match $is_dir { - true => { - let _ = create_dir_all(&path); - }, - false => { - if let Some(dir) = path.parent() { - let _ = create_dir_all(dir); - } - }, - } - path.to_str() - .expect(&format!("Output is not a valid path: {}", path.display())) - .into() - } - }; -} -//step_map_first!(boil_in_wort, Boil, Wort, |b: &Boil| { b.wort_start() }); - -impl GitsySettingsOutputs { - output_path_fn!(repo_list, GitObject, full_hash, false, "repos.html"); - output_path_fn!(repo_summary, GitObject, full_hash, false, "%REPO%/summary.html"); - output_path_fn!(commit, GitObject, full_hash, false, "%REPO%/commit/%ID%.html"); - output_path_fn!(branch, GitObject, full_hash, false, "%REPO%/branch/%ID%.html"); - output_path_fn!(tag, GitObject, full_hash, false, "%REPO%/tag/%ID%.html"); - output_path_fn!(file, GitFile, id, false, "%REPO%/file/%ID%.html"); - output_path_fn!(syntax_css, GitObject, full_hash, false, "%REPO%/file/syntax.css"); - output_path_fn!(dir, GitFile, id, false, "%REPO%/dir/%ID%.html"); - output_path_fn!(error, GitObject, full_hash, false, "404.html"); - output_path_fn!(global_assets, GitObject, full_hash, true, "assets/"); - output_path_fn!(repo_assets , GitObject, full_hash, true, "%REPO%/assets/"); -} - -#[derive(Deserialize, Default, Debug)] -struct GitsySettingsRepo { - path: PathBuf, - name: Option, - description: Option, - website: Option, - asset_files: Option>, - render_markdown: Option, - syntax_highlight: Option, - syntax_highlight_theme: Option, - attributes: Option>, - limit_history: Option, - limit_commits: Option, - limit_branches: Option, - limit_tags: Option, - limit_tree_depth: Option, - limit_file_size: Option, - limit_repo_size: Option, - limit_total_size: Option, -} - -impl Hash for GitsySettingsRepo { - fn hash(&self, state: &mut H) { - self.path.hash(state); - } -} -impl PartialEq for GitsySettingsRepo { - fn eq(&self, other: &Self) -> bool { - self.path == other.path - } -} -impl Eq for GitsySettingsRepo {} - -fn write_rendered(path: &str, rendered: &str) -> usize { - let mut file = File::create(path) - .expect(&format!("Unable to write to output path: {}", path)); - file.write(rendered.as_bytes()) - .expect(&format!("Failed to save rendered html to path: {}", path)); - louder!(" - wrote file: {}", path); - rendered.as_bytes().len() -} - fn main() { - let start_all = Instant::now(); - let cli = CliArgs::parse(); - let config_path = cli.config.as_deref().unwrap_or(Path::new("config.toml")).to_owned(); - let config_dir = config_path.parent().expect("Config file not in valid directory.").to_owned(); - let config_dir = match config_dir.to_str().unwrap_or_default().len() > 0 { - true => config_dir, - false => PathBuf::from("."), - }; - let config_path = match config_path.canonicalize() { - Ok(d) => d, - _ => config_path.clone(), - }; - let config_dir = match config_dir.canonicalize() { - Ok(d) => d, - _ => config_dir.clone(), - }; - VERBOSITY.store(match cli.quiet { - true => 0, - false => (cli.verbose + 1).into(), - }, Ordering::Relaxed); - - // Parse the known settings directly into their struct - let toml = read_to_string(&config_path).expect(&format!("Configuration file not found: {}", config_path.display())); - let settings: GitsySettings = toml::from_str(&toml).expect("Configuration file is invalid."); - - // Settings are valid, so let's move into the directory with the config file - if config_dir.to_str().unwrap_or_default().len() > 0 { // empty string means current directory - std::env::set_current_dir(&config_dir) - .expect(&format!("Unable to set working directory to: {}", config_dir.display())); - } - - // Get a list of all remaining TOML "tables" in the file. - // These are the user-supplied individual repositories. - let reserved_keys = vec!("gitsy_templates", "gitsy_outputs", "gitsy_extra"); - let settings_raw: HashMap = toml::from_str(&toml).expect("blah"); - let table_keys: Vec = settings_raw.iter().filter_map(|x| match x.1.is_table() { - true => match reserved_keys.contains(&x.0.as_str()) { - false => Some(x.0.clone()), - true => None, - }, - false => None - }).collect(); - - // Try to convert each unknown "table" into a repo struct, and - // save the ones that are successful. If no repo name is - // specified, use the TOML table name. - let mut repo_descriptions: HashSet = HashSet::new(); - macro_rules! global_to_repo { - ($settings:ident, $repo:ident, $field:ident) => { - if $repo.$field.is_none() { $repo.$field = $settings.$field.clone() } - } - } - for k in &table_keys { - let v = settings_raw.get(k).unwrap(); - match toml::from_str::(&v.to_string()) { - Ok(mut repo) => { - if repo.name.is_none() { repo.name = Some(k.clone()); } - global_to_repo!(settings, repo, render_markdown); - global_to_repo!(settings, repo, syntax_highlight); - global_to_repo!(settings, repo, syntax_highlight_theme); - global_to_repo!(settings, repo, limit_history); - global_to_repo!(settings, repo, limit_commits); - global_to_repo!(settings, repo, limit_branches); - global_to_repo!(settings, repo, limit_tags); - global_to_repo!(settings, repo, limit_tree_depth); - global_to_repo!(settings, repo, limit_file_size); - global_to_repo!(settings, repo, limit_repo_size); - global_to_repo!(settings, repo, limit_total_size); - - repo_descriptions.insert(repo); - }, - Err(e) => { - error!("Failed to parse repo [{}]: {:?}", k, e); - }, - } - } - - match &settings.recursive_repo_dirs { - Some(dirs) => { - for parent in dirs { - for dir in read_dir(parent).expect("Repo directory not found.") { - let dir = dir.expect("Repo contains invalid entries"); - let name: String = dir.file_name().to_string_lossy().to_string(); - repo_descriptions.insert(GitsySettingsRepo { - path: dir.path().clone(), - name: Some(name), - render_markdown: settings.render_markdown.clone(), - syntax_highlight: settings.syntax_highlight.clone(), - syntax_highlight_theme: settings.syntax_highlight_theme.clone(), - limit_history: settings.limit_history.clone(), - limit_commits: settings.limit_commits.clone(), - limit_branches: settings.limit_branches.clone(), - limit_tags: settings.limit_tags.clone(), - limit_tree_depth: settings.limit_tree_depth.clone(), - limit_file_size: settings.limit_file_size.clone(), - limit_repo_size: settings.limit_repo_size.clone(), - limit_total_size: settings.limit_total_size.clone(), - ..Default::default() - }); - } - } - }, - _ => {}, - } - - let mut template_path = settings.templates.path.clone(); - template_path.push("**"); - template_path.push("*.html"); - let mut tera = match Tera::new(template_path.to_str().expect("No template path set!")) { - Ok(t) => t, - Err(e) => { - error!("Parsing error(s): {}", e); - std::process::exit(1); - } - }; - tera.register_filter("only_files", FileFilter{}); - tera.register_filter("only_dirs", DirFilter{}); - tera.register_function("ts_to_date", TsDateFn{}); - tera.register_function("ts_to_git_timestamp", TsTimestampFn{}); - - macro_rules! size_check { - ($settings:ident, $cur:expr, $total:expr, $action:expr) => { - let cur: usize = $cur; - if cur > $settings.limit_repo_size.unwrap_or(usize::MAX) { - $action; - } - let total: usize = $total; - if total.saturating_add($cur) > $settings.limit_total_size.unwrap_or(usize::MAX) { - $action; - } - } - } - - // Create output directory - let _ = create_dir(settings.outputs.path.to_str().expect("Output path invalid.")); - - let generated_dt = chrono::offset::Local::now(); - let mut global_bytes = 0; - let mut total_bytes = 0; - let mut repos: Vec = vec!(); - - if repo_descriptions.len() == 0 { - panic!("No Git repositories defined! Please check your configuration file ({})", - config_path.display()); - } - - // Sort the repositories by name - let mut repo_vec: Vec = repo_descriptions.drain().collect(); - repo_vec.sort_by(|x,y| x.name.as_deref().map(|n| n.cmp(&y.name.as_deref().unwrap_or_default())) - .unwrap_or(cmp::Ordering::Equal)); - // Find the one with the longest name, for pretty printing - let global_name = "repo list"; - let longest_repo_name = repo_vec.iter().fold(0, |acc, x| { - cmp::max(acc, x.name.as_deref().map(|n| n.len()).unwrap_or(0)) - }).max(global_name.len()); - - loudest!("Global settings:\n{:#?}", &settings); - - // Iterate over each repository, generating outputs - for repo_desc in &repo_vec { - loudest!("Repo settings:\n{:#?}", &repo_desc); - let start_repo = Instant::now(); - let mut repo_bytes = 0; - let name = repo_desc.name.as_deref().expect("A configured repository has no name!"); - - let repo_path = match &repo_desc.path { - url if url.starts_with("https://") || - url.to_str().unwrap_or_default().contains("@") => { - if settings.outputs.cloned_repos.is_none() { - error!("ERROR: Found remote repo [{}], but `cloned_repos` directory not configured.", name); - continue; - }; - let clone_path: PathBuf = [settings.outputs.cloned_repos.as_deref().unwrap(), - name].iter().collect(); - match Repository::open(&clone_path) { - Ok(r) => { - // Repo already cloned, so update all refs - let refs: Vec = r.references() - .expect(&format!("Unable to enumerate references for repo [{}]", name)) - .map(|x| x.expect(&format!("Found invalid reference in repo [{}]", name)) - .name() - .expect(&format!("Found unnamed reference in repo: [{}]", name)) - .to_string()).collect(); - r.find_remote("origin") - .expect(&format!("Clone of repo [{}] missing `origin` remote.", name)) - .fetch(&refs, None, None) - .expect(&format!("Failed to fetch updates from remote repo [{}]", name)); - clone_path.to_string_lossy().to_string() - }, - Err(_) => { - let mut builder = git2::build::RepoBuilder::new(); - - // TODO: git2-rs's ssh support just doesn't seem to - // work. It finds the repo, but fails to either - // decrypt or use the private key. - // - //if !url.starts_with("https://") { - // use secrecy::ExposeSecret; - // // this must be SSH, which needs credentials. - // let mut callbacks = git2::RemoteCallbacks::new(); - // callbacks.credentials(|_url, username_from_url, _allowed_types| { - // //git2::Cred::ssh_key_from_agent(username_from_url.unwrap()) - // - // let keyfile = format!("{}/.ssh/id_rsa", std::env::var("HOME").unwrap()); - // let passphrase = pinentry::PassphraseInput::with_default_binary().unwrap() - // .with_description(&format!("Enter passphrase for SSH key {} (repo: {})", - // keyfile, url.display())) - // .with_prompt("Passphrase:") - // .interact().unwrap(); - // git2::Cred::ssh_key( - // username_from_url.unwrap(), - // None, - // Path::new(&keyfile), - // Some(passphrase.expose_secret()), - // ) - // }); - // let mut options = git2::FetchOptions::new(); - // options.remote_callbacks(callbacks); - // builder.fetch_options(options); - //} - builder - .bare(true) - .clone(&url.to_string_lossy().to_string(), &clone_path) - .expect(&format!("Failed to clone remote repo [{}]", name)); - clone_path.to_string_lossy().to_string() - } - } - } - dir => { - match dir.metadata() { - Ok(m) if m.is_dir() => {}, - _ => { - error!("ERROR: local repository [{}]: directory not found: {}", name, dir.display()); - continue; - }, - } - dir.to_string_lossy().to_string() - }, - }; - let repo = Repository::open(&repo_path).expect("Unable to find git repository."); - let metadata = GitsyMetadata { - full_name: repo_desc.name.clone(), - description: repo_desc.description.clone(), - website: repo_desc.website.clone(), - clone: None, - attributes: repo_desc.attributes.clone().unwrap_or_default(), - }; - normal_noln!("[{}{}]... ", name, " ".repeat(longest_repo_name - name.len())); - let summary = parse_repo(&repo, &name, &repo_desc, metadata).expect("Failed to analyze repo HEAD."); - - let mut local_ctx = Context::from_serialize(&summary).unwrap(); - if let Some(extra) = &settings.extra { - local_ctx.try_insert("extra", extra).expect("Failed to add extra settings to template engine."); - } - if let Some(site_name) = &settings.site_name { - local_ctx.insert("site_name", site_name); - } - if let Some(site_url) = &settings.site_url { - local_ctx.insert("site_url", site_url); - } - if let Some(site_description) = &settings.site_description { - local_ctx.insert("site_description", site_description); - } - local_ctx.insert("site_generated_ts", &generated_dt.timestamp()); - local_ctx.insert("site_generated_offset", &generated_dt.offset().local_minus_utc()); - - if let Some(templ_file) = settings.templates.repo_summary.as_deref() { - match tera.render(templ_file, &local_ctx) { - Ok(rendered) => { - repo_bytes += write_rendered(&settings.outputs.repo_summary(Some(&summary), None), &rendered); - }, - Err(x) => match x.kind { - _ => error!("ERROR: {:?}", x), - }, - } - } - - for branch in &summary.branches { - size_check!(repo_desc, repo_bytes, total_bytes, break); - local_ctx.insert("branch", branch); - if let Some(templ_file) = settings.templates.branch.as_deref() { - match tera.render(templ_file, &local_ctx) { - Ok(rendered) => { - repo_bytes += write_rendered(&settings.outputs.branch(Some(&summary), Some(branch)), &rendered); - }, - Err(x) => match x.kind { - _ => error!("ERROR: {:?}", x), - }, - } - } - local_ctx.remove("branch"); - } - - for tag in &summary.tags { - size_check!(repo_desc, repo_bytes, total_bytes, break); - local_ctx.insert("tag", tag); - if let Some(tagged_id) = tag.tagged_id.as_ref() { - if let Some(commit) = summary.commits.get(tagged_id) { - local_ctx.insert("commit", &commit); - } - } - if let Some(templ_file) = settings.templates.tag.as_deref() { - match tera.render(templ_file, &local_ctx) { - Ok(rendered) => { - repo_bytes += write_rendered(&settings.outputs.tag(Some(&summary), Some(tag)), &rendered); - }, - Err(x) => match x.kind { - _ => error!("ERROR: {:?}", x), - }, - } - } - local_ctx.remove("tag"); - local_ctx.remove("commit"); - } - - for (_id, commit) in &summary.commits { - size_check!(repo_desc, repo_bytes, total_bytes, break); - local_ctx.try_insert("commit", &commit).expect("Failed to add commit to template engine."); - if let Some(templ_file) = settings.templates.commit.as_deref() { - match tera.render(templ_file, &local_ctx) { - Ok(rendered) => { - repo_bytes += write_rendered(&settings.outputs.commit(Some(&summary), Some(commit)), &rendered); - }, - Err(x) => match x.kind { - _ => error!("ERROR: {:?}", x), - }, - } - } - local_ctx.remove("commit"); - } - - #[cfg(any(feature = "highlight", feature = "highlight_fast"))] - if settings.templates.file.is_some() { - let ts = ThemeSet::load_defaults(); - let theme = ts.themes.get(repo_desc.syntax_highlight_theme.as_deref() - .unwrap_or("base16-ocean.light")).expect("Invalid syntax highlighting theme specified."); - let css: String = css_for_theme_with_class_style(theme, syntect::html::ClassStyle::Spaced) - .expect("Invalid syntax highlighting theme specified."); - repo_bytes += write_rendered(&settings.outputs.syntax_css(Some(&summary), None), css.as_str()); - } - - - // TODO: parallelize the rest of the processing steps. This one is - // done first because syntax highlighting is very slow. - let files: Vec<&GitFile> = summary.all_files.iter().filter(|x| x.kind == "file").collect(); - let atomic_bytes: AtomicUsize = AtomicUsize::new(repo_bytes); - let _ = files.par_iter().fold(|| Some(0), |acc, file| { - // These two have to be recreated. Cloning the Tera context is expensive. - let repo = Repository::open(&repo_path).expect("Unable to find git repository."); - let mut local_ctx = local_ctx.clone(); - - let mut local_bytes = 0; - let cur_repo_bytes = atomic_bytes.load(Ordering::Relaxed); - size_check!(repo_desc, cur_repo_bytes, total_bytes, return None); - let file = match file.size < repo_desc.limit_file_size.unwrap_or(usize::MAX) { - true => fill_file_contents(&repo, &file, &repo_desc).expect("Failed to parse file."), - false => (*file).clone(), - }; - local_ctx.try_insert("file", &file).expect("Failed to add file to template engine."); - if let Some(templ_file) = settings.templates.file.as_deref() { - match tera.render(templ_file, &local_ctx) { - Ok(rendered) => { - local_bytes = write_rendered(&settings.outputs.file(Some(&summary), Some(&file)), &rendered); - atomic_bytes.fetch_add(local_bytes, Ordering::Relaxed); - }, - Err(x) => match x.kind { - _ => error!("ERROR: {:?}", x), - }, - } - } - local_ctx.remove("file"); - Some(acc.unwrap() + local_bytes)}) - .while_some() // allow short-circuiting if size limit is reached - .sum::(); - repo_bytes = atomic_bytes.load(Ordering::Relaxed); - - for dir in summary.all_files.iter().filter(|x| x.kind == "dir") { - size_check!(repo_desc, repo_bytes, total_bytes, break); - if dir.tree_depth >= repo_desc.limit_tree_depth.unwrap_or(usize::MAX) - 1 { - continue; - } - let listing = dir_listing(&repo, &dir).expect("Failed to parse file."); - local_ctx.try_insert("files", &listing).expect("Failed to add dir to template engine."); - if let Some(templ_file) = settings.templates.dir.as_deref() { - match tera.render(templ_file, &local_ctx) { - Ok(rendered) => { - repo_bytes += write_rendered(&settings.outputs.dir(Some(&summary), Some(dir)), &rendered); - }, - Err(x) => match x.kind { - _ => error!("ERROR: {:?}", x), - }, - } - } - local_ctx.remove("files"); - } - - if repo_desc.asset_files.is_some() { - let target_dir = settings.outputs.repo_assets(Some(&summary), None); - for src_file in repo_desc.asset_files.as_ref().unwrap() { - let src_file = PathBuf::from(repo_path.to_owned() + "/" + src_file); - let mut dst_file = PathBuf::from(&target_dir); - dst_file.push(src_file.file_name() - .expect(&format!("Failed to copy repo asset file: {} ({})", - src_file.display(), repo_desc.name.as_deref().unwrap_or_default()))); - std::fs::copy(&src_file, &dst_file) - .expect(&format!("Failed to copy repo asset file: {} ({})", - src_file.display(), repo_desc.name.as_deref().unwrap_or_default())); - if let Ok(meta) = std::fs::metadata(dst_file) { - repo_bytes += meta.len() as usize; - } - } - } - - repos.push(summary); - normal!("{}done in {:.2}s ({} bytes)", - match VERBOSITY.load(Ordering::Relaxed) > 1 { - true => " - ", - _ => "", - }, - start_repo.elapsed().as_secs_f32(), repo_bytes); - total_bytes += repo_bytes; - size_check!(repo_desc, 0, total_bytes, break); // break if total is exceeded - } - - let start_global = Instant::now(); - normal_noln!("[{}{}]... ", global_name, " ".repeat(longest_repo_name - global_name.len())); - let mut global_ctx = Context::new(); - global_ctx.try_insert("repos", &repos).expect("Failed to add repo to template engine."); - if let Some(extra) = &settings.extra { - global_ctx.try_insert("extra", extra).expect("Failed to add extra settings to template engine."); - } - if let Some(site_name) = &settings.site_name { - global_ctx.insert("site_name", site_name); - } - if let Some(site_url) = &settings.site_url { - global_ctx.insert("site_url", site_url); - } - if let Some(site_description) = &settings.site_description { - global_ctx.insert("site_description", site_description); - } - global_ctx.insert("site_generated_ts", &generated_dt.timestamp()); - global_ctx.insert("site_generated_offset", &generated_dt.offset().local_minus_utc()); - - if let Some(templ_file) = settings.templates.repo_list.as_deref() { - match tera.render(templ_file, &global_ctx) { - Ok(rendered) => { - global_bytes += write_rendered(&settings.outputs.repo_list(None, None), &rendered); - }, - Err(x) => match x.kind { - _ => error!("ERROR: {:?}", x), - }, - } - } - - if let Some(templ_file) = settings.templates.error.as_deref() { - match tera.render(templ_file, &global_ctx) { - Ok(rendered) => { - global_bytes += write_rendered(&settings.outputs.error(None, None), &rendered); - }, - Err(x) => match x.kind { - _ => error!("ERROR: {:?}", x), - }, - } - } - - if settings.asset_files.is_some() { - let target_dir = settings.outputs.global_assets(None, None); - for src_file in settings.asset_files.unwrap() { - let src_file = PathBuf::from(src_file); - let mut dst_file = PathBuf::from(&target_dir); - dst_file.push(src_file.file_name() - .expect(&format!("Failed to copy asset file: {}", src_file.display()))); - std::fs::copy(&src_file, &dst_file) - .expect(&format!("Failed to copy asset file: {}", src_file.display())); - if let Ok(meta) = std::fs::metadata(dst_file) { - global_bytes += meta.len() as usize; - } - } - } - - total_bytes += global_bytes; - normal!("done in {:.2}s ({} bytes)", start_global.elapsed().as_secs_f32(), global_bytes); - loud!("Wrote {} bytes in {:.2}s", total_bytes, start_all.elapsed().as_secs_f32()); + let cli = GitsyCli::new(); + let (settings, repo_descriptions) = GitsySettings::new(&cli); + let generator = GitsyGenerator::new(cli, settings, repo_descriptions); + generator.generate().expect("Itsy-Gitsy generation failed!"); } diff --git a/src/settings.rs b/src/settings.rs new file mode 100644 index 0000000..6210fe5 --- /dev/null +++ b/src/settings.rs @@ -0,0 +1,305 @@ +use clap::Parser; +use crate::error; +use crate::git::{ + GitFile, + GitObject, + GitRepo, +}; +use serde::Deserialize; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::fs::{create_dir_all, read_dir, read_to_string}; +use std::hash::{Hash, Hasher}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::Ordering; + +#[derive(Parser, Debug)] +#[command(author = "Trevor Bentley", version, about, long_about = None)] +#[command(help_template = "\ +{name} v{version}, by {author-with-newline} +{about-with-newline} +{usage-heading} {usage} + +{all-args}{after-help} +")] +struct CliArgs { + #[arg(short, long, value_name = "FILE")] + config: Option, + #[arg(short, long)] + quiet: bool, + #[arg(short, long, action = clap::ArgAction::Count)] + verbose: u8, +} + +pub struct GitsyCli { + pub path: PathBuf, + pub dir: PathBuf, +} + +impl GitsyCli { + pub fn new() -> Self { + let cli = CliArgs::parse(); + let config_path = cli.config.as_deref().unwrap_or(Path::new("config.toml")).to_owned(); + let config_dir = config_path.parent().expect("Config file not in valid directory.").to_owned(); + let config_dir = match config_dir.to_str().unwrap_or_default().len() > 0 { + true => config_dir, + false => PathBuf::from("."), + }; + let config_path = match config_path.canonicalize() { + Ok(d) => d, + _ => config_path.clone(), + }; + let config_dir = match config_dir.canonicalize() { + Ok(d) => d, + _ => config_dir.clone(), + }; + crate::util::VERBOSITY.store(match cli.quiet { + true => 0, + false => (cli.verbose + 1).into(), + }, Ordering::Relaxed); + GitsyCli { + path: config_path, + dir: config_dir, + } + } +} + + +#[derive(Deserialize, Debug)] +pub struct GitsySettingsTemplates { + pub path: PathBuf, + pub repo_list: Option, + pub repo_summary: Option, + pub commit: Option, + pub branch: Option, + pub tag: Option, + pub file: Option, + pub dir: Option, + pub error: Option, +} + +#[derive(Deserialize, Debug)] +pub struct GitsySettingsOutputs { + pub path: PathBuf, + pub cloned_repos: Option, + pub repo_list: Option, + pub repo_summary: Option, + pub commit: Option, + pub branch: Option, + pub tag: Option, + pub file: Option, + pub dir: Option, + pub error: Option, + pub syntax_css: Option, + pub global_assets: Option, + pub repo_assets: Option, +} + +macro_rules! output_path_fn { + ($var:ident, $obj:ty, $id:ident, $is_dir:expr, $default:expr) => { + pub fn $var(&self, repo: Option<&GitRepo>, obj: Option<&$obj>) -> String { + let tmpl_str = self.$var.as_deref().unwrap_or($default).to_string(); + let tmpl_str = match (tmpl_str.contains("%REPO%"), repo.is_some()) { + (true, true) => { + let name = repo.map(|x| &x.name).unwrap(); + tmpl_str.replace("%REPO%", name) + }, + (true, false) => { + panic!("%REPO% variable not available for output path: {}", tmpl_str); + } + _ => tmpl_str, + }; + let tmpl_str = match (tmpl_str.contains("%ID%"), obj.is_some()) { + (true, true) => { + let name = obj.map(|x| &x.$id).unwrap(); + tmpl_str.replace("%ID%", name) + }, + (true, false) => { + panic!("%ID% variable not available for output path: {}", tmpl_str); + } + _ => tmpl_str, + }; + let tmpl = PathBuf::from(tmpl_str); + let mut path = self.path.clone(); + path.push(tmpl); + match $is_dir { + true => { + let _ = create_dir_all(&path); + }, + false => { + if let Some(dir) = path.parent() { + let _ = create_dir_all(dir); + } + }, + } + path.to_str() + .expect(&format!("Output is not a valid path: {}", path.display())) + .into() + } + }; +} +//step_map_first!(boil_in_wort, Boil, Wort, |b: &Boil| { b.wort_start() }); + +impl GitsySettingsOutputs { + output_path_fn!(repo_list, GitObject, full_hash, false, "repos.html"); + output_path_fn!(repo_summary, GitObject, full_hash, false, "%REPO%/summary.html"); + output_path_fn!(commit, GitObject, full_hash, false, "%REPO%/commit/%ID%.html"); + output_path_fn!(branch, GitObject, full_hash, false, "%REPO%/branch/%ID%.html"); + output_path_fn!(tag, GitObject, full_hash, false, "%REPO%/tag/%ID%.html"); + output_path_fn!(file, GitFile, id, false, "%REPO%/file/%ID%.html"); + output_path_fn!(syntax_css, GitObject, full_hash, false, "%REPO%/file/syntax.css"); + output_path_fn!(dir, GitFile, id, false, "%REPO%/dir/%ID%.html"); + output_path_fn!(error, GitObject, full_hash, false, "404.html"); + output_path_fn!(global_assets, GitObject, full_hash, true, "assets/"); + output_path_fn!(repo_assets , GitObject, full_hash, true, "%REPO%/assets/"); +} + +#[derive(Clone, Deserialize, Default, Debug)] +pub struct GitsySettingsRepo { + pub path: PathBuf, + pub name: Option, + pub description: Option, + pub website: Option, + pub asset_files: Option>, + pub render_markdown: Option, + pub syntax_highlight: Option, + pub syntax_highlight_theme: Option, + pub attributes: Option>, + pub limit_history: Option, + pub limit_commits: Option, + pub limit_branches: Option, + pub limit_tags: Option, + pub limit_tree_depth: Option, + pub limit_file_size: Option, + pub limit_repo_size: Option, + pub limit_total_size: Option, +} + +impl Hash for GitsySettingsRepo { + fn hash(&self, state: &mut H) { + self.path.hash(state); + } +} +impl PartialEq for GitsySettingsRepo { + fn eq(&self, other: &Self) -> bool { + self.path == other.path + } +} +impl Eq for GitsySettingsRepo {} + +pub type GitsyRepoDescriptions = HashSet; + +#[derive(Deserialize, Debug)] +#[allow(dead_code)] +pub struct GitsySettings { + pub recursive_repo_dirs: Option>, + pub site_name: Option, + pub site_url: Option, + pub site_description: Option, + pub asset_files: Option>, + #[serde(rename(deserialize = "gitsy_templates"))] + pub templates: GitsySettingsTemplates, + #[serde(rename(deserialize = "gitsy_outputs"))] + pub outputs: GitsySettingsOutputs, + pub limit_history: Option, + pub limit_commits: Option, + pub limit_branches: Option, + pub limit_tags: Option, + pub limit_tree_depth: Option, + pub limit_file_size: Option, + pub limit_repo_size: Option, + pub limit_total_size: Option, + pub render_markdown: Option, + pub syntax_highlight: Option, + pub syntax_highlight_theme: Option, + #[serde(rename(deserialize = "gitsy_extra"))] + pub extra: Option>, +} + +impl GitsySettings { + pub fn new(cli: &GitsyCli) -> (GitsySettings, GitsyRepoDescriptions) { + // Parse the known settings directly into their struct + let toml = read_to_string(&cli.path).expect(&format!("Configuration file not found: {}", cli.path.display())); + let settings: GitsySettings = toml::from_str(&toml).expect("Configuration file is invalid."); + + // Settings are valid, so let's move into the directory with the config file + if cli.dir.to_str().unwrap_or_default().len() > 0 { // empty string means current directory + std::env::set_current_dir(&cli.dir) + .expect(&format!("Unable to set working directory to: {}", cli.dir.display())); + } + + // Get a list of all remaining TOML "tables" in the file. + // These are the user-supplied individual repositories. + let reserved_keys = vec!("gitsy_templates", "gitsy_outputs", "gitsy_extra"); + let settings_raw: HashMap = toml::from_str(&toml).expect("blah"); + let table_keys: Vec = settings_raw.iter().filter_map(|x| match x.1.is_table() { + true => match reserved_keys.contains(&x.0.as_str()) { + false => Some(x.0.clone()), + true => None, + }, + false => None + }).collect(); + + // Try to convert each unknown "table" into a repo struct, and + // save the ones that are successful. If no repo name is + // specified, use the TOML table name. + let mut repo_descriptions: HashSet = HashSet::new(); + macro_rules! global_to_repo { + ($settings:ident, $repo:ident, $field:ident) => { + if $repo.$field.is_none() { $repo.$field = $settings.$field.clone() } + } + } + for k in &table_keys { + let v = settings_raw.get(k).unwrap(); + match toml::from_str::(&v.to_string()) { + Ok(mut repo) => { + if repo.name.is_none() { repo.name = Some(k.clone()); } + global_to_repo!(settings, repo, render_markdown); + global_to_repo!(settings, repo, syntax_highlight); + global_to_repo!(settings, repo, syntax_highlight_theme); + global_to_repo!(settings, repo, limit_history); + global_to_repo!(settings, repo, limit_commits); + global_to_repo!(settings, repo, limit_branches); + global_to_repo!(settings, repo, limit_tags); + global_to_repo!(settings, repo, limit_tree_depth); + global_to_repo!(settings, repo, limit_file_size); + global_to_repo!(settings, repo, limit_repo_size); + global_to_repo!(settings, repo, limit_total_size); + + repo_descriptions.insert(repo); + }, + Err(e) => { + error!("Failed to parse repo [{}]: {:?}", k, e); + }, + } + } + + match &settings.recursive_repo_dirs { + Some(dirs) => { + for parent in dirs { + for dir in read_dir(parent).expect("Repo directory not found.") { + let dir = dir.expect("Repo contains invalid entries"); + let name: String = dir.file_name().to_string_lossy().to_string(); + repo_descriptions.insert(GitsySettingsRepo { + path: dir.path().clone(), + name: Some(name), + render_markdown: settings.render_markdown.clone(), + syntax_highlight: settings.syntax_highlight.clone(), + syntax_highlight_theme: settings.syntax_highlight_theme.clone(), + limit_history: settings.limit_history.clone(), + limit_commits: settings.limit_commits.clone(), + limit_branches: settings.limit_branches.clone(), + limit_tags: settings.limit_tags.clone(), + limit_tree_depth: settings.limit_tree_depth.clone(), + limit_file_size: settings.limit_file_size.clone(), + limit_repo_size: settings.limit_repo_size.clone(), + limit_total_size: settings.limit_total_size.clone(), + ..Default::default() + }); + } + } + }, + _ => {}, + } + (settings, repo_descriptions) + } +} diff --git a/src/template.rs b/src/template.rs new file mode 100644 index 0000000..611358e --- /dev/null +++ b/src/template.rs @@ -0,0 +1,105 @@ +use chrono::{ + DateTime, + offset::FixedOffset, + naive::NaiveDateTime, +}; +use crate::git::GitFile; +use std::collections::HashMap; +use tera::{Filter, Function, Value, to_value, try_get_value}; + +fn ts_to_date(ts: i64, offset: Option, format: Option) -> String { + let offset = offset.unwrap_or(0); + let dt = NaiveDateTime::from_timestamp_opt(ts + offset, 0).expect("Invalid timestamp"); + let dt_tz: DateTime = DateTime::from_local(dt, FixedOffset::east_opt(offset as i32).expect("Invalid timezone")); + match format { + Some(f) => dt_tz.format(&f).to_string(), + None => dt_tz.format("%Y-%m-%d").to_string(), + } +} + +fn ts_to_git_timestamp(ts: i64, offset: Option) -> String { + let offset = offset.unwrap_or(0); + let dt = chrono::naive::NaiveDateTime::from_timestamp_opt(ts + offset, 0).expect("invalid timestamp"); + let dt_tz: DateTime = DateTime::from_local(dt, FixedOffset::east_opt(offset as i32).expect("Invalid timezone")); + dt_tz.format("%a %b %e %T %Y %z").to_string() +} + +pub struct FileFilter; +impl Filter for FileFilter { + fn filter(&self, value: &Value, _args: &HashMap + ) -> Result { + let file_list: Vec = try_get_value!("only_files", "value", Vec, value); + let file_list: Vec = file_list.iter().filter_map(|x| match x.kind.as_str() { + "file" => Some(x.clone()), + _ => None, + }).collect(); + Ok(to_value(file_list).unwrap()) + } +} + +pub struct DirFilter; +impl Filter for DirFilter { + fn filter(&self, value: &Value, _args: &HashMap + ) -> Result { + let file_list: Vec = try_get_value!("only_dirs", "value", Vec, value); + let file_list: Vec = file_list.iter().filter_map(|x| match x.kind.as_str() { + "dir" => Some(x.clone()), + _ => None, + }).collect(); + Ok(to_value(file_list).unwrap()) + } +} + +pub struct TsDateFn; +impl Function for TsDateFn { + fn call(&self, args: &HashMap) -> Result { + let ts: Option = match args.get("ts") { + Some(ts) => match tera::from_value(ts.clone()) { + Ok(ts) => Some(ts), + _ => None, + }, + _ => None, + }; + let ts = ts.expect("ts_to_date missing a `ts` argument"); + + let tz: Option = match args.get("tz") { + Some(tz) => match tera::from_value(tz.clone()) { + Ok(tz) => Some(tz), + _ => None, + }, + _ => None, + }; + + let fmt: Option = match args.get("fmt") { + Some(fmt) => match tera::from_value(fmt.clone()) { + Ok(fmt) => Some(fmt), + _ => None, + }, + _ => None, + }; + Ok(to_value(ts_to_date(ts, tz, fmt)).unwrap()) + } +} + +pub struct TsTimestampFn; +impl Function for TsTimestampFn { + fn call(&self, args: &HashMap) -> Result { + let ts: Option = match args.get("ts") { + Some(ts) => match tera::from_value(ts.clone()) { + Ok(ts) => Some(ts), + _ => None, + }, + _ => None, + }; + let ts = ts.expect("ts_to_git_timestamp missing a `ts` argument"); + + let tz: Option = match args.get("tz") { + Some(tz) => match tera::from_value(tz.clone()) { + Ok(tz) => Some(tz), + _ => None, + }, + _ => None, + }; + Ok(to_value(ts_to_git_timestamp(ts, tz)).unwrap()) + } +} diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..f360c0d --- /dev/null +++ b/src/util.rs @@ -0,0 +1,110 @@ +use std::sync::atomic::AtomicUsize; + +pub static VERBOSITY: AtomicUsize = AtomicUsize::new(0); + +#[macro_export] +#[allow(unused_macros)] +macro_rules! always { + () => { println!() }; + ($($arg:tt)*) => {{ println!($($arg)*); }}; +} + +#[macro_export] +#[allow(unused_macros)] +macro_rules! error { + () => { eprintln!() }; + ($($arg:tt)*) => {{ eprintln!($($arg)*); }}; +} + +#[macro_export] +#[allow(unused_macros)] +macro_rules! normal { + () => { if crate::util::VERBOSITY.load(Ordering::Relaxed) > 0 { println!() } }; + ($($arg:tt)*) => {{ if crate::util::VERBOSITY.load(Ordering::Relaxed) > 0 { println!($($arg)*); } }}; +} + +#[macro_export] +#[allow(unused_macros)] +macro_rules! normal_noln { + () => { if crate::util::VERBOSITY.load(Ordering::Relaxed) > 0 { print!(); let _ = std::io::stdout().flush(); } }; + ($($arg:tt)*) => { if crate::util::VERBOSITY.load(Ordering::Relaxed) > 0 { {print!($($arg)*);}; let _ = std::io::stdout().flush(); }}; +} + +#[macro_export] +#[allow(unused_macros)] +macro_rules! loud { + () => { if crate::util::VERBOSITY.load(Ordering::Relaxed) > 1 { println!() } }; + ($($arg:tt)*) => {{ if crate::util::VERBOSITY.load(Ordering::Relaxed) > 1 { println!($($arg)*); } }}; +} + +#[macro_export] +#[allow(unused_macros)] +macro_rules! louder { + () => { if crate::util::VERBOSITY.load(Ordering::Relaxed) > 2 { println!() } }; + ($($arg:tt)*) => {{ if crate::util::VERBOSITY.load(Ordering::Relaxed) > 2 { println!($($arg)*); } }}; +} + +#[macro_export] +#[allow(unused_macros)] +macro_rules! loudest { + () => { if crate::util::VERBOSITY.load(Ordering::Relaxed) > 3 { println!() } }; + ($($arg:tt)*) => {{ if crate::util::VERBOSITY.load(Ordering::Relaxed) > 3 { println!($($arg)*); } }}; +} + +#[allow(dead_code)] +pub enum GitsyErrorKind { + Unknown, + Settings, + Template, + Git, +} +pub struct GitsyError { + msg: Option, + kind: GitsyErrorKind, + source: Option>, +} +#[allow(dead_code)] +impl GitsyError { + pub fn kind(kind: GitsyErrorKind, msg: Option<&str>) -> Self { + GitsyError { + kind, + msg: msg.map(|x| x.to_owned()), + source: None, + } + } + pub fn sourced_kind(kind: GitsyErrorKind, msg: Option<&str>, source: impl std::error::Error + 'static) -> Self { + GitsyError { + kind, + msg: msg.map(|x| x.to_owned()), + source: Some(Box::new(source)), + } + } +} +impl std::fmt::Display for GitsyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self.kind { + _ => write!(f, "gitsy error (unknown)")?, + } + write!(f, ": {}", self.msg.as_deref().unwrap_or_default()) + } +} +impl std::fmt::Debug for GitsyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self) + } +} +impl std::error::Error for GitsyError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.source.as_deref() + } +} +impl From for GitsyError { + fn from(source: git2::Error) -> Self { + GitsyError::sourced_kind(GitsyErrorKind::Git, Some(&source.message().to_owned()), source) + } +} +impl From for GitsyError { + fn from(source: tera::Error) -> Self { + GitsyError::sourced_kind(GitsyErrorKind::Template, Some(&source.to_string()), source) + } +}