use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use crate::source::{GitSource, LocalSource, Source};
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Config {
#[serde(default)]
pub default_tool: String,
#[serde(default)]
sources: Vec<SourceConfig>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(tag = "type")]
pub enum SourceConfig {
#[serde(rename = "local")]
Local {
path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
},
#[serde(rename = "git")]
Git {
url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
},
}
impl Config {
pub fn new(sources: Vec<SourceConfig>) -> Self {
Config {
default_tool: "claude".to_string(),
sources,
}
}
pub fn load() -> Result<Option<Self>> {
let config_path = Self::config_path()?;
if config_path.exists() {
let content = std::fs::read_to_string(&config_path)?;
let config: Config = toml::from_str(&content)?;
Ok(Some(config))
} else {
Ok(None)
}
}
pub fn load_or_default() -> Result<Self> {
if let Some(config) = Self::load()? {
Ok(config)
} else {
Ok(Config {
default_tool: "claude".to_string(),
sources: vec![SourceConfig::Local {
path: "~/.claude-skills".to_string(),
name: None,
}],
})
}
}
pub fn save(&self) -> Result<()> {
let config_path = Self::config_path()?;
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)?;
}
let content = toml::to_string_pretty(self)?;
std::fs::write(&config_path, content)?;
Ok(())
}
pub fn config_path() -> Result<PathBuf> {
let proj_dirs = directories::ProjectDirs::from("", "", "skm")
.ok_or_else(|| anyhow::anyhow!("Could not determine config directory"))?;
Ok(proj_dirs.config_dir().join("config.toml"))
}
pub fn exists() -> Result<bool> {
let config_path = Self::config_path()?;
Ok(config_path.exists())
}
pub fn sources(&self) -> Vec<Box<dyn Source>> {
self.sources
.iter()
.filter_map(|s| match s {
SourceConfig::Local { path, .. } => {
let expanded = expand_tilde(path);
Some(Box::new(LocalSource::new(expanded)) as Box<dyn Source>)
}
SourceConfig::Git { url, .. } => match GitSource::new(url.clone()) {
Ok(source) => Some(Box::new(source) as Box<dyn Source>),
Err(e) => {
eprintln!("Warning: Could not initialize git source {}: {}", url, e);
None
}
},
})
.collect()
}
pub fn git_sources(&self) -> Vec<GitSource> {
self.sources
.iter()
.filter_map(|s| match s {
SourceConfig::Git { url, .. } => GitSource::new(url.clone()).ok(),
_ => None,
})
.collect()
}
pub fn source_configs(&self) -> &[SourceConfig] {
&self.sources
}
pub fn add_source(&mut self, source: SourceConfig) {
let exists = self.sources.iter().any(|s| match (s, &source) {
(SourceConfig::Local { path: p1, .. }, SourceConfig::Local { path: p2, .. }) => {
p1 == p2
}
(SourceConfig::Git { url: u1, .. }, SourceConfig::Git { url: u2, .. }) => u1 == u2,
_ => false,
});
if !exists {
self.sources.push(source);
}
}
pub fn move_source(&mut self, from: usize, to: usize) -> Result<()> {
if from >= self.sources.len() || to >= self.sources.len() {
anyhow::bail!("Invalid source index");
}
let source = self.sources.remove(from);
self.sources.insert(to, source);
Ok(())
}
pub fn remove_source(&mut self, path_or_url: &str) -> bool {
let initial_len = self.sources.len();
let input_expanded = expand_tilde(path_or_url);
self.sources.retain(|s| match s {
SourceConfig::Local { path, name } => {
path != path_or_url
&& expand_tilde(path) != input_expanded
&& name.as_deref() != Some(path_or_url)
}
SourceConfig::Git { url, name } => {
url != path_or_url && name.as_deref() != Some(path_or_url)
}
});
self.sources.len() < initial_len
}
pub fn find_bundle(
&self,
name: &str,
) -> Result<Option<(Box<dyn Source>, crate::bundle::Bundle)>> {
for source in self.sources() {
let bundles = match source.list_bundles() {
Ok(b) => b,
Err(_) => continue,
};
if let Some(bundle) = bundles.into_iter().find(|b| b.name == name) {
return Ok(Some((source, bundle)));
}
}
Ok(None)
}
pub fn find_bundle_by_prefix(
&self,
installed_name: &str,
) -> Result<Option<crate::bundle::Bundle>> {
let mut best_match: Option<crate::bundle::Bundle> = None;
let mut best_len = 0;
for source in self.sources() {
let bundles = match source.list_bundles() {
Ok(b) => b,
Err(_) => continue,
};
for bundle in bundles {
let prefix = format!("{}-", bundle.name);
if installed_name.starts_with(&prefix) && bundle.name.len() > best_len {
best_len = bundle.name.len();
best_match = Some(bundle);
}
}
}
Ok(best_match)
}
pub fn find_source_by_name(&self, name: &str) -> Option<(Box<dyn Source>, &SourceConfig)> {
for source_config in &self.sources {
if source_config.name() == Some(name) {
let source: Option<Box<dyn Source>> = match source_config {
SourceConfig::Local { path, .. } => {
let expanded = expand_tilde(path);
Some(Box::new(LocalSource::new(expanded)))
}
SourceConfig::Git { url, .. } => GitSource::new(url.clone())
.ok()
.map(|s| Box::new(s) as Box<dyn Source>),
};
if let Some(source) = source {
return Some((source, source_config));
}
}
}
None
}
}
impl SourceConfig {
pub fn display(&self) -> &str {
match self {
SourceConfig::Local { path, .. } => path,
SourceConfig::Git { url, .. } => url,
}
}
pub fn name(&self) -> Option<&str> {
match self {
SourceConfig::Local { name, .. } => name.as_deref(),
SourceConfig::Git { name, .. } => name.as_deref(),
}
}
}
fn expand_tilde(path: &str) -> PathBuf {
if path.starts_with("~/") {
if let Some(home) = dirs_home() {
return home.join(&path[2..]);
}
} else if path == "~" {
if let Some(home) = dirs_home() {
return home;
}
}
PathBuf::from(path)
}
fn dirs_home() -> Option<PathBuf> {
std::env::var_os("HOME").map(PathBuf::from)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_expand_tilde() {
let home = std::env::var("HOME").unwrap();
assert_eq!(
expand_tilde("~/.claude-skills"),
PathBuf::from(format!("{}/.claude-skills", home))
);
assert_eq!(
expand_tilde("/absolute/path"),
PathBuf::from("/absolute/path")
);
}
#[test]
fn test_default_config() {
let config = Config::load_or_default().unwrap();
assert_eq!(config.default_tool, "claude");
assert!(!config.sources.is_empty());
}
}