use chrono::Utc;
use std::path::Path;
use std::process::Command;
use super::cache::{CachedPlugin, PluginCache};
use super::{log_plugin_operation, PluginError, PluginSource, Result};
#[derive(Debug)]
pub struct PluginFetcher {
verbose: bool,
}
impl Default for PluginFetcher {
fn default() -> Self {
Self::new()
}
}
impl PluginFetcher {
pub fn new() -> Self {
Self { verbose: false }
}
pub fn with_verbose(verbose: bool) -> Self {
Self { verbose }
}
pub fn check_git_available() -> Result<()> {
let output = Command::new("git").arg("--version").output();
match output {
Ok(output) if output.status.success() => Ok(()),
_ => Err(PluginError::GitNotInstalled),
}
}
pub fn fetch(
&self,
source: &PluginSource,
cache: &PluginCache,
force_update: bool,
) -> Result<CachedPlugin> {
let url = source
.url
.as_ref()
.ok_or_else(|| PluginError::CloneFailed {
url: source.name.clone(),
message: "No URL provided for plugin".to_string(),
})?;
if source.is_local_path() {
return self.fetch_local(source, url);
}
Self::check_git_available()?;
let cache_path = cache.url_to_cache_path(url);
if cache_path.exists() {
if force_update {
log_plugin_operation("update", &format!("Updating {}", source.name), self.verbose);
self.update_plugin(url, &cache_path, source.git_ref.as_deref())?;
} else {
log_plugin_operation(
"cache hit",
&format!("Using cached {}", source.name),
self.verbose,
);
}
} else {
log_plugin_operation("clone", &format!("Cloning {}", url), self.verbose);
self.clone_plugin(url, &cache_path, source.git_ref.as_deref())?;
}
let commit_hash = self.get_local_commit_hash(&cache_path);
let now = Utc::now();
let plugin = CachedPlugin {
name: source.name.clone(),
url: url.clone(),
git_ref: source.git_ref.clone(),
commit_hash,
cached_at: now,
last_updated: now,
cache_path,
};
cache.save_cache_metadata(&plugin)?;
Ok(plugin)
}
fn fetch_local(&self, source: &PluginSource, path: &str) -> Result<CachedPlugin> {
let local_path = std::path::PathBuf::from(path);
let resolved_path = if local_path.is_relative() {
std::env::current_dir()
.map_err(|e| PluginError::CacheError {
message: format!("Failed to get current directory: {}", e),
})?
.join(&local_path)
} else {
local_path
};
let canonical_path =
std::fs::canonicalize(&resolved_path).map_err(|e| PluginError::CacheError {
message: format!("Local plugin path '{}' not found: {}", path, e),
})?;
let manifest_path = canonical_path.join("linthis-plugin.toml");
if !manifest_path.exists() {
return Err(PluginError::InvalidManifest {
path: manifest_path,
message: "linthis-plugin.toml not found in local plugin directory".to_string(),
});
}
log_plugin_operation(
"local",
&format!("Using local plugin at {}", canonical_path.display()),
self.verbose,
);
let commit_hash = self.get_local_commit_hash(&canonical_path);
let now = Utc::now();
Ok(CachedPlugin {
name: source.name.clone(),
url: path.to_string(),
git_ref: source.git_ref.clone(),
commit_hash,
cached_at: now,
last_updated: now,
cache_path: canonical_path,
})
}
pub fn clone_plugin(&self, url: &str, target_path: &Path, git_ref: Option<&str>) -> Result<()> {
if let Some(parent) = target_path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut cmd = Command::new("git");
cmd.arg("clone")
.arg("--depth")
.arg("1")
.arg("--single-branch");
if let Some(ref_name) = git_ref {
cmd.arg("--branch").arg(ref_name);
}
cmd.arg(url).arg(target_path);
log_plugin_operation(
"git",
&format!("git clone --depth 1 {} {:?}", url, target_path),
self.verbose,
);
let output = cmd.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if url.starts_with("https://") {
let ssh_url = https_to_ssh_url(url);
log_plugin_operation(
"git",
&format!("HTTPS clone failed, trying SSH: {}", ssh_url),
true,
);
let _ = std::fs::remove_dir_all(target_path);
match self.clone_plugin(&ssh_url, target_path, git_ref) {
Ok(()) => return Ok(()),
Err(_ssh_err) => {
return Err(PluginError::CloneFailed {
url: url.to_string(),
message: format!(
"HTTPS failed: {}\nSSH ({}) also failed: {}",
stderr.trim(),
ssh_url,
_ssh_err
),
});
}
}
}
return Err(PluginError::CloneFailed {
url: url.to_string(),
message: stderr.to_string(),
});
}
if let Some(ref_name) = git_ref {
if self.looks_like_commit_hash(ref_name) {
self.checkout_commit(target_path, ref_name)?;
}
}
Ok(())
}
pub fn update_plugin(&self, url: &str, cache_path: &Path, git_ref: Option<&str>) -> Result<()> {
let mut cmd = Command::new("git");
cmd.current_dir(cache_path)
.arg("fetch")
.arg("--depth")
.arg("1");
if let Some(ref_name) = git_ref {
cmd.arg("origin").arg(ref_name);
}
log_plugin_operation("git", "git fetch --depth 1", self.verbose);
let output = cmd.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(PluginError::UpdateFailed {
name: url.to_string(),
message: stderr.to_string(),
});
}
let mut reset_cmd = Command::new("git");
reset_cmd.current_dir(cache_path).arg("reset").arg("--hard");
if let Some(ref_name) = git_ref {
if self.looks_like_commit_hash(ref_name) {
reset_cmd.arg(ref_name);
} else {
reset_cmd.arg(format!("origin/{}", ref_name));
}
} else {
reset_cmd.arg("origin/HEAD");
}
log_plugin_operation("git", "git reset --hard", self.verbose);
let reset_output = reset_cmd.output()?;
if !reset_output.status.success() {
let stderr = String::from_utf8_lossy(&reset_output.stderr);
return Err(PluginError::UpdateFailed {
name: url.to_string(),
message: stderr.to_string(),
});
}
Ok(())
}
fn checkout_commit(&self, repo_path: &Path, commit: &str) -> Result<()> {
let fetch_output = Command::new("git")
.current_dir(repo_path)
.arg("fetch")
.arg("--depth")
.arg("1")
.arg("origin")
.arg(commit)
.output()?;
if !fetch_output.status.success() {
log_plugin_operation(
"git",
"Commit fetch failed, trying checkout anyway",
self.verbose,
);
}
let checkout_output = Command::new("git")
.current_dir(repo_path)
.arg("checkout")
.arg(commit)
.output()?;
if !checkout_output.status.success() {
let stderr = String::from_utf8_lossy(&checkout_output.stderr);
return Err(PluginError::CloneFailed {
url: commit.to_string(),
message: format!("Failed to checkout commit: {}", stderr),
});
}
Ok(())
}
fn looks_like_commit_hash(&self, s: &str) -> bool {
s.len() >= 7 && s.len() <= 40 && s.chars().all(|c| c.is_ascii_hexdigit())
}
pub fn check_network_available(&self, url: &str) -> bool {
let output = Command::new("git")
.arg("ls-remote")
.arg("--exit-code")
.arg("--heads")
.arg(url)
.arg("HEAD")
.output();
match output {
Ok(output) => output.status.success(),
Err(_) => false,
}
}
pub fn get_local_commit_hash(&self, cache_path: &Path) -> Option<String> {
let output = Command::new("git")
.current_dir(cache_path)
.arg("rev-parse")
.arg("HEAD")
.output()
.ok()?;
if output.status.success() {
let hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !hash.is_empty() {
return Some(hash);
}
}
None
}
pub fn has_updates(&self, cache_path: &Path, url: &str, git_ref: Option<&str>) -> bool {
if !cache_path.exists() {
return false;
}
let local_hash = match self.get_local_commit_hash(cache_path) {
Some(hash) => hash,
None => return false,
};
let remote_hash = match self.get_remote_commit_hash(url, git_ref) {
Some(hash) => hash,
None => return false,
};
local_hash != remote_hash
}
pub fn get_remote_commit_hash(&self, url: &str, git_ref: Option<&str>) -> Option<String> {
let ref_name = git_ref.unwrap_or("HEAD");
let output = Command::new("git")
.arg("ls-remote")
.arg(url)
.arg(ref_name)
.output()
.ok()?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
if let Some(hash) = stdout.split_whitespace().next() {
if hash.len() >= 7 && hash.chars().all(|c| c.is_ascii_hexdigit()) {
return Some(hash.to_string());
}
}
}
None
}
pub fn check_for_updates(
&self,
source: &PluginSource,
cache: &PluginCache,
) -> Option<(String, String)> {
let url = source.url.as_ref()?;
let cache_path = cache.url_to_cache_path(url);
if !cache_path.exists() {
return None;
}
let local_hash = self.get_local_commit_hash(&cache_path)?;
let remote_hash = self.get_remote_commit_hash(url, source.git_ref.as_deref())?;
if local_hash != remote_hash {
Some((local_hash, remote_hash))
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_looks_like_commit_hash() {
let fetcher = PluginFetcher::new();
assert!(fetcher.looks_like_commit_hash("abc1234"));
assert!(fetcher.looks_like_commit_hash("abc1234567890abcdef1234567890abcdef1234")); assert!(!fetcher.looks_like_commit_hash("main"));
assert!(!fetcher.looks_like_commit_hash("v1.0.0"));
assert!(!fetcher.looks_like_commit_hash("abc")); }
#[test]
fn test_git_available() {
let result = PluginFetcher::check_git_available();
let _ = result;
}
#[test]
fn test_https_to_ssh_url() {
assert_eq!(
super::https_to_ssh_url("https://github.com/user/repo.git"),
"git@github.com:user/repo.git"
);
assert_eq!(
super::https_to_ssh_url("https://gitlab.example.com/team/config.git"),
"git@gitlab.example.com:team/config.git"
);
assert_eq!(
super::https_to_ssh_url("git@github.com:user/repo.git"),
"git@github.com:user/repo.git"
);
}
}
fn https_to_ssh_url(url: &str) -> String {
if let Some(rest) = url.strip_prefix("https://") {
if let Some(slash_pos) = rest.find('/') {
let host = &rest[..slash_pos];
let path = &rest[slash_pos + 1..];
return format!("git@{}:{}", host, path);
}
}
url.to_string()
}