返回 CodeWhale
roots.rs
根目录 / crates / tui / src / skills / roots.rs
1 //! Single source of truth for skill root enumeration, ownership, scope, and
2 //! runtime precedence.
3 //!
4 //! Runtime discovery and (later) audit/mutation share this catalog so
5 //! precedence cannot drift between modules. Discovery directories are not
6 //! write targets: only explicitly owned CodeWhale roots are writable.
7
8 use std::collections::HashSet;
9 use std::fs;
10 use std::path::{Path, PathBuf};
11
12 /// Stable identifier for a skill root within a catalog snapshot.
13 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
14 pub struct SkillRootId(String);
15
16 impl SkillRootId {
17 #[must_use]
18 #[allow(dead_code)] // consumed by audit/mutation in later #4651 stages
19 pub fn as_str(&self) -> &str {
20 &self.0
21 }
22 }
23
24 impl std::fmt::Display for SkillRootId {
25 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26 f.write_str(&self.0)
27 }
28 }
29
30 /// External harness layouts that CodeWhale can discover/audit but never owns.
31 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
32 pub enum CompatibleHarness {
33 Agents,
34 Claude,
35 Cursor,
36 OpenCode,
37 Codex,
38 DeepSeekLegacy,
39 /// Flat `<workspace>/skills` layout.
40 FlatProjectSkills,
41 }
42
43 impl CompatibleHarness {
44 #[must_use]
45 #[allow(dead_code)] // consumed by audit UI labels in later #4651 stages
46 pub fn label(self) -> &'static str {
47 match self {
48 Self::Agents => "agents",
49 Self::Claude => "claude",
50 Self::Cursor => "cursor",
51 Self::OpenCode => "opencode",
52 Self::Codex => "codex",
53 Self::DeepSeekLegacy => "deepseek",
54 Self::FlatProjectSkills => "flat-skills",
55 }
56 }
57 }
58
59 /// Kind of skill root on disk (or logical source).
60 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
61 #[allow(dead_code)] // BuiltIn / ReviewedPluginSnapshot used by later #4651 stages
62 pub enum SkillRootKind {
63 CodeWhaleProject,
64 CodeWhaleGlobal,
65 CompatibleProject(CompatibleHarness),
66 CompatibleGlobal(CompatibleHarness),
67 /// Explicitly configured `skills_dir` that is not one of the owned roots.
68 Configured,
69 BuiltIn,
70 ReviewedPluginSnapshot,
71 RegistryCache,
72 }
73
74 /// Whether CodeWhale may mutate files under this root.
75 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
76 #[allow(dead_code)] // Immutable used by later #4651 stages
77 pub enum SkillRootAccess {
78 /// CodeWhale-owned project/global install targets.
79 WritableOwned,
80 /// Compatible harness roots and unclassified configured dirs — read only.
81 ReadOnlyExternal,
82 /// Built-in / reviewed plugin snapshot content.
83 Immutable,
84 /// Registry download cache — not an active install target.
85 CacheOnly,
86 }
87
88 /// Project vs global scope for owned and compatible roots.
89 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
90 pub enum SkillScope {
91 Project,
92 Global,
93 /// Logical / non-filesystem sources (built-in, plugin snapshot, cache).
94 Logical,
95 }
96
97 /// One enumerated skill root with ownership and precedence metadata.
98 #[derive(Debug, Clone, PartialEq, Eq)]
99 pub struct SkillRootDescriptor {
100 pub id: SkillRootId,
101 pub kind: SkillRootKind,
102 pub access: SkillRootAccess,
103 pub scope: SkillScope,
104 pub path: PathBuf,
105 pub canonical_path: Option<PathBuf>,
106 /// Lower value = higher precedence for first-wins runtime merge.
107 pub precedence: Option<usize>,
108 /// When true, runtime skill discovery includes this root.
109 pub active_for_runtime: bool,
110 /// When true, owned-only / compatible audit may include this root.
111 pub active_for_audit: bool,
112 }
113
114 impl SkillRootDescriptor {
115 #[must_use]
116 pub fn is_writable_owned(&self) -> bool {
117 self.access == SkillRootAccess::WritableOwned
118 }
119
120 /// Home-relative or workspace-relative path for UI / receipts.
121 #[must_use]
122 #[allow(dead_code)] // consumed by manager receipts in later #4651 stages
123 pub fn safe_display_path(&self, workspace: Option<&Path>, home: Option<&Path>) -> String {
124 safe_display_path(&self.path, workspace, home)
125 }
126 }
127
128 /// Catalog of skill roots for a workspace (+ optional HOME override for tests).
129 #[derive(Debug, Clone, PartialEq, Eq)]
130 pub struct SkillRootCatalog {
131 roots: Vec<SkillRootDescriptor>,
132 }
133
134 impl SkillRootCatalog {
135 /// Build the full catalog: owned + compatible (including Codex audit-only)
136 /// plus optional configured dir and logical sources.
137 #[must_use]
138 pub fn build(
139 workspace: &Path,
140 home_dir: Option<&Path>,
141 configured_skills_dir: Option<&Path>,
142 ) -> Self {
143 let mut roots = Vec::new();
144 let mut precedence = 0usize;
145
146 // Runtime-compatible workspace roots (existing order — do not reorder).
147 push_existing(
148 &mut roots,
149 &mut precedence,
150 SkillRootKind::CompatibleProject(CompatibleHarness::Agents),
151 SkillRootAccess::ReadOnlyExternal,
152 SkillScope::Project,
153 workspace.join(".agents").join("skills"),
154 true,
155 true,
156 "project-agents",
157 );
158 push_existing(
159 &mut roots,
160 &mut precedence,
161 SkillRootKind::CompatibleProject(CompatibleHarness::FlatProjectSkills),
162 SkillRootAccess::ReadOnlyExternal,
163 SkillScope::Project,
164 workspace.join("skills"),
165 true,
166 true,
167 "project-flat-skills",
168 );
169 push_existing(
170 &mut roots,
171 &mut precedence,
172 SkillRootKind::CompatibleProject(CompatibleHarness::OpenCode),
173 SkillRootAccess::ReadOnlyExternal,
174 SkillScope::Project,
175 workspace.join(".opencode").join("skills"),
176 true,
177 true,
178 "project-opencode",
179 );
180 push_existing(
181 &mut roots,
182 &mut precedence,
183 SkillRootKind::CompatibleProject(CompatibleHarness::Claude),
184 SkillRootAccess::ReadOnlyExternal,
185 SkillScope::Project,
186 workspace.join(".claude").join("skills"),
187 true,
188 true,
189 "project-claude",
190 );
191 push_existing(
192 &mut roots,
193 &mut precedence,
194 SkillRootKind::CompatibleProject(CompatibleHarness::Cursor),
195 SkillRootAccess::ReadOnlyExternal,
196 SkillScope::Project,
197 workspace.join(".cursor").join("skills"),
198 true,
199 true,
200 "project-cursor",
201 );
202
203 // CodeWhale project root — always listed for ownership; runtime
204 // CodeWhale-only mode additionally requires the path stay inside the
205 // workspace (symlink escape check happens in path selection helpers).
206 let project_owned = workspace.join(".codewhale").join("skills");
207 push_descriptor(
208 &mut roots,
209 &mut precedence,
210 SkillRootKind::CodeWhaleProject,
211 SkillRootAccess::WritableOwned,
212 SkillScope::Project,
213 project_owned,
214 true,
215 true,
216 "project-codewhale",
217 true, // include even if missing — owned target may be created later
218 );
219
220 // Codex project: audit-compatible only; never active for runtime in #4651.
221 push_existing(
222 &mut roots,
223 &mut precedence,
224 SkillRootKind::CompatibleProject(CompatibleHarness::Codex),
225 SkillRootAccess::ReadOnlyExternal,
226 SkillScope::Project,
227 workspace.join(".codex").join("skills"),
228 false,
229 true,
230 "project-codex",
231 );
232
233 if let Some(home) = home_dir {
234 push_existing(
235 &mut roots,
236 &mut precedence,
237 SkillRootKind::CompatibleGlobal(CompatibleHarness::Agents),
238 SkillRootAccess::ReadOnlyExternal,
239 SkillScope::Global,
240 home.join(".agents").join("skills"),
241 true,
242 true,
243 "global-agents",
244 );
245 push_existing(
246 &mut roots,
247 &mut precedence,
248 SkillRootKind::CompatibleGlobal(CompatibleHarness::Claude),
249 SkillRootAccess::ReadOnlyExternal,
250 SkillScope::Global,
251 home.join(".claude").join("skills"),
252 true,
253 true,
254 "global-claude",
255 );
256
257 let global_owned = home.join(".codewhale").join("skills");
258 push_descriptor(
259 &mut roots,
260 &mut precedence,
261 SkillRootKind::CodeWhaleGlobal,
262 SkillRootAccess::WritableOwned,
263 SkillScope::Global,
264 global_owned,
265 true,
266 true,
267 "global-codewhale",
268 true,
269 );
270
271 push_existing(
272 &mut roots,
273 &mut precedence,
274 SkillRootKind::CompatibleGlobal(CompatibleHarness::DeepSeekLegacy),
275 SkillRootAccess::ReadOnlyExternal,
276 SkillScope::Global,
277 home.join(".deepseek").join("skills"),
278 true,
279 true,
280 "global-deepseek",
281 );
282
283 // Codex global: audit-compatible only.
284 push_existing(
285 &mut roots,
286 &mut precedence,
287 SkillRootKind::CompatibleGlobal(CompatibleHarness::Codex),
288 SkillRootAccess::ReadOnlyExternal,
289 SkillScope::Global,
290 home.join(".codex").join("skills"),
291 false,
292 true,
293 "global-codex",
294 );
295
296 // Registry cache is never an active skill root.
297 let cache = home.join(".codewhale").join("cache").join("skills");
298 push_descriptor(
299 &mut roots,
300 &mut precedence,
301 SkillRootKind::RegistryCache,
302 SkillRootAccess::CacheOnly,
303 SkillScope::Logical,
304 cache,
305 false,
306 false,
307 "registry-cache",
308 false,
309 );
310 } else {
311 // Match legacy fallback when HOME is unavailable.
312 push_descriptor(
313 &mut roots,
314 &mut precedence,
315 SkillRootKind::CodeWhaleGlobal,
316 SkillRootAccess::WritableOwned,
317 SkillScope::Global,
318 PathBuf::from("/tmp/codewhale/skills"),
319 true,
320 true,
321 "global-codewhale-fallback",
322 true,
323 );
324 }
325
326 if let Some(configured) = configured_skills_dir {
327 insert_configured_root(&mut roots, workspace, home_dir, configured, &mut precedence);
328 }
329
330 Self { roots }
331 }
332
333 #[must_use]
334 #[allow(dead_code)] // consumed by audit scanners in later #4651 stages
335 pub fn roots(&self) -> &[SkillRootDescriptor] {
336 &self.roots
337 }
338
339 /// Paths used by runtime discovery for the given mode (existing dirs only,
340 /// first-wins order preserved). CodeWhale-only applies the workspace
341 /// containment check for the project owned root.
342 #[must_use]
343 pub fn runtime_directories(
344 &self,
345 workspace: &Path,
346 mode: super::SkillDiscoveryMode,
347 ) -> Vec<PathBuf> {
348 let mut out = Vec::new();
349 let mut seen = HashSet::new();
350
351 for root in &self.roots {
352 if !root.active_for_runtime {
353 continue;
354 }
355 match mode {
356 super::SkillDiscoveryMode::Compatible => {}
357 super::SkillDiscoveryMode::CodeWhaleOnly => {
358 if !matches!(
359 root.kind,
360 SkillRootKind::CodeWhaleProject
361 | SkillRootKind::CodeWhaleGlobal
362 | SkillRootKind::Configured
363 ) {
364 continue;
365 }
366 if root.kind == SkillRootKind::CodeWhaleProject
367 && !codewhale_project_root_is_inside_workspace(workspace, &root.path)
368 {
369 continue;
370 }
371 }
372 }
373
374 if !path_is_existing_dir(&root.path) {
375 continue;
376 }
377 let Ok(canonical) = fs::canonicalize(&root.path) else {
378 continue;
379 };
380 if !canonical.is_dir() || !seen.insert(canonical) {
381 continue;
382 }
383 out.push(root.path.clone());
384 }
385 out
386 }
387
388 /// Owned CodeWhale project + global roots (may not exist yet).
389 #[must_use]
390 pub fn owned_writable_roots(&self) -> Vec<&SkillRootDescriptor> {
391 self.roots
392 .iter()
393 .filter(|r| r.is_writable_owned())
394 .collect()
395 }
396
397 /// Roots eligible for owned-only audit (writable owned roots that exist).
398 #[must_use]
399 #[allow(dead_code)] // consumed by owned audit mode in later #4651 stages
400 pub fn audit_owned_directories(&self) -> Vec<&SkillRootDescriptor> {
401 self.roots
402 .iter()
403 .filter(|r| {
404 r.is_writable_owned() && r.active_for_audit && path_is_existing_dir(&r.path)
405 })
406 .collect()
407 }
408
409 /// Owned + compatible roots for explicit `--compatible` audit, including
410 /// Codex. Does not change runtime activation.
411 #[must_use]
412 pub fn audit_compatible_directories(&self) -> Vec<&SkillRootDescriptor> {
413 self.roots
414 .iter()
415 .filter(|r| {
416 r.active_for_audit
417 && !matches!(
418 r.kind,
419 SkillRootKind::RegistryCache
420 | SkillRootKind::BuiltIn
421 | SkillRootKind::ReviewedPluginSnapshot
422 )
423 && path_is_existing_dir(&r.path)
424 })
425 .collect()
426 }
427 }
428
429 /// Resolve candidate skill directories for runtime discovery (existing paths
430 /// only), preserving historical precedence.
431 #[must_use]
432 pub fn skills_directories_with_home_and_mode(
433 workspace: &Path,
434 home_dir: Option<&Path>,
435 mode: super::SkillDiscoveryMode,
436 ) -> Vec<PathBuf> {
437 SkillRootCatalog::build(workspace, home_dir, None).runtime_directories(workspace, mode)
438 }
439
440 /// CodeWhale project skills dir when it exists and stays inside the workspace.
441 #[must_use]
442 pub fn codewhale_workspace_skills_dir(workspace: &Path) -> Option<PathBuf> {
443 let skills_dir = workspace.join(".codewhale").join("skills");
444 codewhale_project_root_is_inside_workspace(workspace, &skills_dir).then_some(skills_dir)
445 }
446
447 /// Filter candidate paths to existing directories, preserving order and
448 /// de-duplicating by canonical path.
449 #[cfg(test)]
450 #[must_use]
451 pub fn existing_skill_dirs(candidates: impl IntoIterator<Item = PathBuf>) -> Vec<PathBuf> {
452 let mut out = Vec::new();
453 let mut seen = HashSet::new();
454 for path in candidates {
455 let Ok(canonical_path) = fs::canonicalize(&path) else {
456 continue;
457 };
458 if canonical_path.is_dir() && seen.insert(canonical_path) {
459 out.push(path);
460 }
461 }
462 out
463 }
464
465 /// Classify a configured `skills_dir`: owned only when it is exactly a
466 /// CodeWhale project/global root; compatible harness paths stay read-only.
467 #[must_use]
468 pub fn classify_configured_skills_dir(
469 workspace: &Path,
470 home_dir: Option<&Path>,
471 skills_dir: &Path,
472 ) -> (SkillRootKind, SkillRootAccess, SkillScope) {
473 let project_owned = workspace.join(".codewhale").join("skills");
474 if paths_refer_to_same_dir(&project_owned, skills_dir) {
475 return (
476 SkillRootKind::CodeWhaleProject,
477 SkillRootAccess::WritableOwned,
478 SkillScope::Project,
479 );
480 }
481 if let Some(home) = home_dir {
482 let global_owned = home.join(".codewhale").join("skills");
483 if paths_refer_to_same_dir(&global_owned, skills_dir) {
484 return (
485 SkillRootKind::CodeWhaleGlobal,
486 SkillRootAccess::WritableOwned,
487 SkillScope::Global,
488 );
489 }
490 }
491
492 if let Some(harness) = match_compatible_project(workspace, skills_dir) {
493 return (
494 SkillRootKind::CompatibleProject(harness),
495 SkillRootAccess::ReadOnlyExternal,
496 SkillScope::Project,
497 );
498 }
499 if let Some(home) = home_dir
500 && let Some(harness) = match_compatible_global(home, skills_dir)
501 {
502 return (
503 SkillRootKind::CompatibleGlobal(harness),
504 SkillRootAccess::ReadOnlyExternal,
505 SkillScope::Global,
506 );
507 }
508
509 // Unknown configured path: treat as external until an explicit owned-root
510 // marker exists (Issue #4651 first cut — do not guess writability).
511 let scope = fs::canonicalize(workspace)
512 .ok()
513 .map_or(SkillScope::Global, |root| {
514 fs::canonicalize(skills_dir)
515 .ok()
516 .filter(|p| p.starts_with(&root))
517 .map_or(SkillScope::Global, |_| SkillScope::Project)
518 });
519 (
520 SkillRootKind::Configured,
521 SkillRootAccess::ReadOnlyExternal,
522 scope,
523 )
524 }
525
526 #[must_use]
527 pub fn safe_display_path(path: &Path, workspace: Option<&Path>, home: Option<&Path>) -> String {
528 // Prefer workspace when both apply so project roots stay distinct from
529 // `~/...` global paths that happen to live under the same home tree.
530 if let Some(workspace) = workspace
531 && let Ok(stripped) = path.strip_prefix(workspace)
532 {
533 return format!("<workspace>/{}", stripped.display()).replace('\\', "/");
534 }
535 if let Some(home) = home
536 && let Ok(stripped) = path.strip_prefix(home)
537 {
538 return format!("~/{}", stripped.display()).replace('\\', "/");
539 }
540 // Last resort: basename chain without expanding unrelated absolute parents.
541 path.file_name()
542 .map(|name| name.to_string_lossy().into_owned())
543 .unwrap_or_else(|| path.display().to_string())
544 }
545
546 #[must_use]
547 pub fn paths_refer_to_same_dir(left: &Path, right: &Path) -> bool {
548 if left == right {
549 return true;
550 }
551 match (fs::canonicalize(left), fs::canonicalize(right)) {
552 (Ok(left), Ok(right)) => left == right,
553 _ => false,
554 }
555 }
556
557 fn codewhale_project_root_is_inside_workspace(workspace: &Path, skills_dir: &Path) -> bool {
558 let Ok(canonical_workspace) = fs::canonicalize(workspace) else {
559 return false;
560 };
561 let Ok(canonical_skills) = fs::canonicalize(skills_dir) else {
562 return false;
563 };
564 canonical_skills.is_dir() && canonical_skills.starts_with(canonical_workspace)
565 }
566
567 fn path_is_existing_dir(path: &Path) -> bool {
568 match fs::symlink_metadata(path) {
569 Ok(meta) if meta.file_type().is_symlink() => {
570 fs::canonicalize(path).ok().is_some_and(|p| p.is_dir())
571 }
572 Ok(meta) => meta.is_dir(),
573 Err(_) => false,
574 }
575 }
576
577 fn match_compatible_project(workspace: &Path, skills_dir: &Path) -> Option<CompatibleHarness> {
578 let candidates = [
579 (
580 CompatibleHarness::Agents,
581 workspace.join(".agents").join("skills"),
582 ),
583 (
584 CompatibleHarness::FlatProjectSkills,
585 workspace.join("skills"),
586 ),
587 (
588 CompatibleHarness::OpenCode,
589 workspace.join(".opencode").join("skills"),
590 ),
591 (
592 CompatibleHarness::Claude,
593 workspace.join(".claude").join("skills"),
594 ),
595 (
596 CompatibleHarness::Cursor,
597 workspace.join(".cursor").join("skills"),
598 ),
599 (
600 CompatibleHarness::Codex,
601 workspace.join(".codex").join("skills"),
602 ),
603 ];
604 for (harness, candidate) in candidates {
605 if paths_refer_to_same_dir(&candidate, skills_dir) {
606 return Some(harness);
607 }
608 }
609 None
610 }
611
612 fn match_compatible_global(home: &Path, skills_dir: &Path) -> Option<CompatibleHarness> {
613 let candidates = [
614 (
615 CompatibleHarness::Agents,
616 home.join(".agents").join("skills"),
617 ),
618 (
619 CompatibleHarness::Claude,
620 home.join(".claude").join("skills"),
621 ),
622 (
623 CompatibleHarness::DeepSeekLegacy,
624 home.join(".deepseek").join("skills"),
625 ),
626 (CompatibleHarness::Codex, home.join(".codex").join("skills")),
627 ];
628 for (harness, candidate) in candidates {
629 if paths_refer_to_same_dir(&candidate, skills_dir) {
630 return Some(harness);
631 }
632 }
633 None
634 }
635
636 #[allow(clippy::too_many_arguments)] // catalog rows keep ownership flags explicit at call sites
637 fn push_existing(
638 roots: &mut Vec<SkillRootDescriptor>,
639 precedence: &mut usize,
640 kind: SkillRootKind,
641 access: SkillRootAccess,
642 scope: SkillScope,
643 path: PathBuf,
644 active_for_runtime: bool,
645 active_for_audit: bool,
646 id: &str,
647 ) {
648 push_descriptor(
649 roots,
650 precedence,
651 kind,
652 access,
653 scope,
654 path,
655 active_for_runtime,
656 active_for_audit,
657 id,
658 false,
659 );
660 }
661
662 #[allow(clippy::too_many_arguments)] // shared constructor for the explicit catalog table above
663 fn push_descriptor(
664 roots: &mut Vec<SkillRootDescriptor>,
665 precedence: &mut usize,
666 kind: SkillRootKind,
667 access: SkillRootAccess,
668 scope: SkillScope,
669 path: PathBuf,
670 active_for_runtime: bool,
671 active_for_audit: bool,
672 id: &str,
673 include_missing: bool,
674 ) {
675 let exists = path_is_existing_dir(&path);
676 if !include_missing && !exists {
677 return;
678 }
679 let canonical_path = fs::canonicalize(&path).ok();
680 let slot = *precedence;
681 *precedence += 1;
682 roots.push(SkillRootDescriptor {
683 id: SkillRootId(id.to_string()),
684 kind,
685 access,
686 scope,
687 path,
688 canonical_path,
689 precedence: Some(slot),
690 active_for_runtime,
691 active_for_audit,
692 });
693 }
694
695 fn insert_configured_root(
696 roots: &mut Vec<SkillRootDescriptor>,
697 workspace: &Path,
698 home_dir: Option<&Path>,
699 skills_dir: &Path,
700 precedence: &mut usize,
701 ) {
702 if !path_is_existing_dir(skills_dir) {
703 return;
704 }
705 if roots
706 .iter()
707 .any(|root| paths_refer_to_same_dir(&root.path, skills_dir))
708 {
709 return;
710 }
711
712 let (kind, access, scope) = classify_configured_skills_dir(workspace, home_dir, skills_dir);
713 let workspace_root = fs::canonicalize(workspace).ok();
714 let insert_at = workspace_root
715 .as_ref()
716 .and_then(|root| {
717 roots.iter().position(|dir| {
718 fs::canonicalize(&dir.path).map_or(true, |dir| !dir.starts_with(root))
719 })
720 })
721 .unwrap_or(roots.len());
722
723 let canonical_path = fs::canonicalize(skills_dir).ok();
724 let slot = *precedence;
725 *precedence += 1;
726 let descriptor = SkillRootDescriptor {
727 id: SkillRootId(format!("configured-{slot}")),
728 kind,
729 access,
730 scope,
731 path: skills_dir.to_path_buf(),
732 canonical_path,
733 precedence: Some(slot),
734 active_for_runtime: true,
735 active_for_audit: true,
736 };
737 roots.insert(insert_at, descriptor);
738 // Re-number precedence after insertion so catalog order stays consistent.
739 for (idx, root) in roots.iter_mut().enumerate() {
740 root.precedence = Some(idx);
741 }
742 *precedence = roots.len();
743 }
744
745 #[cfg(test)]
746 mod tests {
747 use super::*;
748 use crate::skills::SkillDiscoveryMode;
749 use tempfile::TempDir;
750
751 fn write_dir(path: &Path) {
752 std::fs::create_dir_all(path).unwrap();
753 }
754
755 #[test]
756 fn runtime_compatible_preserves_historical_workspace_order() {
757 let tmp = TempDir::new().unwrap();
758 let workspace = tmp.path().join("ws");
759 let home = tmp.path().join("home");
760 write_dir(&workspace.join(".agents").join("skills"));
761 write_dir(&workspace.join("skills"));
762 write_dir(&workspace.join(".claude").join("skills"));
763 write_dir(&workspace.join(".cursor").join("skills"));
764 write_dir(&workspace.join(".codewhale").join("skills"));
765 write_dir(&workspace.join(".codex").join("skills"));
766 write_dir(&home.join(".codewhale").join("skills"));
767
768 let catalog = SkillRootCatalog::build(&workspace, Some(&home), None);
769 let dirs = catalog.runtime_directories(&workspace, SkillDiscoveryMode::Compatible);
770
771 assert_eq!(
772 dirs,
773 vec![
774 workspace.join(".agents").join("skills"),
775 workspace.join("skills"),
776 workspace.join(".claude").join("skills"),
777 workspace.join(".cursor").join("skills"),
778 workspace.join(".codewhale").join("skills"),
779 home.join(".codewhale").join("skills"),
780 ]
781 );
782 assert!(
783 !dirs
784 .iter()
785 .any(|p| p == &workspace.join(".codex").join("skills")),
786 "codex must not activate for runtime"
787 );
788 }
789
790 #[test]
791 fn audit_compatible_includes_codex_without_runtime_activation() {
792 let tmp = TempDir::new().unwrap();
793 let workspace = tmp.path().join("ws");
794 let home = tmp.path().join("home");
795 write_dir(&workspace.join(".codewhale").join("skills"));
796 write_dir(&workspace.join(".codex").join("skills"));
797 write_dir(&home.join(".codewhale").join("skills"));
798 write_dir(&home.join(".codex").join("skills"));
799
800 let catalog = SkillRootCatalog::build(&workspace, Some(&home), None);
801 let audit: Vec<_> = catalog
802 .audit_compatible_directories()
803 .into_iter()
804 .map(|r| r.path.clone())
805 .collect();
806 assert!(audit.contains(&workspace.join(".codex").join("skills")));
807 assert!(audit.contains(&home.join(".codex").join("skills")));
808
809 let runtime = catalog.runtime_directories(&workspace, SkillDiscoveryMode::Compatible);
810 assert!(!runtime.contains(&workspace.join(".codex").join("skills")));
811 assert!(!runtime.contains(&home.join(".codex").join("skills")));
812 }
813
814 #[test]
815 fn owned_roots_are_writable_and_codewhale_only() {
816 let tmp = TempDir::new().unwrap();
817 let workspace = tmp.path().join("ws");
818 let home = tmp.path().join("home");
819 write_dir(&workspace.join(".agents").join("skills"));
820 write_dir(&workspace.join(".codewhale").join("skills"));
821 write_dir(&home.join(".codewhale").join("skills"));
822 write_dir(&home.join(".agents").join("skills"));
823
824 let catalog = SkillRootCatalog::build(&workspace, Some(&home), None);
825 let owned = catalog.owned_writable_roots();
826 assert_eq!(owned.len(), 2);
827 assert!(owned.iter().all(|r| r.is_writable_owned()));
828
829 let runtime = catalog.runtime_directories(&workspace, SkillDiscoveryMode::CodeWhaleOnly);
830 assert_eq!(
831 runtime,
832 vec![
833 workspace.join(".codewhale").join("skills"),
834 home.join(".codewhale").join("skills"),
835 ]
836 );
837 }
838
839 #[test]
840 fn configured_compatible_path_stays_read_only() {
841 let tmp = TempDir::new().unwrap();
842 let workspace = tmp.path().join("ws");
843 let home = tmp.path().join("home");
844 let agents = workspace.join(".agents").join("skills");
845 write_dir(&workspace);
846 write_dir(&agents);
847
848 let (kind, access, scope) =
849 classify_configured_skills_dir(&workspace, Some(&home), &agents);
850 assert_eq!(
851 kind,
852 SkillRootKind::CompatibleProject(CompatibleHarness::Agents)
853 );
854 assert_eq!(access, SkillRootAccess::ReadOnlyExternal);
855 assert_eq!(scope, SkillScope::Project);
856 }
857
858 #[test]
859 fn safe_display_path_prefers_home_then_workspace() {
860 let home = PathBuf::from("/home/user");
861 let workspace = home.join("proj");
862 let path = home.join(".codewhale").join("skills");
863 assert_eq!(
864 safe_display_path(&path, Some(&workspace), Some(&home)),
865 "~/.codewhale/skills"
866 );
867 let project = workspace.join(".codewhale").join("skills");
868 assert_eq!(
869 safe_display_path(&project, Some(&workspace), Some(&home)),
870 "<workspace>/.codewhale/skills"
871 );
872 }
873 }
874
874 lines RUST