use std::{
fs,
path::{Path, PathBuf},
process::Command,
};
use mingling::picker::{PickerArgResult, SinglePickable};
pub const DEFAULT_TMPL_SOURCE: &str = "mingling-rs/tmpl";
pub fn cache_dir() -> PathBuf {
dirs::data_local_dir()
.unwrap_or_default()
.join("mingling")
.join("cache")
}
pub fn normalize_source(source: &str) -> String {
if source.ends_with(".git") {
return source.to_string();
}
if source.contains("://") {
if let Some(rest) = source.strip_prefix("https://github.com/") {
return format!("https://github.com/{rest}.git");
}
return source.to_string();
}
if let Some((owner, repo)) = source.split_once('/') {
return format!("https://github.com/{owner}/{repo}.git");
}
source.to_string()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TemplateSource {
Git { reference: String, variant: String },
FsDir(PathBuf),
}
impl TemplateSource {
pub fn git(reference: impl Into<String>, variant: impl Into<String>) -> Self {
Self::Git {
reference: reference.into(),
variant: variant.into(),
}
}
pub fn fs_dir(path: impl Into<PathBuf>) -> Self {
Self::FsDir(path.into())
}
}
impl SinglePickable for TemplateSource {
fn pick_single(str: Option<&str>) -> PickerArgResult<Self> {
let Some(raw) = str else {
return PickerArgResult::NotFound;
};
if let Some((reference, variant)) = raw.split_once('@')
&& !reference.is_empty()
&& !variant.is_empty()
{
return PickerArgResult::Parsed(Self::git(reference, variant));
}
match <PathBuf as SinglePickable>::pick_single(str) {
PickerArgResult::Parsed(path) => PickerArgResult::Parsed(Self::FsDir(path)),
PickerArgResult::NotFound => PickerArgResult::NotFound,
PickerArgResult::Unparsed => PickerArgResult::Unparsed,
}
}
}
pub fn resolve_git(
source_url: &str,
reference: &str,
variant: &str,
cache: &Path,
) -> Result<PathBuf, String> {
let source_hash = sha256_prefix16(source_url);
let full_hash = resolve_commit(source_url, reference)?;
let ref_hash = &full_hash[..16];
let repo_dir = cache.join(source_hash).join(ref_hash);
if !repo_dir.join(".git").is_dir() {
shallow_clone(source_url, reference, &full_hash, &repo_dir)?;
}
let template_dir = repo_dir.join(variant);
if !template_dir.join("checklist.toml").is_file() {
return Err(format!(
"variant `{variant}` has no checklist.toml in repository {source_url}"
));
}
Ok(template_dir)
}
fn sha256_prefix16(input: &str) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(input.as_bytes());
let digest = hasher.finalize();
digest.iter().take(8).map(|b| format!("{b:02x}")).collect()
}
fn resolve_commit(source_url: &str, reference: &str) -> Result<String, String> {
if reference.len() == 40 && reference.chars().all(|c| c.is_ascii_hexdigit()) {
return Ok(reference.to_string());
}
let by_ref = git_ls_remote(source_url, Some(reference))?;
if let Some(hash) = by_ref
.lines()
.next()
.and_then(|line| line.split('\t').next())
{
return Ok(hash.to_string());
}
let all_refs = git_ls_remote(source_url, None)?;
for line in all_refs.lines() {
let Some(hash) = line.split('\t').next() else {
continue;
};
if hash.starts_with(reference) {
return Ok(hash.to_string());
}
}
Err(format!("reference `{reference}` not found in {source_url}"))
}
fn git_ls_remote(source_url: &str, pattern: Option<&str>) -> Result<String, String> {
let mut cmd = Command::new("git");
cmd.args(["ls-remote", source_url]);
if let Some(pattern) = pattern {
cmd.arg(pattern);
}
let output = cmd
.output()
.map_err(|e| format!("failed to run `git ls-remote`: {e}"))?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
fn shallow_clone(
source_url: &str,
reference: &str,
full_hash: &str,
dst: &Path,
) -> Result<(), String> {
if dst.exists() {
fs::remove_dir_all(dst).map_err(|e| e.to_string())?;
}
fs::create_dir_all(dst).map_err(|e| e.to_string())?;
run_git(dst, ["init", "-q"])?;
run_git(dst, ["remote", "add", "origin", source_url])?;
let fetched_by_ref = run_git(dst, ["fetch", "-q", "--depth", "1", "origin", reference]);
if fetched_by_ref.is_err() {
run_git(dst, ["fetch", "-q", "--depth", "1", "origin", full_hash])?;
}
run_git(dst, ["checkout", "-q", "-B", "cache", "FETCH_HEAD"])?;
Ok(())
}
fn run_git<const N: usize>(cwd: &Path, args: [&str; N]) -> Result<(), String> {
let status = Command::new("git")
.args(args)
.current_dir(cwd)
.status()
.map_err(|e| format!("failed to run git: {e}"))?;
if !status.success() {
return Err(format!("git command failed with {status}"));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn make_repo(dir: &Path) {
fs::create_dir_all(dir).unwrap();
run_git(dir, ["init", "-q", "-b", "main"]).unwrap();
fs::create_dir_all(dir.join("basic")).unwrap();
fs::write(
dir.join("basic").join("checklist.toml"),
"program_name = \"x\"\n",
)
.unwrap();
fs::write(dir.join("basic").join("rule.toml"), "").unwrap();
run_git(dir, ["add", "."]).unwrap();
run_git(
dir,
[
"-c",
"user.name=t",
"-c",
"user.email=t@t",
"commit",
"-q",
"-m",
"init",
],
)
.unwrap();
run_git(dir, ["tag", "v0.1"]).unwrap();
}
#[test]
fn parses_ref_at_variant_as_git() {
let source = TemplateSource::pick_single(Some("0.4@basic")).unwrap();
assert_eq!(source, TemplateSource::git("0.4", "basic"));
}
#[test]
fn parses_plain_path_as_fs_dir() {
let source = TemplateSource::pick_single(Some("/some/dir")).unwrap();
assert_eq!(source, TemplateSource::FsDir(PathBuf::from("/some/dir")));
}
#[test]
fn missing_input_is_not_found() {
assert!(matches!(
TemplateSource::pick_single(None),
PickerArgResult::NotFound
));
}
#[test]
fn empty_variant_falls_back_to_path() {
let source = TemplateSource::pick_single(Some("0.4@")).unwrap();
assert_eq!(source, TemplateSource::FsDir(PathBuf::from("0.4@")));
}
#[test]
fn sha256_prefix_is_stable_and_16_chars() {
let a = sha256_prefix16("https://example.com/repo.git");
let b = sha256_prefix16("https://example.com/repo.git");
let c = sha256_prefix16("https://example.com/other.git");
assert_eq!(a, b);
assert_eq!(a.len(), 16);
assert_ne!(a, c);
}
#[test]
fn normalize_source_default_and_configured() {
assert_eq!(
normalize_source(DEFAULT_TMPL_SOURCE),
"https://github.com/mingling-rs/tmpl.git"
);
assert_eq!(
normalize_source("https://example.com/tmpl.git"),
"https://example.com/tmpl.git"
);
assert_eq!(
normalize_source("some-one/other-tmpl"),
"https://github.com/some-one/other-tmpl.git"
);
assert_eq!(
normalize_source("https://github.com/mingling-rs/tmpl"),
"https://github.com/mingling-rs/tmpl.git"
);
}
#[test]
fn resolve_git_clones_tag_and_validates_variant() {
let tmp =
std::env::temp_dir().join(format!("mling-tmpl-src-test-{}-clone", std::process::id()));
let repo = tmp.join("repo");
let cache = tmp.join("cache");
let _ = fs::remove_dir_all(&tmp);
make_repo(&repo);
let repo_url = format!("file://{}", repo.display());
let template = resolve_git(&repo_url, "v0.1", "basic", &cache).unwrap();
assert!(template.join("checklist.toml").is_file());
let source_hash = sha256_prefix16(&repo_url);
let full_hash = resolve_commit(&repo_url, "v0.1").unwrap();
let cached = cache.join(source_hash).join(&full_hash[..16]);
assert!(cached.join(".git").is_dir());
let template_again = resolve_git(&repo_url, "v0.1", "basic", &cache).unwrap();
assert_eq!(template, template_again);
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn resolve_git_rejects_unknown_variant() {
let tmp =
std::env::temp_dir().join(format!("mling-tmpl-src-test-{}-reject", std::process::id()));
let repo = tmp.join("repo");
let cache = tmp.join("cache");
let _ = fs::remove_dir_all(&tmp);
make_repo(&repo);
let repo_url = format!("file://{}", repo.display());
let err = resolve_git(&repo_url, "v0.1", "nope", &cache).unwrap_err();
assert!(err.contains("nope"), "unexpected error: {err}");
let _ = fs::remove_dir_all(&tmp);
}
}