use std::collections::HashMap;
use std::error::Error;
use std::fs;
use std::hash::{Hash, Hasher};
use std::io::Write;
use std::path::PathBuf;
use clap::parser::ValuesRef;
use colored::Colorize;
use triple_accel::levenshtein_exp;
use walkdir::WalkDir;
const TEMPLATES_ROOT_DIR: &str = "gign";
const TEMPLATES_REPO: &str = "https://github.com/github/gitignore.git";
const TEMPLATES_REPO_DIR: &str = "default";
pub struct TemplateEntry {
prefix: String,
name: String,
template: Option<String>,
path: PathBuf,
}
impl TemplateEntry {
pub fn new(prefix: String, name: String, path: PathBuf) -> TemplateEntry {
TemplateEntry {
prefix,
name,
template: None,
path,
}
}
pub fn name(&self) -> &str {
&self.name
}
pub fn prefix(&self) -> &str {
&self.prefix
}
pub fn with_template(&self) -> Result<Self, Box<dyn Error>> {
let template = fs::read_to_string(&self.path)?;
Ok(TemplateEntry {
prefix: self.prefix.clone(),
name: self.name.clone(),
template: Some(template),
path: self.path.clone(),
})
}
pub fn template(&self) -> Option<&String> {
match &self.template {
Some(template) => Some(template),
None => None,
}
}
pub fn title(&self) -> String {
if self.prefix.is_empty() {
self.name.clone()
} else {
format!("{}:{}", self.prefix(), self.name())
}
}
pub fn title_colored(&self) -> String {
if self.prefix.is_empty() {
self.name.clone()
} else {
let colored_prefix = {
let prefix = self.prefix();
let mut hasher = std::collections::hash_map::DefaultHasher::new();
prefix.hash(&mut hasher);
let hash = hasher.finish();
if hash % 5 == 0 {
prefix.green()
} else if hash % 4 == 0 {
prefix.yellow()
} else if hash % 3 == 0 {
prefix.cyan()
} else if hash % 2 == 0 {
prefix.magenta()
} else {
prefix.blue()
}
};
format!("{}{}{}", colored_prefix, ":".magenta(), self.name())
}
}
pub fn to_string(&self) -> String {
let hashes = "#".repeat(self.name().len() + 4);
let name = self.name();
let template = self.template().unwrap();
format!("
{hashes}
# {} #
{hashes}
{}
", name, template)
}
}
pub fn get_templates() -> Result<HashMap<String, TemplateEntry>, Box<dyn Error>> {
match get_app_dir() {
Some(path) => {
if !path.exists() {
return Err(Box::new(std::io::Error::new(
std::io::ErrorKind::NotFound,
"templates path does not exist",
)));
}
let entries: Vec<TemplateEntry> = WalkDir::new(path)
.into_iter()
.filter_entry(|e| {
if e.file_type().is_file() || e.file_type().is_symlink() {
return e.path().extension() == Some("gitignore".as_ref());
}
!e.path().ends_with(".git") &&
!e.path().ends_with(".github")
})
.filter_map(|e| match e {
Ok(e) => if e.file_type().is_file() { Some(e) } else { None },
Err(_) => None
}
)
.map(|e| {
let name = e.file_name().to_string_lossy().trim_end_matches(".gitignore").to_string();
let prefix = e.path().parent().unwrap().file_name().unwrap().to_string_lossy().to_string();
if prefix == TEMPLATES_ROOT_DIR {
TemplateEntry::new("".to_string(), name, e.path().to_path_buf())
} else {
TemplateEntry::new(prefix.to_lowercase(), name, e.path().to_path_buf())
}
})
.collect();
let mut hash_map = HashMap::new();
for entry in entries {
if hash_map.contains_key(entry.title().as_str()) {
return Err(Box::new(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
format!("duplicate template name: {}", entry.name()),
)));
}
hash_map.insert(entry.title(), entry);
}
Ok(hash_map)
}
None => {
Err(Box::new(std::io::Error::new(
std::io::ErrorKind::NotFound,
"could not find the application directory",
)))
}
}
}
pub fn find_closest<'a>(target: &str, templates: Vec<&'a TemplateEntry>) -> Option<&'a TemplateEntry> {
let mut closest_distance = u32::MAX;
let mut closest_template = None;
let target = if target.contains(":") {
target.split(":").last().unwrap()
} else {
target
};
for template in templates {
let distance = levenshtein_exp(target.to_lowercase().as_ref(), template.name().to_lowercase().as_ref());
if distance < closest_distance && distance < 10 {
closest_distance = distance;
closest_template = Some(template);
}
}
if let Some(entry) = closest_template {
return Some(entry);
}
None
}
pub fn generate_gitignore(mut templates: ValuesRef<String>, strict: bool) -> Result<String, Box<dyn Error>> {
match get_templates() {
Ok(available_templates) => {
let res = templates.try_fold(
"".to_string(),
|acc, name| {
if available_templates.contains_key(name) {
let template = available_templates.get(name).unwrap();
return Ok(format!("{}{}", acc, template.with_template().unwrap().to_string()));
}
let available_templates: Vec<&TemplateEntry> = available_templates
.values()
.collect();
let closest = find_closest(name, available_templates);
if let Some(closest) = closest {
if !strict {
return Ok(format!("{}{}", acc, closest.with_template().unwrap().to_string()));
}
return {
let message = format!(
"template '{}' not found, did you mean '{}'?",
name,
closest.title_colored(),
);
Err(Box::new(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
message,
)))
};
}
if !strict {
return Ok(acc);
}
Err(Box::new(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("template '{}' not found", name),
)))
},
)?;
Ok(res.trim_matches('\n').to_string())
}
Err(err) => Err(err)
}
}
pub fn get_app_dir() -> Option<PathBuf> {
match dirs::config_dir() {
Some(path) => Some(path.join(TEMPLATES_ROOT_DIR)),
None => None
}
}
pub fn clone_templates_repo() -> Result<PathBuf, Box<dyn Error>> {
match get_app_dir() {
Some(path) => {
if !command_is_available("git") {
return Err(Box::new(std::io::Error::new(
std::io::ErrorKind::NotFound,
"Git is not installed",
)));
}
let target_path = path.join(TEMPLATES_REPO_DIR);
let output = std::process::Command::new("git")
.arg("clone")
.arg(TEMPLATES_REPO)
.arg(target_path.to_str().unwrap())
.output()?;
if !output.status.success() {
return Err(Box::new(std::io::Error::new(
std::io::ErrorKind::Other,
"failed to clone templates repository",
)));
}
Ok(target_path)
}
None => Err(Box::new(std::io::Error::new(
std::io::ErrorKind::NotFound,
"could not find app directory",
)))
}
}
pub fn pull_templates_repo() -> Result<PathBuf, Box<dyn Error>> {
match get_app_dir() {
Some(path) => {
let target_path = path.join(TEMPLATES_REPO_DIR);
if !target_path.exists() {
clone_templates_repo()?;
return Ok(target_path);
}
let output = std::process::Command::new("git")
.arg("-C")
.arg(target_path.to_str().unwrap())
.arg("pull")
.arg("origin")
.arg("main")
.output()?;
if !output.status.success() {
return Err(Box::new(std::io::Error::new(
std::io::ErrorKind::Other,
"failed to pull templates repository",
)));
}
Ok(target_path)
}
None => Err(Box::new(std::io::Error::new(
std::io::ErrorKind::NotFound,
"could not find app directory",
)))
}
}
pub fn init_default_templates() -> Result<(), Box<dyn Error>> {
match get_app_dir() {
Some(path) => {
if !path.exists() {
fs::create_dir_all(path)?;
if let Err(err) = clone_templates_repo() {
warning(err.to_string().as_str());
}
}
Ok(())
}
None => Err(Box::new(std::io::Error::new(
std::io::ErrorKind::NotFound,
"could not find app directory",
)))
}
}
pub fn command_is_available(name: &str) -> bool {
let output = std::process::Command::new("which")
.arg(name)
.output()
.ok();
output.is_some()
}
pub fn error(msg: &str) {
eprintln!("{}: {}", "error".red().bold(), msg);
std::process::exit(1);
}
fn warning(msg: &str) {
eprintln!("{}: {}", "warning".yellow().bold(), msg);
}
fn is_git_repo(path: &PathBuf) -> Result<bool, Box<dyn Error>> {
let output = std::process::Command::new("git")
.arg("-C")
.arg(path.to_str().unwrap())
.arg("rev-parse")
.arg("--is-inside-work-tree")
.output()?;
Ok(output.status.success())
}
fn get_git_root(path: &PathBuf) -> Result<PathBuf, Box<dyn Error>> {
let output = std::process::Command::new("git")
.arg("-C")
.arg(path.to_str().unwrap())
.arg("rev-parse")
.arg("--show-toplevel")
.output()?;
if !output.status.success() {
return Err(Box::new(std::io::Error::new(
std::io::ErrorKind::Other,
"failed to get git root",
)));
}
let root = String::from_utf8(output.stdout)?;
Ok(PathBuf::from(root.trim()))
}
pub fn append_to_gitignore(path: &PathBuf, template: &str) -> Result<PathBuf, Box<dyn Error>> {
if !is_git_repo(path)? {
return Err(Box::new(std::io::Error::new(
std::io::ErrorKind::Other,
"not a git repository",
)));
}
let root = get_git_root(path)?;
let gitignore = root.join(".gitignore");
let file = {
if gitignore.exists() {
fs::OpenOptions::new()
.append(true)
.open(&gitignore)
} else {
fs::OpenOptions::new()
.write(true)
.create(true)
.open(&gitignore)
}
};
match file {
Ok(mut f) => {
if let Err(e) = writeln!(f, "\n{}", template) {
return Err(Box::new(std::io::Error::new(
e.kind(),
e.to_string(),
)));
}
Ok(gitignore)
}
Err(err) => Err(Box::new(err)),
}
}