mod registration;
use std::path::{Path, PathBuf};
use crate::context::ContextScope;
pub(crate) use registration::SkillRoots;
pub const DEFAULT_WORKSPACE_SKILLS_DIR: &str = ".basis/skills";
pub const SHARED_SKILLS_DIR: &str = ".agents/skills";
pub const DEFAULT_GLOBAL_SKILLS_DIR: &str = "skills";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillsConfig {
pub workspace_subdir: Option<PathBuf>,
pub shared_workspace_dir: bool,
pub global_dir: Option<PathBuf>,
pub shared_home_dir: bool,
}
impl Default for SkillsConfig {
fn default() -> Self {
Self {
workspace_subdir: Some(PathBuf::from(DEFAULT_WORKSPACE_SKILLS_DIR)),
shared_workspace_dir: true,
global_dir: crate::context::default_global_dir(),
shared_home_dir: true,
}
}
}
impl SkillsConfig {
pub fn none() -> Self {
Self {
workspace_subdir: None,
shared_workspace_dir: false,
global_dir: None,
shared_home_dir: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SkillsSource {
pub path: PathBuf,
pub scope: ContextScope,
}
pub fn discover(workspace: &Path, config: &SkillsConfig) -> Vec<SkillsSource> {
discover_in(workspace, config, user_home().as_deref())
}
fn discover_in(workspace: &Path, config: &SkillsConfig, home: Option<&Path>) -> Vec<SkillsSource> {
let mut sources = Vec::new();
if let Some(subdir) = &config.workspace_subdir {
push(
&mut sources,
workspace.join(subdir),
ContextScope::Workspace,
);
}
if config.shared_workspace_dir {
push(
&mut sources,
workspace.join(SHARED_SKILLS_DIR),
ContextScope::Workspace,
);
}
if let Some(global) = &config.global_dir {
push(
&mut sources,
global.join(DEFAULT_GLOBAL_SKILLS_DIR),
ContextScope::Global,
);
}
if config.shared_home_dir
&& let Some(home) = home
{
push(
&mut sources,
home.join(SHARED_SKILLS_DIR),
ContextScope::Global,
);
}
sources
}
fn push(sources: &mut Vec<SkillsSource>, path: PathBuf, scope: ContextScope) {
if !path.is_dir()
|| sources
.iter()
.any(|source| crate::paths::same_dir(&source.path, &path))
{
return;
}
sources.push(SkillsSource { path, scope });
}
fn user_home() -> Option<PathBuf> {
std::env::var_os("HOME").map(PathBuf::from)
}
#[cfg(test)]
mod tests {
use super::*;
fn config(global: Option<PathBuf>) -> SkillsConfig {
SkillsConfig {
workspace_subdir: Some(PathBuf::from(DEFAULT_WORKSPACE_SKILLS_DIR)),
shared_workspace_dir: true,
global_dir: global,
shared_home_dir: true,
}
}
fn found(workspace: &Path, config: &SkillsConfig, home: Option<&Path>) -> Vec<SkillsSource> {
discover_in(workspace, config, home)
}
fn dir(parent: &Path, relative: &str) -> PathBuf {
let path = parent.join(relative);
std::fs::create_dir_all(&path).expect("create dir");
path
}
#[test]
fn nothing_on_disk_means_no_sources() {
let tmp = tempfile::tempdir().expect("tempdir");
assert!(found(tmp.path(), &config(None), None).is_empty());
}
#[test]
fn a_workspace_directory_is_found() {
let tmp = tempfile::tempdir().expect("tempdir");
let skills = dir(tmp.path(), DEFAULT_WORKSPACE_SKILLS_DIR);
let sources = found(tmp.path(), &config(None), None);
assert_eq!(sources.len(), 1);
assert_eq!(sources[0].scope, ContextScope::Workspace);
assert_eq!(sources[0].path, skills);
}
#[test]
fn the_shared_workspace_directory_is_found() {
let tmp = tempfile::tempdir().expect("tempdir");
let shared = dir(tmp.path(), SHARED_SKILLS_DIR);
let sources = found(tmp.path(), &config(None), None);
assert_eq!(sources.len(), 1);
assert_eq!(sources[0].scope, ContextScope::Workspace);
assert_eq!(sources[0].path, shared);
}
#[test]
fn the_basis_directory_outranks_the_shared_one_because_it_names_basis() {
let tmp = tempfile::tempdir().expect("tempdir");
let basis = dir(tmp.path(), DEFAULT_WORKSPACE_SKILLS_DIR);
let shared = dir(tmp.path(), SHARED_SKILLS_DIR);
let sources = found(tmp.path(), &config(None), None);
assert_eq!(
sources
.iter()
.map(|source| &source.path)
.collect::<Vec<_>>(),
vec![&basis, &shared],
"registration is strongest-first, so a name defined in both loads from .basis/skills"
);
}
#[test]
fn the_workspace_directory_outranks_the_global_one() {
let tmp = tempfile::tempdir().expect("tempdir");
dir(tmp.path(), DEFAULT_WORKSPACE_SKILLS_DIR);
let global = tmp.path().join("global");
dir(&global, DEFAULT_GLOBAL_SKILLS_DIR);
let sources = found(tmp.path(), &config(Some(global)), None);
assert_eq!(sources.len(), 2);
assert_eq!(sources[0].scope, ContextScope::Workspace);
assert_eq!(sources[1].scope, ContextScope::Global);
}
#[test]
fn a_global_directory_alone_is_used() {
let tmp = tempfile::tempdir().expect("tempdir");
let global = tmp.path().join("global");
dir(&global, DEFAULT_GLOBAL_SKILLS_DIR);
let sources = found(tmp.path(), &config(Some(global)), None);
assert_eq!(sources.len(), 1);
assert_eq!(sources[0].scope, ContextScope::Global);
}
#[test]
fn the_shared_user_directory_is_found_behind_the_basis_one() {
let tmp = tempfile::tempdir().expect("tempdir");
let global = tmp.path().join("global");
let home = tmp.path().join("home");
let basis_global = dir(&global, DEFAULT_GLOBAL_SKILLS_DIR);
let shared_user = dir(&home, SHARED_SKILLS_DIR);
let sources = found(tmp.path(), &config(Some(global)), Some(&home));
assert_eq!(
sources
.iter()
.map(|source| &source.path)
.collect::<Vec<_>>(),
vec![&basis_global, &shared_user]
);
assert!(
sources
.iter()
.all(|source| source.scope == ContextScope::Global)
);
}
#[test]
fn all_four_roots_come_back_most_specific_first() {
let tmp = tempfile::tempdir().expect("tempdir");
let workspace = tmp.path().join("repo");
let global = tmp.path().join("global");
let home = tmp.path().join("home");
let expected = [
dir(&workspace, DEFAULT_WORKSPACE_SKILLS_DIR),
dir(&workspace, SHARED_SKILLS_DIR),
dir(&global, DEFAULT_GLOBAL_SKILLS_DIR),
dir(&home, SHARED_SKILLS_DIR),
];
let sources = found(&workspace, &config(Some(global)), Some(&home));
assert_eq!(
sources
.iter()
.map(|source| &source.path)
.collect::<Vec<_>>(),
expected.iter().collect::<Vec<_>>()
);
}
#[test]
fn a_missing_root_is_simply_absent() {
let tmp = tempfile::tempdir().expect("tempdir");
let workspace = tmp.path().join("repo");
let global = tmp.path().join("global");
let home = tmp.path().join("home");
let basis = dir(&workspace, DEFAULT_WORKSPACE_SKILLS_DIR);
let basis_global = dir(&global, DEFAULT_GLOBAL_SKILLS_DIR);
let shared_user = dir(&home, SHARED_SKILLS_DIR);
let sources = found(&workspace, &config(Some(global)), Some(&home));
assert_eq!(
sources
.iter()
.map(|source| &source.path)
.collect::<Vec<_>>(),
vec![&basis, &basis_global, &shared_user]
);
}
#[test]
fn disabling_the_shared_home_root_alone_leaves_the_other_three() {
let tmp = tempfile::tempdir().expect("tempdir");
let workspace = tmp.path().join("repo");
let global = tmp.path().join("global");
let home = tmp.path().join("home");
dir(&workspace, DEFAULT_WORKSPACE_SKILLS_DIR);
dir(&global, DEFAULT_GLOBAL_SKILLS_DIR);
dir(&home, SHARED_SKILLS_DIR);
let sources = found(
&workspace,
&SkillsConfig {
shared_home_dir: false,
..config(Some(global))
},
Some(&home),
);
assert_eq!(sources.len(), 2, "workspace root and global root only");
assert!(
sources
.iter()
.all(|source| source.path != home.join(SHARED_SKILLS_DIR))
);
}
#[test]
fn a_missing_global_dir_no_longer_silences_the_home_root() {
let tmp = tempfile::tempdir().expect("tempdir");
let home = tmp.path().join("home");
let shared_user = dir(&home, SHARED_SKILLS_DIR);
let sources = found(tmp.path(), &config(None), Some(&home));
assert_eq!(
sources,
vec![SkillsSource {
path: shared_user,
scope: ContextScope::Global,
}]
);
}
#[test]
fn disabling_the_global_dir_alone_leaves_the_home_root() {
let tmp = tempfile::tempdir().expect("tempdir");
let global = tmp.path().join("global");
let home = tmp.path().join("home");
dir(&global, DEFAULT_GLOBAL_SKILLS_DIR);
let shared_user = dir(&home, SHARED_SKILLS_DIR);
let sources = found(
tmp.path(),
&SkillsConfig {
global_dir: None,
..config(Some(global))
},
Some(&home),
);
assert_eq!(
sources,
vec![SkillsSource {
path: shared_user,
scope: ContextScope::Global,
}]
);
}
#[test]
fn disabling_the_basis_workspace_root_alone_leaves_the_shared_one() {
let tmp = tempfile::tempdir().expect("tempdir");
dir(tmp.path(), DEFAULT_WORKSPACE_SKILLS_DIR);
let shared = dir(tmp.path(), SHARED_SKILLS_DIR);
let sources = found(
tmp.path(),
&SkillsConfig {
workspace_subdir: None,
..config(None)
},
None,
);
assert_eq!(
sources,
vec![SkillsSource {
path: shared,
scope: ContextScope::Workspace,
}]
);
}
#[test]
fn disabling_the_shared_workspace_root_alone_leaves_the_basis_one() {
let tmp = tempfile::tempdir().expect("tempdir");
let basis = dir(tmp.path(), DEFAULT_WORKSPACE_SKILLS_DIR);
dir(tmp.path(), SHARED_SKILLS_DIR);
let sources = found(
tmp.path(),
&SkillsConfig {
shared_workspace_dir: false,
..config(None)
},
None,
);
assert_eq!(
sources,
vec![SkillsSource {
path: basis,
scope: ContextScope::Workspace,
}]
);
}
#[test]
fn a_file_where_the_directory_should_be_is_ignored() {
let tmp = tempfile::tempdir().expect("tempdir");
let skills = tmp.path().join(DEFAULT_WORKSPACE_SKILLS_DIR);
std::fs::create_dir_all(skills.parent().expect("parent")).expect("create .basis");
std::fs::write(&skills, "not a directory").expect("write file");
assert!(found(tmp.path(), &config(None), None).is_empty());
}
#[test]
fn the_same_directory_reached_twice_is_reported_once() {
let tmp = tempfile::tempdir().expect("tempdir");
let global = tmp.path().join("global");
dir(&global, DEFAULT_GLOBAL_SKILLS_DIR);
let sources = found(
&global,
&SkillsConfig {
workspace_subdir: Some(PathBuf::from(DEFAULT_GLOBAL_SKILLS_DIR)),
shared_workspace_dir: false,
global_dir: Some(global.clone()),
shared_home_dir: false,
},
None,
);
assert_eq!(sources.len(), 1);
assert_eq!(sources[0].scope, ContextScope::Workspace);
}
#[test]
fn a_home_that_is_the_workspace_does_not_register_one_directory_twice() {
let tmp = tempfile::tempdir().expect("tempdir");
let shared = dir(tmp.path(), SHARED_SKILLS_DIR);
let global = tmp.path().join("global");
dir(&global, DEFAULT_GLOBAL_SKILLS_DIR);
let sources = found(tmp.path(), &config(Some(global)), Some(tmp.path()));
assert_eq!(
sources
.iter()
.filter(|source| source.path == shared)
.count(),
1
);
assert_eq!(sources[0].scope, ContextScope::Workspace);
}
}