1use serde::{Deserialize, Serialize};
4use std::{
5 collections::BTreeSet,
6 fs,
7 path::{Path, PathBuf},
8};
9
10#[derive(Debug, Clone, Deserialize, Serialize)]
11#[serde(deny_unknown_fields)]
12pub struct DesignContext {
13 pub version: u32,
14 #[serde(default)]
15 pub components: Vec<Component>,
16 #[serde(default)]
17 pub rules: Vec<BusinessRule>,
18 #[serde(default)]
19 pub relationships: Vec<Relationship>,
20 #[serde(default)]
21 pub scenarios: Vec<Scenario>,
22 #[serde(default)]
23 pub decisions: Vec<Decision>,
24}
25
26impl Default for DesignContext {
27 fn default() -> Self {
28 Self {
29 version: 1,
30 components: vec![],
31 rules: vec![],
32 relationships: vec![],
33 scenarios: vec![],
34 decisions: vec![],
35 }
36 }
37}
38
39#[derive(Debug, Clone, Deserialize, Serialize)]
40#[serde(deny_unknown_fields)]
41pub struct Component {
42 pub name: String,
43 pub modules: Vec<String>,
44 #[serde(default)]
45 pub owners: Vec<String>,
46 pub build_unit: Option<String>,
47 pub test_unit: Option<String>,
48 pub release_unit: Option<String>,
49 pub planned_changes: Option<String>,
50 pub frozen_reason: Option<String>,
51 #[serde(default = "one")]
52 pub business_value: f64,
53 pub effort_days: Option<f64>,
54}
55
56fn one() -> f64 {
57 1.0
58}
59
60#[derive(Debug, Clone, Deserialize, Serialize)]
61#[serde(deny_unknown_fields)]
62pub struct BusinessRule {
63 pub id: String,
64 pub modules: Vec<String>,
65 pub description: String,
66}
67
68#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
69#[serde(rename_all = "kebab-case")]
70pub enum RelationshipKind {
71 Sequence,
72 Timing,
73 Transaction,
74 SharedState,
75 Synchronous,
76 Asynchronous,
77}
78
79#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)]
80#[serde(rename_all = "kebab-case")]
81pub enum EvidenceOrigin {
82 #[default]
83 Declared,
84 Observed,
85}
86
87#[derive(Debug, Clone, Deserialize, Serialize)]
88#[serde(deny_unknown_fields)]
89pub struct Relationship {
90 pub id: String,
91 pub source: String,
92 pub target: String,
93 pub kind: RelationshipKind,
94 pub evidence: String,
95 #[serde(default)]
96 pub origin: EvidenceOrigin,
97}
98
99#[derive(Debug, Clone, Deserialize, Serialize)]
100#[serde(deny_unknown_fields)]
101pub struct Scenario {
102 pub name: String,
103 pub description: String,
104 pub changes: Vec<ScenarioChange>,
105}
106
107#[derive(Debug, Clone, Deserialize, Serialize)]
108#[serde(deny_unknown_fields)]
109pub struct ScenarioChange {
110 pub source: String,
111 pub target: String,
112 pub strength: Option<f64>,
113 pub distance: Option<f64>,
114 pub volatility: Option<f64>,
115}
116
117#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
118#[serde(rename_all = "kebab-case")]
119pub enum ReviewTrigger {
120 PlannedChange,
121 CrossTeam,
122 NewIssue,
123 WorsenedIssue,
124 Changed,
125 ContextChanged,
126}
127
128#[derive(Debug, Clone, Deserialize, Serialize)]
129#[serde(deny_unknown_fields)]
130pub struct Decision {
131 pub id: String,
132 pub modules: Vec<String>,
133 pub reason: String,
134 pub review_on: Vec<ReviewTrigger>,
135 pub context_fingerprint: Option<String>,
137}
138
139#[derive(Debug, thiserror::Error)]
140pub enum ContextError {
141 #[error("Cannot read design context: {0}")]
142 Io(#[from] std::io::Error),
143 #[error("Invalid design context: {0}")]
144 Parse(#[from] toml::de::Error),
145 #[error("Invalid design context: {0}")]
146 Invalid(String),
147}
148
149impl DesignContext {
150 pub fn parse(source: &str) -> Result<Self, ContextError> {
151 let context: Self = toml::from_str(source)?;
152 context.validate()?;
153 Ok(context)
154 }
155
156 pub fn validate(&self) -> Result<(), ContextError> {
157 if self.version != 1 {
158 return Err(ContextError::Invalid(format!(
159 "unsupported version {}",
160 self.version
161 )));
162 }
163 unique(self.components.iter().map(|v| v.name.as_str()), "component")?;
164 unique(self.rules.iter().map(|v| v.id.as_str()), "rule")?;
165 unique(
166 self.relationships.iter().map(|v| v.id.as_str()),
167 "relationship",
168 )?;
169 unique(self.scenarios.iter().map(|v| v.name.as_str()), "scenario")?;
170 unique(self.decisions.iter().map(|v| v.id.as_str()), "decision")?;
171 for component in &self.components {
172 selectors(&component.modules)?;
173 if !component.business_value.is_finite()
174 || component.business_value <= 0.0
175 || component
176 .effort_days
177 .is_some_and(|v| !v.is_finite() || v <= 0.0)
178 {
179 return Err(ContextError::Invalid(format!(
180 "{}: business_value and effort_days must be finite and positive",
181 component.name
182 )));
183 }
184 for text in component
185 .owners
186 .iter()
187 .map(String::as_str)
188 .chain(component.planned_changes.as_deref())
189 .chain(component.frozen_reason.as_deref())
190 .chain(component.build_unit.as_deref())
191 .chain(component.test_unit.as_deref())
192 .chain(component.release_unit.as_deref())
193 {
194 nonempty(text)?;
195 }
196 }
197 for rule in &self.rules {
198 selectors(&rule.modules)?;
199 nonempty(&rule.description)?;
200 }
201 for relation in &self.relationships {
202 selector(&relation.source)?;
203 selector(&relation.target)?;
204 nonempty(&relation.evidence)?;
205 }
206 for scenario in &self.scenarios {
207 nonempty(&scenario.description)?;
208 if scenario.changes.is_empty() {
209 return Err(ContextError::Invalid(format!(
210 "{} has no changes",
211 scenario.name
212 )));
213 }
214 for change in &scenario.changes {
215 selector(&change.source)?;
216 selector(&change.target)?;
217 let values = [change.strength, change.distance, change.volatility];
218 if values.iter().all(Option::is_none)
219 || values
220 .into_iter()
221 .flatten()
222 .any(|v| !v.is_finite() || !(0.0..=1.0).contains(&v))
223 {
224 return Err(ContextError::Invalid(format!(
225 "{}: specify at least one dimension in 0..=1",
226 scenario.name
227 )));
228 }
229 }
230 }
231 for decision in &self.decisions {
232 selectors(&decision.modules)?;
233 nonempty(&decision.reason)?;
234 if decision.review_on.is_empty() {
235 return Err(ContextError::Invalid(format!(
236 "{} needs review triggers",
237 decision.id
238 )));
239 }
240 }
241 Ok(())
242 }
243}
244
245fn nonempty(value: &str) -> Result<(), ContextError> {
246 if value.trim().is_empty() {
247 Err(ContextError::Invalid("empty value".into()))
248 } else {
249 Ok(())
250 }
251}
252fn unique<'a>(values: impl Iterator<Item = &'a str>, kind: &str) -> Result<(), ContextError> {
253 let mut seen = BTreeSet::new();
254 for value in values {
255 nonempty(value)?;
256 if !seen.insert(value) {
257 return Err(ContextError::Invalid(format!("duplicate {kind}: {value}")));
258 }
259 }
260 Ok(())
261}
262fn selector(value: &str) -> Result<(), ContextError> {
263 nonempty(value)?;
264 glob::Pattern::new(value).map_err(|e| ContextError::Invalid(e.to_string()))?;
265 Ok(())
266}
267fn selectors(values: &[String]) -> Result<(), ContextError> {
268 if values.is_empty() {
269 return Err(ContextError::Invalid("modules cannot be empty".into()));
270 }
271 for value in values {
272 selector(value)?;
273 }
274 Ok(())
275}
276
277pub fn load_context(
279 path: &Path,
280 explicit: Option<&Path>,
281) -> Result<(DesignContext, Option<PathBuf>), ContextError> {
282 let selected = if let Some(explicit) = explicit {
283 Some(explicit.to_path_buf())
284 } else {
285 let absolute = fs::canonicalize(path)?;
286 let start = if absolute.is_file() {
287 absolute.parent().unwrap_or(&absolute)
288 } else {
289 &absolute
290 };
291 start
292 .ancestors()
293 .map(|dir| dir.join(".coupling-context.toml"))
294 .find(|p| p.is_file())
295 };
296 match selected {
297 Some(path) => Ok((
298 DesignContext::parse(&fs::read_to_string(&path)?)?,
299 Some(fs::canonicalize(path)?),
300 )),
301 None => Ok((DesignContext::default(), None)),
302 }
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308 #[test]
309 fn rejects_unknown_and_invalid_inputs() {
310 for text in [
311 "version=2",
312 "version=1\ncomponents_typo=[]",
313 "version=1\n[[components]]\nname='x'\nmodules=['[']",
314 "version=1\n[[components]]\nname='x'\nmodules=['*']\neffort_days=0",
315 ] {
316 assert!(DesignContext::parse(text).is_err(), "{text}");
317 }
318 }
319}