use anyhow::{Context, Result};
use colored::Colorize;
use std::path::PathBuf;
use crate::bundle::Bundle;
pub trait Source {
fn list_bundles(&self) -> Result<Vec<Bundle>>;
fn display_path(&self) -> String;
}
pub struct LocalSource {
path: PathBuf,
}
impl LocalSource {
pub fn new(path: PathBuf) -> Self {
LocalSource { path }
}
}
impl LocalSource {
fn list_bundles_from_manifest(
&self,
manifest: crate::manifest::SourceManifest,
) -> Result<Vec<Bundle>> {
let mut bundles = Vec::new();
for decl in &manifest.bundles {
let bundle_root = self.path.join(&decl.path);
if !bundle_root.exists() {
eprintln!(
" {}: bundle path {} does not exist",
"Warning".yellow(),
decl.path
);
continue;
}
match crate::manifest::bundle_from_declaration(&self.path, decl) {
Ok(bundle) if !bundle.is_empty() => bundles.push(bundle),
Ok(_) => {
}
Err(e) => {
eprintln!(
" {}: failed to scan bundle {}: {}",
"Warning".yellow(),
decl.name,
e
);
}
}
}
Ok(bundles)
}
}
impl Source for LocalSource {
fn list_bundles(&self) -> Result<Vec<Bundle>> {
if !self.path.exists() {
return Ok(vec![]);
}
if let Some(manifest) = crate::manifest::load_manifest(&self.path) {
return self.list_bundles_from_manifest(manifest);
}
if Bundle::is_resources_format(&self.path) {
return Bundle::list_from_resources_path(self.path.clone());
}
if Bundle::is_anthropic_format(&self.path) {
return Bundle::list_from_anthropic_path(self.path.clone());
}
let mut bundles = vec![];
for entry in std::fs::read_dir(&self.path)? {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name.starts_with('.') || name == "shell" {
continue;
}
match Bundle::from_path(path) {
Ok(bundle) if !bundle.is_empty() => bundles.push(bundle),
_ => continue,
}
}
bundles.sort_by(|a, b| a.name.cmp(&b.name));
Ok(bundles)
}
fn display_path(&self) -> String {
if let Some(home) = std::env::var_os("HOME") {
let home_path = PathBuf::from(home);
if let Ok(relative) = self.path.strip_prefix(&home_path) {
return format!("~/{}", relative.display());
}
}
self.path.display().to_string()
}
}
pub struct GitSource {
url: String,
cache_path: PathBuf,
}
impl GitSource {
pub fn new(url: String) -> Result<Self> {
let cache_path = Self::cache_path_for_url(&url)?;
Ok(GitSource { url, cache_path })
}
fn cache_path_for_url(url: &str) -> Result<PathBuf> {
let cache_dir = directories::ProjectDirs::from("", "", "skm")
.ok_or_else(|| anyhow::anyhow!("Could not determine cache directory"))?
.cache_dir()
.to_path_buf();
let path_suffix = Self::url_to_path(url);
Ok(cache_dir.join(path_suffix))
}
fn url_to_path(url: &str) -> String {
let url = url.trim_end_matches(".git");
if url.starts_with("https://") {
url.strip_prefix("https://").unwrap_or(url).to_string()
} else if url.starts_with("git@") {
url.strip_prefix("git@").unwrap_or(url).replace(':', "/")
} else {
url.to_string()
}
}
pub fn ensure_cloned(&self) -> Result<()> {
if self.cache_path.exists() {
return Ok(());
}
println!(" {} {}...", "Cloning".cyan(), self.url);
if let Some(parent) = self.cache_path.parent() {
std::fs::create_dir_all(parent)?;
}
git2::Repository::clone(&self.url, &self.cache_path)
.with_context(|| format!("Failed to clone {}", self.url))?;
Ok(())
}
pub fn url(&self) -> &str {
&self.url
}
pub fn pull(&self) -> Result<bool> {
if !self.cache_path.exists() {
self.ensure_cloned()?;
return Ok(true);
}
let repo = git2::Repository::open(&self.cache_path)
.with_context(|| format!("Failed to open repository at {:?}", self.cache_path))?;
let mut remote = repo.find_remote("origin")?;
remote.fetch(&["HEAD"], None, None)?;
let fetch_head = repo.find_reference("FETCH_HEAD")?;
let fetch_commit = repo.reference_to_annotated_commit(&fetch_head)?;
let head = repo.head()?;
let head_commit = head.peel_to_commit()?;
if fetch_commit.id() == head_commit.id() {
return Ok(false);
}
let refname = head.name().unwrap_or("HEAD");
repo.reference(refname, fetch_commit.id(), true, "Fast-forward")?;
repo.set_head(refname)?;
repo.checkout_head(Some(git2::build::CheckoutBuilder::default().force()))?;
Ok(true)
}
}
impl Source for GitSource {
fn list_bundles(&self) -> Result<Vec<Bundle>> {
self.ensure_cloned()?;
let local = LocalSource::new(self.cache_path.clone());
local.list_bundles()
}
fn display_path(&self) -> String {
self.url.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn test_local_source_empty_dir() {
let dir = tempdir().unwrap();
let source = LocalSource::new(dir.path().to_path_buf());
let bundles = source.list_bundles().unwrap();
assert!(bundles.is_empty());
}
#[test]
fn test_local_source_with_bundle() {
let dir = tempdir().unwrap();
let bundle_dir = dir.path().join("test-bundle");
let commands_dir = bundle_dir.join("commands");
fs::create_dir_all(&commands_dir).unwrap();
fs::write(commands_dir.join("test.md"), "# Test command").unwrap();
let source = LocalSource::new(dir.path().to_path_buf());
let bundles = source.list_bundles().unwrap();
assert_eq!(bundles.len(), 1);
assert_eq!(bundles[0].name, "test-bundle");
assert_eq!(bundles[0].commands.len(), 1);
assert_eq!(bundles[0].commands[0].name, "test");
}
#[test]
fn test_local_source_skips_hidden_and_shell() {
let dir = tempdir().unwrap();
let hidden = dir.path().join(".hidden");
fs::create_dir_all(hidden.join("commands")).unwrap();
fs::write(hidden.join("commands/test.md"), "# Test").unwrap();
let shell = dir.path().join("shell");
fs::create_dir_all(&shell).unwrap();
fs::write(shell.join("skim.bash"), "# Shell script").unwrap();
let source = LocalSource::new(dir.path().to_path_buf());
let bundles = source.list_bundles().unwrap();
assert!(bundles.is_empty());
}
#[test]
fn test_local_source_resources_format() {
let dir = tempdir().unwrap();
let resources = dir.path().join("resources");
let skills_dir = resources.join("skills");
let skill1 = skills_dir.join("my-skill");
fs::create_dir_all(&skill1).unwrap();
fs::write(skill1.join("meta.yaml"), "name: My Skill\nauthor: test\n").unwrap();
fs::write(skill1.join("skill.md"), "# Skill content").unwrap();
let skill2 = skills_dir.join("another-skill");
fs::create_dir_all(&skill2).unwrap();
fs::write(
skill2.join("meta.yaml"),
"name: Another Skill\nauthor: test\n",
)
.unwrap();
fs::write(skill2.join("skill.md"), "# Another skill").unwrap();
let source = LocalSource::new(dir.path().to_path_buf());
let bundles = source.list_bundles().unwrap();
assert_eq!(bundles.len(), 2);
assert_eq!(bundles[0].name, "Another Skill");
assert_eq!(bundles[1].name, "My Skill");
}
}