use std::{
fmt::Write,
path::{Path, PathBuf},
};
use serde::{Deserialize, Serialize};
use crate::{Error, Name, Result};
pub struct Boilerplate<'a> {
name: String,
path: PathBuf,
repo: &'a Repository,
}
impl<'a> Boilerplate<'a> {
pub(crate) fn new<P: AsRef<Path>>(name: String, path: P, repo: &'a Repository) -> Boilerplate<'a> {
log::debug!(
"Boilerplate::new(name: {name}, path: {:?}, repo: {:?})",
path.as_ref(),
repo.path
);
Self {
name,
path: path.as_ref().to_path_buf(),
repo,
}
}
pub fn name(&self) -> Name {
Name::new(self.repository_name(), self.boilerplate_name())
}
pub fn file_path<P: AsRef<Path>>(&self, base_path: P) -> PathBuf {
self.repo_path(base_path).join(self.path())
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn repo_path<P: AsRef<Path>>(&self, base_path: P) -> PathBuf {
self.repo.path(base_path)
}
pub fn boilerplate_name(&self) -> &str {
self.name.as_ref()
}
pub fn repository_name(&self) -> &str {
&self.repo.name
}
pub fn matches(&self, name: &Name) -> bool {
name.boilerplate_name.to_lowercase() == self.boilerplate_name().to_lowercase()
&& match &name.repository_name {
Some(repo_name) => {
repo_name.to_lowercase() == self.repository_name().to_lowercase()
}
None => true,
}
}
pub fn hash<P: AsRef<Path>>(&self, base_path: P) -> Result<Vec<u8>> {
crate::source::hash(self, base_path)
}
pub fn content_url<P: AsRef<Path>>(&self, base_path: P) -> Result<String> {
let base_path = base_path.as_ref();
log::info!(
"boilerplate content_url(name: {}, path: {:?}, repo: {:?}, base_path: {:?})",
self.name,
self.path,
self.repo.path,
base_path
);
let hash = self.hash(base_path)?;
log::trace!("hash: {hash:02x?}");
let hash_string = hash.iter().fold(String::new(), |mut output, b| {
let _ = write!(output, "{b:02X}");
output
});
let url = &self.repo.url;
if url.contains("github.com") {
Ok(format!(
"https://raw.github.com/{0}/{1}/{2}/{3}",
self.repo.owner,
self.repo.repo_name,
hash_string,
self.path.to_string_lossy()
))
} else if url.contains("gitlab.com") {
Ok(format!(
"https://gitlab.com/{0}/{1}/-/raw/{2}/{3}",
self.repo.owner,
self.repo.repo_name,
hash_string,
self.path.to_string_lossy()
))
} else if url.contains("bitbucket.org") {
Ok(format!(
"https://bitbucket.org/{0}/{1}/raw/{2}/{3}",
self.repo.owner,
self.repo.repo_name,
hash_string,
self.path.to_string_lossy()
))
} else {
Err(Error::Fatal(format!("{url}: Unsupported repository host")))
}
}
pub fn dump<P: AsRef<Path>>(&self, base_path: P) -> Result<String> {
let content = crate::source::read(self, &base_path)?;
Ok(format!(
r#"### Generated by Gixor (https://github.com/tamada/gixor) ({}/{})
### {}
{}
"#,
self.repository_name(),
self.name,
self.content_url(base_path.as_ref())?,
content
))
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "kebab-case")]
pub struct Repository {
pub name: String,
pub url: String,
pub owner: String,
pub repo_name: String,
pub path: PathBuf,
}
impl Default for Repository {
fn default() -> Self {
Repository {
name: "default".to_string(),
url: "https://github.com/github/gitignore.git".to_string(),
repo_name: "gitignore".to_string(),
owner: "github".to_string(),
path: PathBuf::from("default"),
}
}
}
impl Repository {
pub fn new<S: AsRef<str>>(url: S) -> Self {
let url = url.as_ref();
let (owner, repo_name) = url_to_owner_and_repo_name(url);
let path = PathBuf::from(&owner);
log::debug!(
"Repository::new(url: {url}) -> owner: {owner}, repo_name: {repo_name}, path: {path:?}"
);
Self {
name: owner.clone(),
url: url.to_string(),
owner: owner.clone(),
repo_name,
path,
}
}
pub fn new_with<S: AsRef<str>>(name: S, url: S) -> Self {
let url = url.as_ref();
let name = name.as_ref();
let (owner, repo_name) = url_to_owner_and_repo_name(url);
log::debug!("Repository::new_with(name: {name}, url: {url}) -> owner: {owner}, repo_name: {repo_name}");
Self {
name: name.to_string(),
url: url.to_string(),
owner,
repo_name,
path: PathBuf::from(name),
}
}
pub fn path<P: AsRef<Path>>(&self, base_path: P) -> PathBuf {
if self.path.is_absolute() {
self.path.clone()
} else {
base_path.as_ref().join(&self.path)
}
}
pub fn find<P: AsRef<Path>>(&self, name: &Name, base_path: P) -> Option<Boilerplate<'_>> {
self.iter(base_path).find(|b| name.matches(b))
}
pub fn iter<P: AsRef<Path>>(&self, base_path: P) -> impl Iterator<Item = Boilerplate<'_>> {
crate::source::list(self, base_path).into_iter()
}
pub fn prepare<P: AsRef<Path>>(&self, base_path: P) -> Result<()> {
crate::source::prepare(self, base_path)
}
pub fn clone_repo_to<P: AsRef<Path>>(&self, base_path: P) -> Result<()> {
let path = self.path(&base_path);
if path.join(".git").exists() {
log::info!("{}: repository exists", path.display());
Ok(())
} else {
crate::source::prepare(self, base_path)
}
}
}
fn url_to_owner_and_repo_name(url: &str) -> (String, String) {
let items = url.split('/').collect::<Vec<_>>();
let len = items.len();
match (len.checked_sub(2).and_then(|idx| items.get(idx)), items.last()) {
(Some(&owner), Some(&name)) => (to_owner(owner), strip_dot_git(name)),
(None, Some(&name)) => ("unknown".into(), strip_dot_git(name)),
(Some(&owner), None) => (to_owner(owner), "gitignore".into()),
_ => ("unknown".into(), "gitignore".into()),
}
}
fn to_owner<S: AsRef<str>>(s: S) -> String {
let s = s.as_ref();
if s.contains(':') {
let items = s.split(':').collect::<Vec<_>>();
if let Some(&owner) = items.last() {
owner.to_string()
} else {
"unknown".to_string()
}
} else {
s.to_string()
}
}
fn strip_dot_git<S: AsRef<str>>(s: S) -> String {
let s = s.as_ref().to_string();
if let Some(name) = s.strip_suffix(".git") {
name.to_string()
} else {
s.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "local")]
#[test]
fn test_repo() {
let repo = Repository::new_with("tamada", "https://github.com/tamada/gitignore.git");
assert_eq!(repo.name, "tamada");
assert_eq!(repo.url, "https://github.com/tamada/gitignore.git");
crate::tests::prepare_once();
let base_path = crate::tests::boilerplates_path();
if let Err(e) = repo.prepare(&base_path) {
panic!("Failed to prepare the repository: {e}");
}
let boilerplates = repo.iter(&base_path).collect::<Vec<_>>();
assert_eq!(boilerplates.len(), 1);
if let Some(b) = repo.find(&Name::new_of("devcontainer"), &base_path) {
assert_eq!(
b.file_path(&base_path),
base_path.join("tamada").join("devcontainer.gitignore")
);
} else {
panic!("Failed to find the devcontainer.gitignore");
}
}
#[test]
fn test_url_to_name() {
let url = "https://github.com/github/gitignore.git";
let (owner, name) = url_to_owner_and_repo_name(url);
assert_eq!(owner, "github");
assert_eq!(name, "gitignore");
}
#[test]
fn test_url_to_name2() {
let url = "git@github.com:tamada/gitignore.git";
let (owner, name) = url_to_owner_and_repo_name(url);
assert_eq!(owner, "tamada");
assert_eq!(name, "gitignore");
}
#[test]
fn test_url_parsing_edge_cases() {
assert_eq!(url_to_owner_and_repo_name("simple-name"), ("unknown".to_string(), "simple-name".to_string()));
assert_eq!(url_to_owner_and_repo_name("owner/repo"), ("owner".to_string(), "repo".to_string()));
assert_eq!(url_to_owner_and_repo_name("https://host.com/repo.git"), ("host.com".to_string(), "repo".to_string()));
assert_eq!(to_owner("user:name"), "name".to_string());
assert_eq!(to_owner("name"), "name".to_string());
assert_eq!(to_owner(":"), "".to_string()); }
#[test]
fn test_boilerplate_content_url() {
let repo = Repository::default();
let b = Boilerplate::new("Rust".into(), "Rust.gitignore", &repo);
let result = b.content_url(".");
if let Ok(url) = result {
assert!(url.contains("raw.github.com/github/gitignore"));
}
}
}