use crate::error::AgentConfigError;
use super::validate::{validate_identifier, IdentifierKind};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum InstructionPlacement {
InlineBlock,
ReferencedFile,
StandaloneFile,
}
#[derive(Debug, Clone)]
pub struct InstructionSpec {
pub name: String,
pub owner_tag: String,
pub placement: InstructionPlacement,
pub body: String,
pub adopt_unowned: bool,
}
impl InstructionSpec {
pub fn builder(name: impl Into<String>) -> InstructionSpecBuilder {
InstructionSpecBuilder {
name: name.into(),
owner_tag: None,
placement: InstructionPlacement::ReferencedFile,
body: String::new(),
adopt_unowned: false,
}
}
pub(crate) fn validate(&self) -> Result<(), AgentConfigError> {
Self::validate_name(&self.name)?;
validate_identifier(&self.owner_tag, IdentifierKind::OwnerTag)?;
if self.body.trim().is_empty() {
return Err(AgentConfigError::MissingSpecField {
id: "<instruction>",
field: "body",
});
}
Ok(())
}
pub(crate) fn validate_name(name: &str) -> Result<(), AgentConfigError> {
validate_identifier(name, IdentifierKind::InstructionName)
}
}
#[derive(Debug, Clone)]
pub struct InstructionSpecBuilder {
name: String,
owner_tag: Option<String>,
placement: InstructionPlacement,
body: String,
adopt_unowned: bool,
}
impl InstructionSpecBuilder {
pub fn owner(mut self, tag: impl Into<String>) -> Self {
self.owner_tag = Some(tag.into());
self
}
pub fn adopt_unowned(mut self, adopt: bool) -> Self {
self.adopt_unowned = adopt;
self
}
pub fn placement(mut self, p: InstructionPlacement) -> Self {
self.placement = p;
self
}
pub fn body(mut self, body: impl Into<String>) -> Self {
self.body = body.into();
self
}
pub fn build(self) -> InstructionSpec {
self.try_build()
.expect("InstructionSpec missing required field")
}
pub fn try_build(self) -> Result<InstructionSpec, AgentConfigError> {
let owner_tag = self.owner_tag.ok_or(AgentConfigError::MissingSpecField {
id: "<instruction>",
field: "owner",
})?;
let spec = InstructionSpec {
name: self.name,
owner_tag,
placement: self.placement,
body: self.body,
adopt_unowned: self.adopt_unowned,
};
spec.validate()?;
Ok(spec)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn invalid_name_error_mentions_instruction_not_mcp() {
let err = InstructionSpec::builder("bad name")
.owner("owner")
.body("body")
.try_build()
.expect_err("space in name must be rejected");
let AgentConfigError::InvalidTag { reason, .. } = err else {
panic!("expected InvalidTag, got {err:?}");
};
assert!(
reason.contains("instruction name"),
"error reason should reference instruction name, got: {reason}"
);
assert!(
!reason.contains("MCP"),
"error reason must not mention MCP for an instruction failure: {reason}"
);
}
#[test]
fn empty_name_error_mentions_instruction() {
let err = InstructionSpec::builder("")
.owner("owner")
.body("body")
.try_build()
.expect_err("empty name must be rejected");
let AgentConfigError::InvalidTag { reason, .. } = err else {
panic!("expected InvalidTag, got {err:?}");
};
assert!(
reason.contains("instruction name"),
"empty-name reason should reference instruction name, got: {reason}"
);
}
}