1use crate::sqlite_store::DecisionRow;
7
8#[derive(Debug, Clone, serde::Serialize)]
11pub struct DecisionView {
12 pub event_id: String,
14 pub branch: String,
15 pub ts: Option<String>,
16
17 pub key: String,
19 pub value: String,
20 pub reason: String,
21 pub domain: String,
22
23 pub status: String,
26 pub authority: String,
28 pub reversibility: String,
30
31 pub affected_paths: Vec<String>,
33 pub tags: Vec<String>,
34 pub propagation: String,
36
37 pub supersedes_id: Option<String>,
39
40 pub review_after: Option<String>,
42
43 #[serde(skip_serializing_if = "Option::is_none")]
45 pub village_id: Option<String>,
46}
47
48pub 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 let view = to_view(&make_default_row());
159 let _ = view.status; }
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}