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}