Skip to main content

edda_ledger/
view.rs

1//! Read-side projection of decisions.
2//!
3//! `DecisionView` is the delivery type consumed by Injection (hooks, pack builder).
4//! Storage code uses `DecisionRow`; read-side consumers use `DecisionView` (BOUNDARY-01).
5
6use crate::sqlite_store::DecisionRow;
7
8/// Read-side projection of a decision. Injection consumers use this type
9/// instead of `DecisionRow` (BOUNDARY-01).
10#[derive(Debug, Clone, serde::Serialize)]
11pub struct DecisionView {
12    // Identity
13    pub event_id: String,
14    pub branch: String,
15    pub ts: Option<String>,
16
17    // What
18    pub key: String,
19    pub value: String,
20    pub reason: String,
21    pub domain: String,
22
23    // Governance state
24    /// "active" | "experimental" | "proposed" | "deprecated" | "superseded"
25    pub status: String,
26    /// "human" | "agent" | "system"
27    pub authority: String,
28    /// "easy" | "medium" | "hard"
29    pub reversibility: String,
30
31    // Scope — parsed arrays, not JSON strings
32    pub affected_paths: Vec<String>,
33    pub tags: Vec<String>,
34    /// Renamed from `scope` column.
35    pub propagation: String,
36
37    // Graph (optional)
38    pub supersedes_id: Option<String>,
39
40    // Review schedule
41    pub review_after: Option<String>,
42
43    // Village scope
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub village_id: Option<String>,
46}
47
48/// Convert a storage row into a delivery view.
49///
50/// - Parses `affected_paths` and `tags` from JSON string → `Vec<String>`
51/// - Renames `scope` → `propagation`
52/// - Drops `is_active`, `source_project_id`, `source_event_id`
53///
54/// If `affected_paths` or `tags` JSON is invalid or missing, defaults to `vec![]`.
55pub fn to_view(row: &DecisionRow) -> DecisionView {
56    let affected_paths: Vec<String> = serde_json::from_str(&row.affected_paths).unwrap_or_default();
57
58    let tags: Vec<String> = serde_json::from_str(&row.tags).unwrap_or_default();
59
60    DecisionView {
61        event_id: row.event_id.clone(),
62        branch: row.branch.clone(),
63        ts: row.ts.clone(),
64        key: row.key.clone(),
65        value: row.value.clone(),
66        reason: row.reason.clone(),
67        domain: row.domain.clone(),
68        status: row.status.clone(),
69        authority: row.authority.clone(),
70        reversibility: row.reversibility.clone(),
71        affected_paths,
72        tags,
73        propagation: row.scope.clone(),
74        supersedes_id: row.supersedes_id.clone(),
75        review_after: row.review_after.clone(),
76        village_id: row.village_id.clone(),
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    fn make_default_row() -> DecisionRow {
85        DecisionRow {
86            event_id: "evt_test".to_string(),
87            key: "db.engine".to_string(),
88            value: "sqlite".to_string(),
89            reason: "embedded".to_string(),
90            domain: "db".to_string(),
91            branch: "main".to_string(),
92            supersedes_id: None,
93            is_active: true,
94            ts: Some("2026-03-20T00:00:00Z".to_string()),
95            scope: "local".to_string(),
96            source_project_id: None,
97            source_event_id: None,
98            status: "active".to_string(),
99            authority: "human".to_string(),
100            affected_paths: "[]".to_string(),
101            tags: "[]".to_string(),
102            review_after: None,
103            reversibility: "medium".to_string(),
104            village_id: None,
105        }
106    }
107
108    fn make_row_with_paths(affected_paths: &str, tags: &str) -> DecisionRow {
109        let mut row = make_default_row();
110        row.affected_paths = affected_paths.to_string();
111        row.tags = tags.to_string();
112        row
113    }
114
115    #[test]
116    fn to_view_parses_json_arrays() {
117        let row = make_row_with_paths(
118            r#"["crates/edda-ledger/**", "crates/edda-core/**"]"#,
119            r#"["architecture", "storage"]"#,
120        );
121        let view = to_view(&row);
122        assert_eq!(
123            view.affected_paths,
124            vec!["crates/edda-ledger/**", "crates/edda-core/**"]
125        );
126        assert_eq!(view.tags, vec!["architecture", "storage"]);
127    }
128
129    #[test]
130    fn to_view_defaults_empty_on_empty_array() {
131        let row = make_row_with_paths("[]", "[]");
132        let view = to_view(&row);
133        assert!(view.affected_paths.is_empty());
134        assert!(view.tags.is_empty());
135    }
136
137    #[test]
138    fn to_view_defaults_empty_on_invalid_json() {
139        let row = make_row_with_paths("not json", "{bad}");
140        let view = to_view(&row);
141        assert!(view.affected_paths.is_empty());
142        assert!(view.tags.is_empty());
143    }
144
145    #[test]
146    fn to_view_renames_scope_to_propagation() {
147        let mut row = make_default_row();
148        row.scope = "shared".to_string();
149        let view = to_view(&row);
150        assert_eq!(view.propagation, "shared");
151    }
152
153    #[test]
154    fn decision_view_has_no_is_active() {
155        // Compile-time check: DecisionView must not have is_active field.
156        // If someone adds `is_active`, the struct definition changes but
157        // this test ensures we only access `status`.
158        let view = to_view(&make_default_row());
159        let _ = view.status; // exists
160                             // view.is_active; // must not compile
161    }
162
163    #[test]
164    fn to_view_preserves_review_after() {
165        let mut row = make_default_row();
166        row.review_after = Some("2026-06-01".to_string());
167        let view = to_view(&row);
168        assert_eq!(view.review_after, Some("2026-06-01".to_string()));
169    }
170}