//! Skill entity — reusable, model-agnostic workflows assignable to agents. //! //! A [`Skill`] is IdeA's universal equivalent of a CLI's slash-command, but //! without any dependency on a particular model's `/command` syntax //! (ARCHITECTURE §14.2). Assigned skills are injected as plain text into the //! agent's generated convention file at activation — there is no proprietary //! CLI mechanism involved. use serde::{Deserialize, Serialize}; use crate::error::DomainError; use crate::ids::SkillId; use crate::markdown::MarkdownDoc; /// Where a skill lives, which also selects the store used to resolve it. /// /// - [`SkillScope::Global`] skills are stored in the global IDE store /// (`/IdeA/skills/`) and reusable across projects. /// - [`SkillScope::Project`] skills are stored under `.ideai/skills/` and are /// specific to one project. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum SkillScope { /// Reusable across projects (global IDE store). Global, /// Specific to a single project (`.ideai/skills/`). Project, } /// A reusable workflow assignable to one or more agents. /// /// Invariants enforced here: /// - `name` non-empty, /// - `content_md` non-empty (an empty skill carries no behaviour). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Skill { /// Stable identifier. pub id: SkillId, /// Display name (also used as the `.md` file stem on disk). pub name: String, /// Optional one-line description surfaced as an *affordance* at high altitude /// in an agent's convention file (« à la MCP »), so the agent knows the skill /// exists and what it does without reading the whole body. `None`/empty ⇒ /// [`Skill::effective_description`] falls back to the first line of the body. #[serde(default)] pub description: Option, /// Markdown body — the workflow injected into an agent's convention file. pub content_md: MarkdownDoc, /// Scope (selects the backing store). pub scope: SkillScope, } impl Skill { /// Builds a validated skill. /// /// # Errors /// - [`DomainError::EmptyField`] if `name` is empty, /// - [`DomainError::EmptyField`] if `content_md` is empty. pub fn new( id: SkillId, name: impl Into, content_md: MarkdownDoc, scope: SkillScope, ) -> Result { let name = name.into(); crate::validation::non_empty(&name, "skill.name")?; if content_md.is_empty() { return Err(DomainError::EmptyField { field: "skill.content_md", }); } Ok(Self { id, name, description: None, content_md, scope, }) } /// Returns this skill with its one-line [`Skill::description`] set (builder). /// /// Kept separate from [`Skill::new`] so every existing call site is untouched /// (the field is purely additive). An empty/whitespace description is treated /// as absent by [`Skill::effective_description`], so no normalisation here. #[must_use] pub fn with_description(mut self, description: Option) -> Self { self.description = description; self } /// The description to surface for this skill, with a deterministic fallback. /// /// Returns [`Skill::description`] when present and non-blank (trimmed); /// otherwise the **first non-empty line** of the body, stripped of any leading /// `#` (Markdown heading marker) and trimmed. When the body has no usable line /// either, returns an **empty `String`** (chosen over `Option` so callers can /// format it unconditionally — an empty affordance line is harmless). #[must_use] pub fn effective_description(&self) -> String { if let Some(desc) = &self.description { let trimmed = desc.trim(); if !trimmed.is_empty() { return trimmed.to_owned(); } } self.content_md .as_str() .lines() .map(str::trim) .find(|line| !line.is_empty()) .map(|line| line.trim_start_matches('#').trim().to_owned()) .unwrap_or_default() } /// Returns a copy of this skill with replaced content, re-validating the /// non-empty invariant and **preserving** the [`Skill::description`]. /// /// # Errors /// [`DomainError::EmptyField`] if `content_md` is empty. pub fn with_content(&self, content_md: MarkdownDoc) -> Result { Ok( Skill::new(self.id, self.name.clone(), content_md, self.scope)? .with_description(self.description.clone()), ) } } /// A reference from an agent to one assigned skill. /// /// Stored in the [`crate::agent::ManifestEntry`]: an agent carries 0..N of these. /// The `scope` is kept alongside the id so the application layer knows which /// store to resolve the skill from without a global lookup. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SkillRef { /// The assigned skill. pub skill_id: SkillId, /// Scope of the assigned skill (selects its store). pub scope: SkillScope, } impl SkillRef { /// Builds a reference to an assigned skill. #[must_use] pub const fn new(skill_id: SkillId, scope: SkillScope) -> Self { Self { skill_id, scope } } } impl From<&Skill> for SkillRef { fn from(skill: &Skill) -> Self { Self::new(skill.id, skill.scope) } } #[cfg(test)] mod tests { use super::*; use crate::ids::SkillId; fn skill(body: &str) -> Skill { Skill::new( SkillId::from_uuid(uuid::Uuid::from_u128(1)), "demo", MarkdownDoc::new(body), SkillScope::Global, ) .unwrap() } // -- effective_description -------------------------------------------- #[test] fn effective_description_returns_explicit_description_trimmed() { // (a) explicit non-empty description ⇒ returned trimmed. let s = skill("# Heading\n\nbody").with_description(Some(" Refactors code ".to_owned())); assert_eq!(s.effective_description(), "Refactors code"); } #[test] fn effective_description_falls_back_to_first_body_line_without_hash() { // (b) description None ⇒ first non-empty body line, `#` stripped & trimmed. let s = skill("\n\n# My Skill Title \n\nrest of body"); assert_eq!(s.effective_description(), "My Skill Title"); } #[test] fn effective_description_first_line_without_heading_marker() { // (b') first non-empty line that is not a heading is returned verbatim (trimmed). let s = skill(" just a plain first line\nsecond"); assert_eq!(s.effective_description(), "just a plain first line"); } #[test] fn effective_description_blank_body_yields_empty_string() { // (c) body that is only whitespace ⇒ empty String. let s = skill(" \n\t\n "); assert_eq!(s.effective_description(), ""); } #[test] fn effective_description_blank_description_falls_back_to_body() { // (d) description Some("") / whitespace-only ⇒ treated as absent ⇒ body fallback. let empty = skill("# Body Heading").with_description(Some(String::new())); assert_eq!(empty.effective_description(), "Body Heading"); let spaces = skill("# Body Heading").with_description(Some(" \t ".to_owned())); assert_eq!(spaces.effective_description(), "Body Heading"); } // -- with_content preserves description ------------------------------- #[test] fn with_content_preserves_description() { let s = skill("old body").with_description(Some("kept".to_owned())); let updated = s.with_content(MarkdownDoc::new("new body")).unwrap(); assert_eq!(updated.description.as_deref(), Some("kept")); assert_eq!(updated.content_md.as_str(), "new body"); } #[test] fn with_content_rejects_empty_body() { let s = skill("old body"); assert!(s.with_content(MarkdownDoc::new("")).is_err()); } // -- serde: description is additive / defaults to None ---------------- #[test] fn deserialize_legacy_skill_without_description_defaults_to_none() { // A skill JSON written before `description` existed must deserialise with // `description: None` thanks to `#[serde(default)]`. let id = uuid::Uuid::from_u128(7); let json = format!(r##"{{"id":"{id}","name":"legacy","contentMd":"# body","scope":"global"}}"##); let parsed: Skill = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.description, None); assert_eq!(parsed.name, "legacy"); } #[test] fn serde_round_trip_with_description() { let original = skill("# body").with_description(Some("affordance".to_owned())); let json = serde_json::to_string(&original).unwrap(); let back: Skill = serde_json::from_str(&json).unwrap(); assert_eq!(back, original); assert_eq!(back.description.as_deref(), Some("affordance")); } }