Skip to main content

cargo_coupling/design/
context.rs

1//! Optional human-supplied design facts. Nothing here rewrites observed metrics.
2
3use 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    /// Optional previously recorded context fingerprint.
136    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
277/// Search upward from the selected source, respecting an explicitly supplied path.
278pub 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}