1mod registration;
63
64use std::path::{Path, PathBuf};
65
66use crate::context::ContextScope;
67
68pub(crate) use registration::SkillRoots;
69
70pub const DEFAULT_WORKSPACE_SKILLS_DIR: &str = ".basis/skills";
72
73pub const SHARED_SKILLS_DIR: &str = ".agents/skills";
77
78pub const DEFAULT_GLOBAL_SKILLS_DIR: &str = "skills";
80
81#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct SkillsConfig {
92 pub workspace_subdir: Option<PathBuf>,
95 pub shared_workspace_dir: bool,
98 pub global_dir: Option<PathBuf>,
102 pub shared_home_dir: bool,
107}
108
109impl Default for SkillsConfig {
110 fn default() -> Self {
111 Self {
112 workspace_subdir: Some(PathBuf::from(DEFAULT_WORKSPACE_SKILLS_DIR)),
113 shared_workspace_dir: true,
114 global_dir: crate::context::default_global_dir(),
115 shared_home_dir: true,
116 }
117 }
118}
119
120impl SkillsConfig {
121 pub fn none() -> Self {
128 Self {
129 workspace_subdir: None,
130 shared_workspace_dir: false,
131 global_dir: None,
132 shared_home_dir: false,
133 }
134 }
135}
136
137#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct SkillsSource {
140 pub path: PathBuf,
141 pub scope: ContextScope,
142}
143
144pub fn discover(workspace: &Path, config: &SkillsConfig) -> Vec<SkillsSource> {
150 discover_in(workspace, config, user_home().as_deref())
151}
152
153fn discover_in(workspace: &Path, config: &SkillsConfig, home: Option<&Path>) -> Vec<SkillsSource> {
157 let mut sources = Vec::new();
158
159 if let Some(subdir) = &config.workspace_subdir {
160 push(
161 &mut sources,
162 workspace.join(subdir),
163 ContextScope::Workspace,
164 );
165 }
166 if config.shared_workspace_dir {
167 push(
168 &mut sources,
169 workspace.join(SHARED_SKILLS_DIR),
170 ContextScope::Workspace,
171 );
172 }
173
174 if let Some(global) = &config.global_dir {
175 push(
176 &mut sources,
177 global.join(DEFAULT_GLOBAL_SKILLS_DIR),
178 ContextScope::Global,
179 );
180 }
181 if config.shared_home_dir
186 && let Some(home) = home
187 {
188 push(
189 &mut sources,
190 home.join(SHARED_SKILLS_DIR),
191 ContextScope::Global,
192 );
193 }
194
195 sources
196}
197
198fn push(sources: &mut Vec<SkillsSource>, path: PathBuf, scope: ContextScope) {
206 if !path.is_dir()
207 || sources
208 .iter()
209 .any(|source| crate::paths::same_dir(&source.path, &path))
210 {
211 return;
212 }
213
214 sources.push(SkillsSource { path, scope });
215}
216
217fn user_home() -> Option<PathBuf> {
220 std::env::var_os("HOME").map(PathBuf::from)
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226
227 fn config(global: Option<PathBuf>) -> SkillsConfig {
230 SkillsConfig {
231 workspace_subdir: Some(PathBuf::from(DEFAULT_WORKSPACE_SKILLS_DIR)),
232 shared_workspace_dir: true,
233 global_dir: global,
234 shared_home_dir: true,
235 }
236 }
237
238 fn found(workspace: &Path, config: &SkillsConfig, home: Option<&Path>) -> Vec<SkillsSource> {
241 discover_in(workspace, config, home)
242 }
243
244 fn dir(parent: &Path, relative: &str) -> PathBuf {
245 let path = parent.join(relative);
246 std::fs::create_dir_all(&path).expect("create dir");
247 path
248 }
249
250 #[test]
251 fn nothing_on_disk_means_no_sources() {
252 let tmp = tempfile::tempdir().expect("tempdir");
253
254 assert!(found(tmp.path(), &config(None), None).is_empty());
255 }
256
257 #[test]
258 fn a_workspace_directory_is_found() {
259 let tmp = tempfile::tempdir().expect("tempdir");
260 let skills = dir(tmp.path(), DEFAULT_WORKSPACE_SKILLS_DIR);
261
262 let sources = found(tmp.path(), &config(None), None);
263
264 assert_eq!(sources.len(), 1);
265 assert_eq!(sources[0].scope, ContextScope::Workspace);
266 assert_eq!(sources[0].path, skills);
267 }
268
269 #[test]
270 fn the_shared_workspace_directory_is_found() {
271 let tmp = tempfile::tempdir().expect("tempdir");
274 let shared = dir(tmp.path(), SHARED_SKILLS_DIR);
275
276 let sources = found(tmp.path(), &config(None), None);
277
278 assert_eq!(sources.len(), 1);
279 assert_eq!(sources[0].scope, ContextScope::Workspace);
280 assert_eq!(sources[0].path, shared);
281 }
282
283 #[test]
284 fn the_basis_directory_outranks_the_shared_one_because_it_names_basis() {
285 let tmp = tempfile::tempdir().expect("tempdir");
289 let basis = dir(tmp.path(), DEFAULT_WORKSPACE_SKILLS_DIR);
290 let shared = dir(tmp.path(), SHARED_SKILLS_DIR);
291
292 let sources = found(tmp.path(), &config(None), None);
293
294 assert_eq!(
295 sources
296 .iter()
297 .map(|source| &source.path)
298 .collect::<Vec<_>>(),
299 vec![&basis, &shared],
300 "registration is strongest-first, so a name defined in both loads from .basis/skills"
301 );
302 }
303
304 #[test]
305 fn the_workspace_directory_outranks_the_global_one() {
306 let tmp = tempfile::tempdir().expect("tempdir");
307 dir(tmp.path(), DEFAULT_WORKSPACE_SKILLS_DIR);
308 let global = tmp.path().join("global");
309 dir(&global, DEFAULT_GLOBAL_SKILLS_DIR);
310
311 let sources = found(tmp.path(), &config(Some(global)), None);
312
313 assert_eq!(sources.len(), 2);
314 assert_eq!(sources[0].scope, ContextScope::Workspace);
315 assert_eq!(sources[1].scope, ContextScope::Global);
316 }
317
318 #[test]
319 fn a_global_directory_alone_is_used() {
320 let tmp = tempfile::tempdir().expect("tempdir");
321 let global = tmp.path().join("global");
322 dir(&global, DEFAULT_GLOBAL_SKILLS_DIR);
323
324 let sources = found(tmp.path(), &config(Some(global)), None);
325
326 assert_eq!(sources.len(), 1);
327 assert_eq!(sources[0].scope, ContextScope::Global);
328 }
329
330 #[test]
331 fn the_shared_user_directory_is_found_behind_the_basis_one() {
332 let tmp = tempfile::tempdir().expect("tempdir");
333 let global = tmp.path().join("global");
334 let home = tmp.path().join("home");
335 let basis_global = dir(&global, DEFAULT_GLOBAL_SKILLS_DIR);
336 let shared_user = dir(&home, SHARED_SKILLS_DIR);
337
338 let sources = found(tmp.path(), &config(Some(global)), Some(&home));
339
340 assert_eq!(
341 sources
342 .iter()
343 .map(|source| &source.path)
344 .collect::<Vec<_>>(),
345 vec![&basis_global, &shared_user]
346 );
347 assert!(
348 sources
349 .iter()
350 .all(|source| source.scope == ContextScope::Global)
351 );
352 }
353
354 #[test]
355 fn all_four_roots_come_back_most_specific_first() {
356 let tmp = tempfile::tempdir().expect("tempdir");
357 let workspace = tmp.path().join("repo");
358 let global = tmp.path().join("global");
359 let home = tmp.path().join("home");
360
361 let expected = [
362 dir(&workspace, DEFAULT_WORKSPACE_SKILLS_DIR),
363 dir(&workspace, SHARED_SKILLS_DIR),
364 dir(&global, DEFAULT_GLOBAL_SKILLS_DIR),
365 dir(&home, SHARED_SKILLS_DIR),
366 ];
367
368 let sources = found(&workspace, &config(Some(global)), Some(&home));
369
370 assert_eq!(
371 sources
372 .iter()
373 .map(|source| &source.path)
374 .collect::<Vec<_>>(),
375 expected.iter().collect::<Vec<_>>()
376 );
377 }
378
379 #[test]
380 fn a_missing_root_is_simply_absent() {
381 let tmp = tempfile::tempdir().expect("tempdir");
383 let workspace = tmp.path().join("repo");
384 let global = tmp.path().join("global");
385 let home = tmp.path().join("home");
386
387 let basis = dir(&workspace, DEFAULT_WORKSPACE_SKILLS_DIR);
388 let basis_global = dir(&global, DEFAULT_GLOBAL_SKILLS_DIR);
389 let shared_user = dir(&home, SHARED_SKILLS_DIR);
390
391 let sources = found(&workspace, &config(Some(global)), Some(&home));
392
393 assert_eq!(
394 sources
395 .iter()
396 .map(|source| &source.path)
397 .collect::<Vec<_>>(),
398 vec![&basis, &basis_global, &shared_user]
399 );
400 }
401
402 #[test]
403 fn disabling_the_shared_home_root_alone_leaves_the_other_three() {
404 let tmp = tempfile::tempdir().expect("tempdir");
405 let workspace = tmp.path().join("repo");
406 let global = tmp.path().join("global");
407 let home = tmp.path().join("home");
408 dir(&workspace, DEFAULT_WORKSPACE_SKILLS_DIR);
409 dir(&global, DEFAULT_GLOBAL_SKILLS_DIR);
410 dir(&home, SHARED_SKILLS_DIR);
411
412 let sources = found(
413 &workspace,
414 &SkillsConfig {
415 shared_home_dir: false,
416 ..config(Some(global))
417 },
418 Some(&home),
419 );
420
421 assert_eq!(sources.len(), 2, "workspace root and global root only");
422 assert!(
423 sources
424 .iter()
425 .all(|source| source.path != home.join(SHARED_SKILLS_DIR))
426 );
427 }
428
429 #[test]
430 fn a_missing_global_dir_no_longer_silences_the_home_root() {
431 let tmp = tempfile::tempdir().expect("tempdir");
435 let home = tmp.path().join("home");
436 let shared_user = dir(&home, SHARED_SKILLS_DIR);
437
438 let sources = found(tmp.path(), &config(None), Some(&home));
439
440 assert_eq!(
441 sources,
442 vec![SkillsSource {
443 path: shared_user,
444 scope: ContextScope::Global,
445 }]
446 );
447 }
448
449 #[test]
450 fn disabling_the_global_dir_alone_leaves_the_home_root() {
451 let tmp = tempfile::tempdir().expect("tempdir");
452 let global = tmp.path().join("global");
453 let home = tmp.path().join("home");
454 dir(&global, DEFAULT_GLOBAL_SKILLS_DIR);
455 let shared_user = dir(&home, SHARED_SKILLS_DIR);
456
457 let sources = found(
458 tmp.path(),
459 &SkillsConfig {
460 global_dir: None,
461 ..config(Some(global))
462 },
463 Some(&home),
464 );
465
466 assert_eq!(
467 sources,
468 vec![SkillsSource {
469 path: shared_user,
470 scope: ContextScope::Global,
471 }]
472 );
473 }
474
475 #[test]
476 fn disabling_the_basis_workspace_root_alone_leaves_the_shared_one() {
477 let tmp = tempfile::tempdir().expect("tempdir");
478 dir(tmp.path(), DEFAULT_WORKSPACE_SKILLS_DIR);
479 let shared = dir(tmp.path(), SHARED_SKILLS_DIR);
480
481 let sources = found(
482 tmp.path(),
483 &SkillsConfig {
484 workspace_subdir: None,
485 ..config(None)
486 },
487 None,
488 );
489
490 assert_eq!(
491 sources,
492 vec![SkillsSource {
493 path: shared,
494 scope: ContextScope::Workspace,
495 }]
496 );
497 }
498
499 #[test]
500 fn disabling_the_shared_workspace_root_alone_leaves_the_basis_one() {
501 let tmp = tempfile::tempdir().expect("tempdir");
502 let basis = dir(tmp.path(), DEFAULT_WORKSPACE_SKILLS_DIR);
503 dir(tmp.path(), SHARED_SKILLS_DIR);
504
505 let sources = found(
506 tmp.path(),
507 &SkillsConfig {
508 shared_workspace_dir: false,
509 ..config(None)
510 },
511 None,
512 );
513
514 assert_eq!(
515 sources,
516 vec![SkillsSource {
517 path: basis,
518 scope: ContextScope::Workspace,
519 }]
520 );
521 }
522
523 #[test]
524 fn a_file_where_the_directory_should_be_is_ignored() {
525 let tmp = tempfile::tempdir().expect("tempdir");
526 let skills = tmp.path().join(DEFAULT_WORKSPACE_SKILLS_DIR);
527 std::fs::create_dir_all(skills.parent().expect("parent")).expect("create .basis");
528 std::fs::write(&skills, "not a directory").expect("write file");
529
530 assert!(found(tmp.path(), &config(None), None).is_empty());
531 }
532
533 #[test]
534 fn the_same_directory_reached_twice_is_reported_once() {
535 let tmp = tempfile::tempdir().expect("tempdir");
536 let global = tmp.path().join("global");
537 dir(&global, DEFAULT_GLOBAL_SKILLS_DIR);
538
539 let sources = found(
541 &global,
542 &SkillsConfig {
543 workspace_subdir: Some(PathBuf::from(DEFAULT_GLOBAL_SKILLS_DIR)),
544 shared_workspace_dir: false,
545 global_dir: Some(global.clone()),
546 shared_home_dir: false,
547 },
548 None,
549 );
550
551 assert_eq!(sources.len(), 1);
552 assert_eq!(sources[0].scope, ContextScope::Workspace);
553 }
554
555 #[test]
556 fn a_home_that_is_the_workspace_does_not_register_one_directory_twice() {
557 let tmp = tempfile::tempdir().expect("tempdir");
560 let shared = dir(tmp.path(), SHARED_SKILLS_DIR);
561 let global = tmp.path().join("global");
562 dir(&global, DEFAULT_GLOBAL_SKILLS_DIR);
563
564 let sources = found(tmp.path(), &config(Some(global)), Some(tmp.path()));
565
566 assert_eq!(
567 sources
568 .iter()
569 .filter(|source| source.path == shared)
570 .count(),
571 1
572 );
573 assert_eq!(sources[0].scope, ContextScope::Workspace);
574 }
575}