use crate::error::{Result, SkillcError};
use serde::Deserialize;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use strum::EnumProperty;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Tokenizer {
#[default]
Ascii,
Cjk,
}
impl FromStr for Tokenizer {
type Err = ();
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"ascii" => Ok(Tokenizer::Ascii),
"cjk" => Ok(Tokenizer::Cjk),
_ => Err(()),
}
}
}
impl Tokenizer {
pub fn as_str(&self) -> &'static str {
match self {
Tokenizer::Ascii => "ascii",
Tokenizer::Cjk => "cjk",
}
}
}
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
clap::ValueEnum,
strum::Display,
strum::EnumString,
strum::EnumIter,
strum::EnumProperty,
)]
#[strum(serialize_all = "lowercase")]
#[clap(rename_all = "lowercase")]
pub enum Target {
#[strum(props(dir = ".claude"))]
Claude,
#[strum(props(dir = ".codex"))]
Codex,
#[strum(props(dir = ".github"))]
Copilot,
#[strum(props(dir = ".cursor"))]
Cursor,
#[strum(props(dir = ".gemini"))]
Gemini,
#[strum(props(dir = ".kiro"))]
Kiro,
#[strum(props(dir = ".opencode"))]
Opencode,
#[strum(props(dir = ".trae"))]
Trae,
}
impl Target {
pub fn dir_name(&self) -> &'static str {
self.get_str("dir").expect("all variants have dir prop")
}
pub fn global_path(&self) -> Result<PathBuf> {
let home = dirs::home_dir().ok_or_else(|| {
SkillcError::Internal("could not determine home directory".to_string())
})?;
Ok(home.join(self.dir_name()).join("skills"))
}
pub fn project_path(&self, project_root: &Path) -> PathBuf {
project_root.join(self.dir_name()).join("skills")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TargetSpec {
Known(Target),
Custom(PathBuf),
}
impl TargetSpec {
pub fn skills_path(&self, project_root: Option<&Path>) -> Result<PathBuf> {
match self {
TargetSpec::Known(t) => match project_root {
Some(root) => Ok(t.project_path(root)),
None => t.global_path(),
},
TargetSpec::Custom(p) => Ok(p.clone()),
}
}
pub fn is_known(&self) -> bool {
matches!(self, TargetSpec::Known(_))
}
}
impl std::fmt::Display for TargetSpec {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
TargetSpec::Known(t) => write!(f, "{}", t),
TargetSpec::Custom(p) => write!(f, "{}", p.display()),
}
}
}
impl FromStr for TargetSpec {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s.parse::<Target>() {
Ok(t) => Ok(TargetSpec::Known(t)),
Err(_) => Ok(TargetSpec::Custom(PathBuf::from(s))),
}
}
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct SearchConfig {
#[serde(default)]
pub tokenizer: Option<Tokenizer>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct SkillcConfig {
#[serde(default)]
pub version: Option<u32>,
#[serde(default)]
pub search: SearchConfig,
}
fn load_config_file(path: &Path) -> Option<SkillcConfig> {
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return None,
};
let config: SkillcConfig = match toml::from_str(&content) {
Ok(c) => c,
Err(e) => {
eprintln!(
"warning: Failed to parse config file {}: {}",
path.display(),
e
);
return None;
}
};
if let Some(version) = config.version {
if version == 0 {
eprintln!(
"error: Invalid config version {} in {} (must be positive integer)",
version,
path.display()
);
return None; }
if version > 1 {
eprintln!(
"warning: Config version {} in {} is newer than supported (1), using recognized fields only",
version,
path.display()
);
}
}
Some(config)
}
fn find_project_config() -> Option<PathBuf> {
let mut dir = env::current_dir().ok()?;
loop {
let config_path = dir.join(".skillc").join("config.toml");
if config_path.exists() {
return Some(config_path);
}
if !dir.pop() {
break;
}
}
None
}
pub fn get_tokenizer() -> Tokenizer {
if let Ok(val) = env::var("SKILLC_TOKENIZER") {
if let Ok(t) = val.parse::<Tokenizer>() {
return t;
} else {
eprintln!(
"warning: Invalid SKILLC_TOKENIZER value '{}', ignoring",
val
);
}
}
if let Some(path) = find_project_config()
&& let Some(config) = load_config_file(&path)
&& let Some(t) = config.search.tokenizer
{
return t;
}
if let Ok(global_config_path) = global_skillc_dir().map(|d| d.join("config.toml"))
&& let Some(config) = load_config_file(&global_config_path)
&& let Some(t) = config.search.tokenizer
{
return t;
}
Tokenizer::default()
}
pub fn global_skillc_dir() -> Result<PathBuf> {
if let Ok(skillc_home) = env::var("SKILLC_HOME") {
return Ok(PathBuf::from(skillc_home).join(".skillc"));
}
let home = dirs::home_dir()
.ok_or_else(|| SkillcError::Internal("could not determine home directory".to_string()))?;
Ok(home.join(".skillc"))
}
pub fn global_source_store() -> Result<PathBuf> {
Ok(global_skillc_dir()?.join("skills"))
}
pub fn find_project_root() -> Option<PathBuf> {
let excluded_home = if let Ok(skillc_home) = env::var("SKILLC_HOME") {
Some(PathBuf::from(skillc_home))
} else {
dirs::home_dir()
};
let mut dir = env::current_dir().ok()?;
loop {
if Some(&dir) != excluded_home.as_ref() && dir.join(".skillc").is_dir() {
return Some(dir);
}
if !dir.pop() {
break;
}
}
None
}
pub fn find_project_skill(skill_name: &str) -> Option<(PathBuf, PathBuf)> {
let project_root = find_project_root()?;
let skill_path = crate::util::project_skill_dir(&project_root, skill_name);
if crate::util::is_valid_skill(&skill_path) {
Some((skill_path, project_root))
} else {
None
}
}
pub fn resolve_source_store() -> Result<(PathBuf, bool)> {
if let Some(root) = find_project_root() {
Ok((crate::util::project_skills_dir(&root), true))
} else {
Ok((global_source_store()?, false))
}
}
pub fn get_target_path(target: &str) -> Result<PathBuf> {
match target.parse::<Target>() {
Ok(t) => t.global_path(),
Err(_) => Ok(PathBuf::from(target)),
}
}
pub fn global_registry_path() -> Result<PathBuf> {
Ok(global_skillc_dir()?.join("registry.json"))
}
pub fn project_runtime_store() -> Option<PathBuf> {
let project_root = find_project_root()?;
Some(crate::util::project_runtime_dir(&project_root))
}
pub fn global_runtime_store() -> Result<PathBuf> {
Ok(global_skillc_dir()?.join("runtime"))
}
pub fn ensure_dir(path: &Path) -> std::io::Result<()> {
if !path.exists() {
std::fs::create_dir_all(path)?;
}
Ok(())
}
pub fn get_cwd() -> String {
env::current_dir()
.and_then(|p| p.canonicalize())
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| "<unknown>".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_tokenizer_from_str() {
assert_eq!("ascii".parse::<Tokenizer>().ok(), Some(Tokenizer::Ascii));
assert_eq!("ASCII".parse::<Tokenizer>().ok(), Some(Tokenizer::Ascii));
assert_eq!("cjk".parse::<Tokenizer>().ok(), Some(Tokenizer::Cjk));
assert_eq!("CJK".parse::<Tokenizer>().ok(), Some(Tokenizer::Cjk));
assert_eq!("invalid".parse::<Tokenizer>().ok(), None);
assert_eq!("".parse::<Tokenizer>().ok(), None);
}
#[test]
fn test_tokenizer_as_str() {
assert_eq!(Tokenizer::Ascii.as_str(), "ascii");
assert_eq!(Tokenizer::Cjk.as_str(), "cjk");
}
#[test]
fn test_tokenizer_default() {
assert_eq!(Tokenizer::default(), Tokenizer::Ascii);
}
#[test]
fn test_load_config_file_not_found() {
let result = load_config_file(Path::new("/nonexistent/config.toml"));
assert!(result.is_none());
}
#[test]
fn test_load_config_file_empty() {
let temp = TempDir::new().expect("create temp dir");
let config_path = temp.path().join("config.toml");
fs::write(&config_path, "").expect("write config");
let result = load_config_file(&config_path);
assert!(result.is_some());
let config = result.expect("expected result");
assert_eq!(config.version, None);
assert_eq!(config.search.tokenizer, None);
}
#[test]
fn test_load_config_file_with_tokenizer() {
let temp = TempDir::new().expect("create temp dir");
let config_path = temp.path().join("config.toml");
fs::write(
&config_path,
r#"
[search]
tokenizer = "cjk"
"#,
)
.expect("test operation");
let result = load_config_file(&config_path);
assert!(result.is_some());
let config = result.expect("expected result");
assert_eq!(config.search.tokenizer, Some(Tokenizer::Cjk));
}
#[test]
fn test_load_config_file_with_version() {
let temp = TempDir::new().expect("create temp dir");
let config_path = temp.path().join("config.toml");
fs::write(
&config_path,
r#"
version = 1
[search]
tokenizer = "ascii"
"#,
)
.expect("test operation");
let result = load_config_file(&config_path);
assert!(result.is_some());
let config = result.expect("expected result");
assert_eq!(config.version, Some(1));
assert_eq!(config.search.tokenizer, Some(Tokenizer::Ascii));
}
#[test]
fn test_load_config_file_invalid_version_zero() {
let temp = TempDir::new().expect("create temp dir");
let config_path = temp.path().join("config.toml");
fs::write(
&config_path,
r#"
version = 0
"#,
)
.expect("test operation");
let result = load_config_file(&config_path);
assert!(result.is_none());
}
#[test]
fn test_load_config_file_future_version() {
let temp = TempDir::new().expect("create temp dir");
let config_path = temp.path().join("config.toml");
fs::write(
&config_path,
r#"
version = 99
[search]
tokenizer = "cjk"
"#,
)
.expect("test operation");
let result = load_config_file(&config_path);
assert!(result.is_some());
let config = result.expect("expected result");
assert_eq!(config.search.tokenizer, Some(Tokenizer::Cjk));
}
#[test]
fn test_load_config_file_unknown_keys() {
let temp = TempDir::new().expect("create temp dir");
let config_path = temp.path().join("config.toml");
fs::write(
&config_path,
r#"
[search]
tokenizer = "ascii"
unknown_key = "ignored"
[unknown_section]
foo = "bar"
"#,
)
.expect("test operation");
let result = load_config_file(&config_path);
assert!(result.is_some());
let config = result.expect("expected result");
assert_eq!(config.search.tokenizer, Some(Tokenizer::Ascii));
}
#[test]
fn test_load_config_file_invalid_toml() {
let temp = TempDir::new().expect("create temp dir");
let config_path = temp.path().join("config.toml");
fs::write(&config_path, "this is not valid toml [[[").expect("write invalid config");
let result = load_config_file(&config_path);
assert!(result.is_none());
}
}