Skip to main content

agent_config/spec/
instruction.rs

1//! Caller-supplied description of a standalone instruction file to install.
2
3use crate::error::AgentConfigError;
4
5use super::validate::{validate_identifier, IdentifierKind};
6
7/// How an instruction file should be placed relative to the agent's config.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9#[non_exhaustive]
10pub enum InstructionPlacement {
11    /// Inject content as a managed markdown block inside a shared file
12    /// (reuses `md_block::upsert_instruction`/`md_block::remove_instruction`
13    /// with the `AGENT-CONFIG-INSTR` fence prefix and the instruction name
14    /// as the tag).
15    InlineBlock,
16
17    /// Write a standalone file and add a managed include reference line
18    /// (e.g. `@MYAPP.md` in `~/.claude/CLAUDE.md`). The reference itself
19    /// is managed as a fenced markdown block so it can be removed cleanly.
20    ReferencedFile,
21
22    /// Write a standalone file only, no include reference.
23    /// For agents with rules directories where the file's presence alone
24    /// causes the agent to load it (e.g. `.roo/rules/MYAPP.md`).
25    StandaloneFile,
26}
27
28/// Caller-supplied description of a standalone instruction file to install.
29///
30/// Instructions differ from hook rules: they are named, standalone files
31/// that persist across sessions (like `~/.claude/MYAPP.md`). They may be
32/// referenced via include directives from the agent's memory file, or
33/// placed directly in a rules directory.
34///
35/// Build via [`InstructionSpec::builder`].
36#[derive(Debug, Clone)]
37pub struct InstructionSpec {
38    /// Instruction name. Used as the filename stem (e.g. `MYAPP` becomes
39    /// `MYAPP.md`). Must be ASCII alnum / `_` / `-`, non-empty.
40    ///
41    /// **Fence prefix.** For [`InstructionPlacement::ReferencedFile`] and
42    /// [`InstructionPlacement::InlineBlock`], `name` is reused as the fence
43    /// tag in the host markdown file using a distinct instruction-only
44    /// prefix:
45    /// `<!-- BEGIN AGENT-CONFIG-INSTR:<name> --> ... <!-- END AGENT-CONFIG-INSTR:<name> -->`.
46    /// This is intentionally separate from the hook fence
47    /// (`<!-- BEGIN AGENT-CONFIG:<tag> -->`) so a hook with `tag = "T"` and
48    /// an instruction with `name = "T"` cannot overwrite each other when
49    /// they share a memory file (e.g. `~/.claude/CLAUDE.md`). Installs from
50    /// versions before this rename used the hook prefix; status detection
51    /// and uninstall accept the legacy prefix as a fallback so existing
52    /// installs drain cleanly on upgrade.
53    pub name: String,
54
55    /// The consumer of this library that owns the instruction.
56    /// Recorded in the sidecar ownership ledger; refusal to remove an
57    /// instruction owned by another consumer matches the same
58    /// [`AgentConfigError::NotOwnedByCaller`] model used for MCP and skills.
59    pub owner_tag: String,
60
61    /// How this instruction should be placed for the target agent.
62    pub placement: InstructionPlacement,
63
64    /// Markdown body of the instruction file.
65    pub body: String,
66
67    /// When true, install adopts an instruction file (or include block) that
68    /// exists on disk but has no recorded owner instead of refusing. Use
69    /// after a crash between file write and ledger record. Default `false`.
70    pub adopt_unowned: bool,
71}
72
73impl InstructionSpec {
74    /// Begin building an instruction spec with the given name.
75    pub fn builder(name: impl Into<String>) -> InstructionSpecBuilder {
76        InstructionSpecBuilder {
77            name: name.into(),
78            owner_tag: None,
79            placement: InstructionPlacement::ReferencedFile,
80            body: String::new(),
81            adopt_unowned: false,
82        }
83    }
84
85    pub(crate) fn validate(&self) -> Result<(), AgentConfigError> {
86        Self::validate_name(&self.name)?;
87        validate_identifier(&self.owner_tag, IdentifierKind::OwnerTag)?;
88        if self.body.trim().is_empty() {
89            return Err(AgentConfigError::MissingSpecField {
90                id: "<instruction>",
91                field: "body",
92            });
93        }
94        Ok(())
95    }
96
97    pub(crate) fn validate_name(name: &str) -> Result<(), AgentConfigError> {
98        validate_identifier(name, IdentifierKind::InstructionName)
99    }
100}
101
102/// Builder for [`InstructionSpec`].
103#[derive(Debug, Clone)]
104pub struct InstructionSpecBuilder {
105    name: String,
106    owner_tag: Option<String>,
107    placement: InstructionPlacement,
108    body: String,
109    adopt_unowned: bool,
110}
111
112impl InstructionSpecBuilder {
113    /// Set the owner tag. Required.
114    pub fn owner(mut self, tag: impl Into<String>) -> Self {
115        self.owner_tag = Some(tag.into());
116        self
117    }
118
119    /// Adopt an instruction file (or include block) that exists on disk but
120    /// has no recorded owner. See [`InstructionSpec::adopt_unowned`].
121    pub fn adopt_unowned(mut self, adopt: bool) -> Self {
122        self.adopt_unowned = adopt;
123        self
124    }
125
126    /// Set the placement mode. Defaults to `ReferencedFile`.
127    pub fn placement(mut self, p: InstructionPlacement) -> Self {
128        self.placement = p;
129        self
130    }
131
132    /// Set the markdown body. Required, must not be empty/whitespace.
133    pub fn body(mut self, body: impl Into<String>) -> Self {
134        self.body = body.into();
135        self
136    }
137
138    /// Consume the builder and return an [`InstructionSpec`].
139    ///
140    /// # Panics
141    ///
142    /// Panics if required fields are missing or validation fails.
143    pub fn build(self) -> InstructionSpec {
144        self.try_build()
145            .expect("InstructionSpec missing required field")
146    }
147
148    /// Consume the builder and return an [`InstructionSpec`], or an error
149    /// if required fields are missing or validation fails.
150    ///
151    /// # Errors
152    ///
153    /// - [`AgentConfigError::MissingSpecField`] with `field = "owner"` when
154    ///   [`InstructionSpecBuilder::owner`] was never called, or
155    ///   `field = "body"` when the body is empty/whitespace-only.
156    /// - [`AgentConfigError::InvalidTag`] when `name` or `owner_tag` contain
157    ///   characters outside ASCII alnum / `_` / `-`.
158    pub fn try_build(self) -> Result<InstructionSpec, AgentConfigError> {
159        let owner_tag = self.owner_tag.ok_or(AgentConfigError::MissingSpecField {
160            id: "<instruction>",
161            field: "owner",
162        })?;
163        let spec = InstructionSpec {
164            name: self.name,
165            owner_tag,
166            placement: self.placement,
167            body: self.body,
168            adopt_unowned: self.adopt_unowned,
169        };
170        spec.validate()?;
171        Ok(spec)
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn invalid_name_error_mentions_instruction_not_mcp() {
181        let err = InstructionSpec::builder("bad name")
182            .owner("owner")
183            .body("body")
184            .try_build()
185            .expect_err("space in name must be rejected");
186        let AgentConfigError::InvalidTag { reason, .. } = err else {
187            panic!("expected InvalidTag, got {err:?}");
188        };
189        assert!(
190            reason.contains("instruction name"),
191            "error reason should reference instruction name, got: {reason}"
192        );
193        assert!(
194            !reason.contains("MCP"),
195            "error reason must not mention MCP for an instruction failure: {reason}"
196        );
197    }
198
199    #[test]
200    fn empty_name_error_mentions_instruction() {
201        let err = InstructionSpec::builder("")
202            .owner("owner")
203            .body("body")
204            .try_build()
205            .expect_err("empty name must be rejected");
206        let AgentConfigError::InvalidTag { reason, .. } = err else {
207            panic!("expected InvalidTag, got {err:?}");
208        };
209        assert!(
210            reason.contains("instruction name"),
211            "empty-name reason should reference instruction name, got: {reason}"
212        );
213    }
214}