use std::path::Path;
use std::process::Command;
use std::time::Duration;
use crate::spawn::SpawnOutcome;
pub(crate) fn normalize_git_url(raw: &str) -> String {
let mut s = raw.trim().to_string();
if let Some(rest) = s.strip_prefix("git@github.com:") {
s = format!("https://github.com/{rest}");
} else if let Some(rest) = s.strip_prefix("git@gitlab.com:") {
s = format!("https://gitlab.com/{rest}");
}
s = s.trim_end_matches('/').to_string();
if let Some(stripped) = s.strip_suffix(".git") {
s = stripped.to_string();
}
s
}
pub(crate) fn urls_equivalent(a: &str, b: &str) -> bool {
if a == b {
return true;
}
normalize_git_url(a) == normalize_git_url(b)
}
pub(crate) fn detect_repo_url(root: &Path) -> Option<String> {
let mut cmd = Command::new("git");
cmd.args(["remote", "get-url", "origin"]).current_dir(root);
match crate::spawn::run(&mut cmd, Duration::from_secs(3)) {
SpawnOutcome::RanClean(out) => {
let s = out.trim();
if s.is_empty() {
None
} else {
Some(s.to_string())
}
}
_ => None,
}
}
pub(crate) fn detect_author(root: &Path) -> Option<String> {
let mut cmd = Command::new("git");
cmd.args(["config", "user.name"]).current_dir(root);
match crate::spawn::run(&mut cmd, Duration::from_secs(3)) {
SpawnOutcome::RanClean(out) => {
let s = out.trim().to_string();
if s.is_empty() {
None
} else {
Some(s)
}
}
_ => None,
}
}
pub(crate) fn detect_license(root: &Path) -> Option<String> {
for filename in &["LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING"] {
let p = root.join(filename);
if let Ok(raw) = std::fs::read_to_string(&p) {
let head = raw.split('\n').take(3).collect::<Vec<_>>().join("\n");
let lower = head.to_lowercase();
if lower.contains("mit license") || lower.contains("permission is hereby granted") {
return Some("MIT".to_string());
}
if lower.contains("apache license") {
return Some("Apache-2.0".to_string());
}
if lower.contains("bsd 3-clause") || lower.contains("neither the name") {
return Some("BSD-3-Clause".to_string());
}
if lower.contains("gnu general public license") {
return Some("GPL-3.0".to_string());
}
}
}
None
}
const README_HEAD_LINES: usize = 500;
fn is_markup_only(line: &str) -> bool {
static RE: std::sync::OnceLock<[regex::Regex; 3]> = std::sync::OnceLock::new();
let [img, link, html] = RE.get_or_init(|| {
[
regex::Regex::new(r"!\[[^\]]*\]\([^)]*\)").unwrap(),
regex::Regex::new(r"\[[^\]]*\]\([^)]*\)").unwrap(),
regex::Regex::new(r"<[^>]*>").unwrap(),
]
});
let mut s = line.to_string();
s = img.replace_all(&s, "").into_owned();
s = link.replace_all(&s, "").into_owned();
s = html.replace_all(&s, "").into_owned();
!s.chars().any(|c| c.is_ascii_alphabetic())
}
pub(crate) fn read_readme_hint(root: &Path) -> Option<String> {
for filename in &["README.md", "README", "readme.md"] {
let p = root.join(filename);
if let Ok(raw) = std::fs::read_to_string(&p) {
let head: String = raw
.lines()
.take(README_HEAD_LINES)
.collect::<Vec<_>>()
.join("\n");
let raw_lines: Vec<&str> = head
.lines()
.skip_while(|l| {
let t = l.trim();
t.is_empty()
|| t.starts_with('#')
|| t.starts_with('!')
|| t.starts_with('<')
|| is_markup_only(t)
})
.take_while(|l| !l.trim().is_empty())
.collect();
let paragraph = raw_lines
.iter()
.map(|l| {
let mut t = l.trim();
while let Some(stripped) = t.strip_prefix('>') {
t = stripped.trim();
}
t
})
.filter(|l| !l.is_empty())
.collect::<Vec<_>>()
.join(" ");
let trimmed = paragraph.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
}
None
}
pub(crate) fn repo_url_name(repo_url: &Option<String>) -> Option<String> {
let url = repo_url.as_ref()?;
let last = url.rsplit('/').next()?.trim_end();
let stem = last.strip_suffix(".git").unwrap_or(last);
Some(stem.to_string())
}
#[cfg(test)]
mod tests {
use super::read_readme_hint;
#[test]
fn read_readme_hint_skips_leading_html_div() {
let dir = std::env::temp_dir().join(format!(
"skillpack-readme-html-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("README.md"),
"<div align=\"center\"><img src=\"logo.png\" alt=\"logo\"></div>\n\n\
# mytool\n\n\
A sample tool that frobs widgets.\n",
)
.unwrap();
let hint = read_readme_hint(&dir).unwrap_or_default();
let _ = std::fs::remove_dir_all(&dir);
assert!(
!hint.contains('<'),
"desc_hint must drop raw HTML, got: {hint:?}"
);
assert!(
hint.contains("frobs widgets"),
"desc_hint must land on first prose line, got: {hint:?}"
);
}
#[test]
fn read_readme_hint_skips_badge_rows() {
let dir = std::env::temp_dir().join(format!(
"skillpack-readme-badges-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("README.md"),
"# fd\n\n\
[](https://github.com/sharkdp/fd/actions/workflows/CICD.yml) \
[](https://crates.io/crates/fd-find) \
[[中文](https://github.com/cha0ran/fd-zh)] [[한국어](https://github.com/spearkkk/fd-kor)]\n\n\
fd is a simple, fast and user-friendly alternative to find.\n",
)
.unwrap();
let hint = read_readme_hint(&dir).unwrap_or_default();
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(
hint, "fd is a simple, fast and user-friendly alternative to find.",
"desc_hint must skip badge rows and land on prose, got: {hint:?}"
);
}
#[test]
fn read_readme_hint_strips_blockquotes() {
let dir = std::env::temp_dir().join(format!(
"skillpack-readme-quote-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("README.md"),
"# Ashen Ledger\n\n\
> Rule a starving post-collapse city through its public ledger, while every\n\
> citizen, faction, rumor, shipment, law, and betrayal leaves a trace in a\n\
> living historical record.\n",
)
.unwrap();
let hint = read_readme_hint(&dir).unwrap_or_default();
let _ = std::fs::remove_dir_all(&dir);
assert!(
hint.starts_with("Rule a starving"),
"desc_hint must strip leading blockquote marker, got: {hint:?}"
);
assert!(!hint.contains('>'));
}
}