//! Skill use cases (ARCHITECTURE §14.2; L12). //! //! - **CRUD** in either scope: [`CreateSkill`], [`UpdateSkill`], [`DeleteSkill`], //! [`ListSkills`]. //! - **Assignment**: [`AssignSkillToAgent`] / [`UnassignSkillFromAgent`] mutate //! the project manifest entry's `skills` and announce //! [`DomainEvent::SkillAssigned`]. Both are idempotent. use std::sync::Arc; use domain::ports::{AgentContextStore, EventBus, IdGenerator, SkillStore}; use domain::{ AgentId, AgentManifest, DomainEvent, MarkdownDoc, Project, ProjectPath, Skill, SkillId, SkillRef, SkillScope, }; use crate::error::AppError; // --------------------------------------------------------------------------- // CreateSkill // --------------------------------------------------------------------------- /// Input for [`CreateSkill::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CreateSkillInput { /// Display name (also the `.md` stem on disk). pub name: String, /// Optional one-line affordance description (feature « skills à la MCP »). /// `None`/empty ⇒ the skill falls back to the first line of its body when /// surfaced (see [`domain::Skill::effective_description`]). pub description: Option, /// Initial Markdown body. pub content: String, /// Scope the skill is created in (selects its backing store). pub scope: SkillScope, /// Active project root (used only for [`SkillScope::Project`]). pub project_root: ProjectPath, } /// Output of [`CreateSkill::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CreateSkillOutput { /// The created skill. pub skill: Skill, } /// Creates a skill in the store of its [`SkillScope`]. pub struct CreateSkill { skills: Arc, ids: Arc, } impl CreateSkill { /// Builds the use case from its ports. #[must_use] pub fn new(skills: Arc, ids: Arc) -> Self { Self { skills, ids } } /// Executes creation. /// /// # Errors /// - [`AppError::Invalid`] if `name`/`content` is empty, /// - [`AppError::Store`] on persistence failure. pub async fn execute(&self, input: CreateSkillInput) -> Result { let id = SkillId::from_uuid(self.ids.new_uuid()); let skill = Skill::new(id, input.name, MarkdownDoc::new(input.content), input.scope) .map_err(|e| AppError::Invalid(e.to_string()))? .with_description(input.description); self.skills.save(&skill, &input.project_root).await?; Ok(CreateSkillOutput { skill }) } } // --------------------------------------------------------------------------- // UpdateSkill // --------------------------------------------------------------------------- /// Input for [`UpdateSkill::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct UpdateSkillInput { /// Scope the skill lives in. pub scope: SkillScope, /// Skill to update. pub skill_id: SkillId, /// New Markdown body. pub content: String, /// Active project root (used only for [`SkillScope::Project`]). pub project_root: ProjectPath, } /// Output of [`UpdateSkill::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct UpdateSkillOutput { /// The updated skill. pub skill: Skill, } /// Replaces a skill's content (re-validating the non-empty invariant). pub struct UpdateSkill { skills: Arc, } impl UpdateSkill { /// Builds the use case. #[must_use] pub fn new(skills: Arc) -> Self { Self { skills } } /// Executes the update. /// /// # Errors /// - [`AppError::NotFound`] if the skill is unknown in that scope, /// - [`AppError::Invalid`] if the new content is empty, /// - [`AppError::Store`] on persistence failure. pub async fn execute(&self, input: UpdateSkillInput) -> Result { let current = self .skills .get(input.scope, &input.project_root, input.skill_id) .await?; let updated = current .with_content(MarkdownDoc::new(input.content)) .map_err(|e| AppError::Invalid(e.to_string()))?; self.skills.save(&updated, &input.project_root).await?; Ok(UpdateSkillOutput { skill: updated }) } } // --------------------------------------------------------------------------- // ListSkills / DeleteSkill // --------------------------------------------------------------------------- /// Input for [`ListSkills::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ListSkillsInput { /// Scope to list. pub scope: SkillScope, /// Active project root (used only for [`SkillScope::Project`]). pub project_root: ProjectPath, } /// Output of [`ListSkills::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ListSkillsOutput { /// All skills in the requested scope. pub skills: Vec, } /// Lists the skills in one scope. pub struct ListSkills { skills: Arc, } impl ListSkills { /// Builds the use case. #[must_use] pub fn new(skills: Arc) -> Self { Self { skills } } /// Lists skills in `input.scope`. /// /// # Errors /// [`AppError::Store`] on persistence failure. pub async fn execute(&self, input: ListSkillsInput) -> Result { Ok(ListSkillsOutput { skills: self.skills.list(input.scope, &input.project_root).await?, }) } } /// Input for [`DeleteSkill::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DeleteSkillInput { /// Scope the skill lives in. pub scope: SkillScope, /// Skill to delete. pub skill_id: SkillId, /// Active project root (used only for [`SkillScope::Project`]). pub project_root: ProjectPath, } /// Deletes a skill from its scope's store. /// /// Agents that referenced it keep their [`SkillRef`]; the injection step simply /// finds nothing to resolve for the now-absent skill and skips it. pub struct DeleteSkill { skills: Arc, } impl DeleteSkill { /// Builds the use case. #[must_use] pub fn new(skills: Arc) -> Self { Self { skills } } /// Deletes the skill. /// /// # Errors /// - [`AppError::NotFound`] if the skill is unknown in that scope, /// - [`AppError::Store`] on persistence failure. pub async fn execute(&self, input: DeleteSkillInput) -> Result<(), AppError> { self.skills .delete(input.scope, &input.project_root, input.skill_id) .await?; Ok(()) } } // --------------------------------------------------------------------------- // ReadSkill // --------------------------------------------------------------------------- /// Input for [`ReadSkill::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ReadSkillInput { /// Skill display name to resolve (case-insensitive). pub name: String, /// Active project root (used for the [`SkillScope::Project`] lookup). pub project_root: ProjectPath, } /// Reads a skill's Markdown body **by name** — the application side of the MCP /// `idea_skill_read` tool (feature « skills à la MCP »). /// /// Resolution follows the affordance contract: **project scope first, then /// global** (a project skill shadows a global one of the same name). It composes /// the **existing** [`SkillStore`] port only — no new port. Read-only. pub struct ReadSkill { skills: Arc, } impl ReadSkill { /// Builds the use case from the existing skill store port. #[must_use] pub fn new(skills: Arc) -> Self { Self { skills } } /// Resolves `name` in `scope`, returning the single match. /// /// `Ok(None)` ⇒ no skill by that name in this scope; `Err(Invalid)` ⇒ the /// name is **ambiguous** (more than one skill shares it in this scope). async fn resolve_in( &self, scope: SkillScope, input: &ReadSkillInput, ) -> Result, AppError> { let all = self.skills.list(scope, &input.project_root).await?; let mut matches = all .into_iter() .filter(|s| s.name.eq_ignore_ascii_case(&input.name)); match (matches.next(), matches.next()) { (None, _) => Ok(None), (Some(skill), None) => Ok(Some(skill)), (Some(_), Some(_)) => Err(AppError::Invalid(format!( "skill name `{}` is ambiguous in {scope:?} scope (several skills share it)", input.name ))), } } /// Resolves the skill by name and returns its Markdown body. /// /// # Errors /// - [`AppError::Invalid`] if the name is ambiguous within a scope, /// - [`AppError::NotFound`] if no skill carries that name in either scope, /// - [`AppError::Store`] on a store failure. pub async fn execute(&self, input: ReadSkillInput) -> Result { // Project scope shadows global: try it first, then fall back to global. if let Some(skill) = self.resolve_in(SkillScope::Project, &input).await? { return Ok(skill.content_md); } if let Some(skill) = self.resolve_in(SkillScope::Global, &input).await? { return Ok(skill.content_md); } Err(AppError::NotFound(format!("skill `{}`", input.name))) } } // --------------------------------------------------------------------------- // AssignSkillToAgent / UnassignSkillFromAgent // --------------------------------------------------------------------------- /// Input for [`AssignSkillToAgent::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct AssignSkillToAgentInput { /// The owning project. pub project: Project, /// The agent receiving the skill. pub agent_id: AgentId, /// The skill to assign. pub skill: SkillRef, } /// Assigns a skill to an agent by recording a [`SkillRef`] in its manifest entry. /// Idempotent: re-assigning the same skill is a no-op (no duplicate). pub struct AssignSkillToAgent { contexts: Arc, events: Arc, } impl AssignSkillToAgent { /// Builds the use case from its ports. #[must_use] pub fn new(contexts: Arc, events: Arc) -> Self { Self { contexts, events } } /// Executes the assignment. /// /// # Errors /// - [`AppError::NotFound`] if the agent is unknown to the project, /// - [`AppError::Invalid`] if the resulting manifest is invalid, /// - [`AppError::Store`] on persistence failure. pub async fn execute(&self, input: AssignSkillToAgentInput) -> Result<(), AppError> { let mut manifest = self.contexts.load_manifest(&input.project).await?; let entry = manifest .entries .iter_mut() .find(|e| e.agent_id == input.agent_id) .ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?; // Mutate through the domain entity so the dedup invariant is enforced in // one place, then fold the result back into the manifest entry. let mut agent = entry .to_agent() .map_err(|e| AppError::Invalid(e.to_string()))?; let changed = agent.assign_skill(input.skill); *entry = domain::ManifestEntry::from_agent(&agent); if changed { self.persist_and_announce( &input.project, manifest, input.agent_id, input.skill.skill_id, true, ) .await?; } Ok(()) } /// Saves the manifest and announces the assignment change. async fn persist_and_announce( &self, project: &Project, manifest: AgentManifest, agent_id: AgentId, skill_id: SkillId, assigned: bool, ) -> Result<(), AppError> { let manifest = AgentManifest::new(manifest.version, manifest.entries) .map_err(|e| AppError::Invalid(e.to_string()))?; self.contexts.save_manifest(project, &manifest).await?; self.events.publish(DomainEvent::SkillAssigned { agent_id, skill_id, assigned, }); Ok(()) } } /// Input for [`UnassignSkillFromAgent::execute`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct UnassignSkillFromAgentInput { /// The owning project. pub project: Project, /// The agent losing the skill. pub agent_id: AgentId, /// The skill to unassign. pub skill_id: SkillId, } /// Removes a skill assignment from an agent. Idempotent: unassigning a skill the /// agent does not carry is a no-op. pub struct UnassignSkillFromAgent { contexts: Arc, events: Arc, } impl UnassignSkillFromAgent { /// Builds the use case from its ports. #[must_use] pub fn new(contexts: Arc, events: Arc) -> Self { Self { contexts, events } } /// Executes the un-assignment. /// /// # Errors /// - [`AppError::NotFound`] if the agent is unknown to the project, /// - [`AppError::Invalid`] if the resulting manifest is invalid, /// - [`AppError::Store`] on persistence failure. pub async fn execute(&self, input: UnassignSkillFromAgentInput) -> Result<(), AppError> { let mut manifest = self.contexts.load_manifest(&input.project).await?; let entry = manifest .entries .iter_mut() .find(|e| e.agent_id == input.agent_id) .ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?; let mut agent = entry .to_agent() .map_err(|e| AppError::Invalid(e.to_string()))?; let changed = agent.unassign_skill(input.skill_id); *entry = domain::ManifestEntry::from_agent(&agent); if changed { let manifest = AgentManifest::new(manifest.version, manifest.entries) .map_err(|e| AppError::Invalid(e.to_string()))?; self.contexts .save_manifest(&input.project, &manifest) .await?; self.events.publish(DomainEvent::SkillAssigned { agent_id: input.agent_id, skill_id: input.skill_id, assigned: false, }); } Ok(()) } } #[cfg(test)] mod tests { use super::*; use std::sync::Mutex; use async_trait::async_trait; use domain::ports::{SkillStore, StoreError}; /// In-memory [`SkillStore`] fake: skills are bucketed by scope, ignoring the /// project root (the [`ReadSkill`] resolution logic is root-agnostic — it only /// switches scope). Enough to exercise project-first resolution, shadowing, /// ambiguity and absence without touching disk. #[derive(Default)] struct FakeSkillStore { global: Mutex>, project: Mutex>, } impl FakeSkillStore { fn bucket(&self, scope: SkillScope) -> &Mutex> { match scope { SkillScope::Global => &self.global, SkillScope::Project => &self.project, } } fn push(&self, skill: Skill) { self.bucket(skill.scope).lock().unwrap().push(skill); } } #[async_trait] impl SkillStore for FakeSkillStore { async fn list( &self, scope: SkillScope, _root: &ProjectPath, ) -> Result, StoreError> { Ok(self.bucket(scope).lock().unwrap().clone()) } async fn get( &self, scope: SkillScope, _root: &ProjectPath, id: SkillId, ) -> Result { self.bucket(scope) .lock() .unwrap() .iter() .find(|s| s.id == id) .cloned() .ok_or(StoreError::NotFound) } async fn save(&self, skill: &Skill, _root: &ProjectPath) -> Result<(), StoreError> { self.push(skill.clone()); Ok(()) } async fn delete( &self, _scope: SkillScope, _root: &ProjectPath, _id: SkillId, ) -> Result<(), StoreError> { Ok(()) } } fn root() -> ProjectPath { ProjectPath::new("/proj").unwrap() } fn skill(id: u128, name: &str, body: &str, scope: SkillScope) -> Skill { Skill::new( SkillId::from_uuid(uuid::Uuid::from_u128(id)), name, MarkdownDoc::new(body), scope, ) .unwrap() } fn read_skill_uc(store: Arc) -> ReadSkill { ReadSkill::new(store) } #[tokio::test] async fn read_skill_resolves_project_scope() { // (a) skill present in the project scope ⇒ its body is returned. let store = Arc::new(FakeSkillStore::default()); store.push(skill(1, "deploy", "PROJECT_BODY", SkillScope::Project)); let body = read_skill_uc(store) .execute(ReadSkillInput { name: "deploy".to_owned(), project_root: root(), }) .await .unwrap(); assert_eq!(body.as_str(), "PROJECT_BODY"); } #[tokio::test] async fn read_skill_falls_back_to_global_scope() { // (b) absent from project but present globally ⇒ resolved via global. let store = Arc::new(FakeSkillStore::default()); store.push(skill(1, "deploy", "GLOBAL_BODY", SkillScope::Global)); let body = read_skill_uc(store) .execute(ReadSkillInput { name: "deploy".to_owned(), project_root: root(), }) .await .unwrap(); assert_eq!(body.as_str(), "GLOBAL_BODY"); } #[tokio::test] async fn read_skill_project_shadows_global() { // (c) same name in both scopes ⇒ project wins. let store = Arc::new(FakeSkillStore::default()); store.push(skill(1, "deploy", "GLOBAL_BODY", SkillScope::Global)); store.push(skill(2, "deploy", "PROJECT_BODY", SkillScope::Project)); let body = read_skill_uc(store) .execute(ReadSkillInput { name: "deploy".to_owned(), project_root: root(), }) .await .unwrap(); assert_eq!(body.as_str(), "PROJECT_BODY"); } #[tokio::test] async fn read_skill_is_case_insensitive() { let store = Arc::new(FakeSkillStore::default()); store.push(skill(1, "Deploy", "BODY", SkillScope::Project)); let body = read_skill_uc(store) .execute(ReadSkillInput { name: "deploy".to_owned(), project_root: root(), }) .await .unwrap(); assert_eq!(body.as_str(), "BODY"); } #[tokio::test] async fn read_skill_unknown_is_not_found() { // (d) unknown in both scopes ⇒ NotFound. let store = Arc::new(FakeSkillStore::default()); let err = read_skill_uc(store) .execute(ReadSkillInput { name: "ghost".to_owned(), project_root: root(), }) .await .unwrap_err(); assert!(matches!(err, AppError::NotFound(_)), "got {err:?}"); } #[tokio::test] async fn read_skill_ambiguous_name_is_invalid() { // (e) two skills share a name within a scope ⇒ Invalid (ambiguous). let store = Arc::new(FakeSkillStore::default()); store.push(skill(1, "deploy", "ONE", SkillScope::Project)); store.push(skill(2, "Deploy", "TWO", SkillScope::Project)); let err = read_skill_uc(store) .execute(ReadSkillInput { name: "deploy".to_owned(), project_root: root(), }) .await .unwrap_err(); assert!(matches!(err, AppError::Invalid(_)), "got {err:?}"); } }