1use serde::{Deserialize, Serialize};
4use thiserror::Error;
5
6use oris_evolution::{MutationIntent, MutationTarget, RiskLevel};
7
8pub type SpecId = String;
9pub type SpecVersion = String;
10
11#[derive(Clone, Debug, Serialize, Deserialize)]
12pub struct SpecConstraint {
13 pub key: String,
14 pub value: String,
15}
16
17#[derive(Clone, Debug, Serialize, Deserialize)]
18pub struct SpecMutation {
19 pub strategy: String,
20}
21
22#[derive(Clone, Debug, Serialize, Deserialize)]
23pub struct SpecDocument {
24 pub id: SpecId,
25 pub version: SpecVersion,
26 pub intent: String,
27 #[serde(default)]
28 pub signals: Vec<String>,
29 #[serde(default)]
30 pub constraints: Vec<SpecConstraint>,
31 pub mutation: SpecMutation,
32 #[serde(default)]
33 pub validation: Vec<String>,
34}
35
36#[derive(Clone, Debug, Serialize, Deserialize)]
37pub struct CompiledMutationPlan {
38 pub mutation_intent: MutationIntent,
39 pub validation_profile: String,
40}
41
42#[derive(Debug, Error)]
43pub enum SpecCompileError {
44 #[error("spec parse error: {0}")]
45 Parse(String),
46 #[error("invalid spec: {0}")]
47 Invalid(String),
48}
49
50pub struct SpecCompiler;
51
52impl SpecCompiler {
53 pub fn from_yaml(input: &str) -> Result<SpecDocument, SpecCompileError> {
54 serde_yaml::from_str(input).map_err(|err| SpecCompileError::Parse(err.to_string()))
55 }
56
57 pub fn compile(doc: &SpecDocument) -> Result<CompiledMutationPlan, SpecCompileError> {
58 if doc.id.trim().is_empty() {
59 return Err(SpecCompileError::Invalid("spec id cannot be empty".into()));
60 }
61 if doc.intent.trim().is_empty() {
62 return Err(SpecCompileError::Invalid(
63 "spec intent cannot be empty".into(),
64 ));
65 }
66 if doc.mutation.strategy.trim().is_empty() {
67 return Err(SpecCompileError::Invalid(
68 "spec mutation strategy cannot be empty".into(),
69 ));
70 }
71
72 Ok(CompiledMutationPlan {
73 mutation_intent: MutationIntent {
74 id: format!("spec-{}", doc.id),
75 intent: doc.intent.clone(),
76 target: MutationTarget::WorkspaceRoot,
77 expected_effect: doc.mutation.strategy.clone(),
78 risk: RiskLevel::Low,
79 signals: doc.signals.clone(),
80 spec_id: Some(doc.id.clone()),
81 },
82 validation_profile: if doc.validation.is_empty() {
83 "spec-default".into()
84 } else {
85 doc.validation.join(",")
86 },
87 })
88 }
89}
90
91#[cfg(test)]
92mod tests {
93 use super::*;
94
95 const SAMPLE_SPEC: &str = r#"
96id: example-spec
97version: "1.0"
98intent: Fix borrow checker error
99signals:
100 - rust borrow error
101constraints:
102 - key: crate
103 value: oris-kernel
104mutation:
105 strategy: tighten_borrow_scope
106validation:
107 - cargo check
108"#;
109
110 #[test]
111 fn test_spec_compiler_from_yaml() {
112 let doc = SpecCompiler::from_yaml(SAMPLE_SPEC).unwrap();
113 assert_eq!(doc.id, "example-spec");
114 assert_eq!(doc.version, "1.0");
115 assert_eq!(doc.signals.len(), 1);
116 }
117
118 #[test]
119 fn test_spec_compiler_compile() {
120 let doc = SpecCompiler::from_yaml(SAMPLE_SPEC).unwrap();
121 let plan = SpecCompiler::compile(&doc).unwrap();
122 assert!(plan.mutation_intent.id.starts_with("spec-"));
123 assert_eq!(plan.validation_profile, "cargo check");
124 }
125
126 #[test]
127 fn test_spec_compile_empty_id() {
128 let doc = SpecDocument {
129 id: "".into(),
130 version: "1.0".into(),
131 intent: "test".into(),
132 signals: vec![],
133 constraints: vec![],
134 mutation: SpecMutation {
135 strategy: "test".into(),
136 },
137 validation: vec![],
138 };
139 let result = SpecCompiler::compile(&doc);
140 assert!(result.is_err());
141 }
142
143 #[test]
144 fn test_spec_compile_empty_intent() {
145 let doc = SpecDocument {
146 id: "test".into(),
147 version: "1.0".into(),
148 intent: "".into(),
149 signals: vec![],
150 constraints: vec![],
151 mutation: SpecMutation {
152 strategy: "test".into(),
153 },
154 validation: vec![],
155 };
156 let result = SpecCompiler::compile(&doc);
157 assert!(result.is_err());
158 }
159
160 #[test]
161 fn test_spec_compile_empty_strategy() {
162 let doc = SpecDocument {
163 id: "test".into(),
164 version: "1.0".into(),
165 intent: "test".into(),
166 signals: vec![],
167 constraints: vec![],
168 mutation: SpecMutation {
169 strategy: "".into(),
170 },
171 validation: vec![],
172 };
173 let result = SpecCompiler::compile(&doc);
174 assert!(result.is_err());
175 }
176
177 #[test]
178 fn test_default_validation_profile() {
179 let doc = SpecDocument {
180 id: "test".into(),
181 version: "1.0".into(),
182 intent: "test".into(),
183 signals: vec![],
184 constraints: vec![],
185 mutation: SpecMutation {
186 strategy: "test".into(),
187 },
188 validation: vec![],
189 };
190 let plan = SpecCompiler::compile(&doc).unwrap();
191 assert_eq!(plan.validation_profile, "spec-default");
192 }
193}