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