Skip to main content

oris_spec/
lib.rs

1//! OUSL v0.1 YAML spec contracts.
2
3use 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}