1use std::collections::BTreeMap;
8
9use objects::object::TimelineToolCallStatus;
10use serde_json::Value;
11
12pub fn parse_relay_payload(payload: &str) -> (Value, Option<String>) {
14 if payload.trim().is_empty() {
15 return (Value::Null, None);
16 }
17 match serde_json::from_str::<Value>(payload) {
18 Ok(value) => (value, None),
19 Err(err) => (
20 Value::Null,
21 Some(format!(
22 "warning: failed to parse harness relay payload as JSON: {err}; continuing with null payload"
23 )),
24 ),
25 }
26}
27
28pub fn value_string(value: &Value, path: &[&str]) -> Option<String> {
30 let mut current = value;
31 for segment in path {
32 current = current.get(*segment)?;
33 }
34 match current {
35 Value::String(s) => Some(s.clone()),
36 Value::Bool(v) => Some(v.to_string()),
37 Value::Number(v) => Some(v.to_string()),
38 _ => None,
39 }
40}
41
42pub fn first_value_string(value: &Value, paths: &[&[&str]]) -> Option<String> {
44 paths.iter().find_map(|path| value_string(value, path))
45}
46
47pub fn value_string_array(value: &Value, path: &[&str]) -> Option<Vec<String>> {
49 let mut current = value;
50 for segment in path {
51 current = current.get(*segment)?;
52 }
53 current.as_array().map(|items| {
54 items
55 .iter()
56 .filter_map(|item| item.as_str().map(ToString::to_string))
57 .collect()
58 })
59}
60
61pub fn value_array_join(value: &Value, path: &[&str]) -> Option<String> {
63 value_string_array(value, path).map(|items| items.join(","))
64}
65
66pub fn value_u64(value: &Value, path: &[&str]) -> Option<u64> {
68 let mut current = value;
69 for segment in path {
70 current = current.get(*segment)?;
71 }
72 current.as_u64()
73}
74
75pub fn value_u64_string(value: &Value, path: &[&str]) -> Option<String> {
77 value_u64(value, path).map(|v| v.to_string())
78}
79
80pub fn value_cost_micros(value: &Value, path: &[&str]) -> Option<String> {
82 value_cost_micros_u64(value, path).map(|v| v.to_string())
83}
84
85pub fn value_cost_micros_u64(value: &Value, path: &[&str]) -> Option<u64> {
87 let mut current = value;
88 for segment in path {
89 current = current.get(*segment)?;
90 }
91 current.as_f64().map(|v| (v * 1_000_000.0).round() as u64)
92}
93
94pub fn merge_string_vec(target: &mut Vec<String>, incoming: Vec<String>) {
96 for item in incoming {
97 if !item.trim().is_empty() && !target.contains(&item) {
98 target.push(item);
99 }
100 }
101}
102
103pub fn map_from_pairs<const N: usize>(
105 pairs: [(&str, Option<String>); N],
106) -> BTreeMap<String, String> {
107 pairs
108 .into_iter()
109 .filter_map(|(key, value)| value.map(|value| (key.to_string(), value)))
110 .collect()
111}
112
113pub fn opencode_tool_name(payload: &Value) -> String {
115 first_value_string(
116 payload,
117 &[
118 &["tool", "name"],
119 &["toolName"],
120 &["tool_name"],
121 &["tool"],
122 &["name"],
123 ],
124 )
125 .unwrap_or_else(|| "tool".to_string())
126}
127
128pub fn opencode_tool_status(payload: &Value) -> TimelineToolCallStatus {
130 let status = first_value_string(
131 payload,
132 &[
133 &["status"],
134 &["tool", "status"],
135 &["result", "status"],
136 &["output", "status"],
137 ],
138 )
139 .unwrap_or_default()
140 .to_ascii_lowercase();
141 if status.contains("cancel") {
142 TimelineToolCallStatus::Cancelled
143 } else if status.contains("fail")
144 || status.contains("error")
145 || payload.get("error").is_some()
146 || payload.get("exception").is_some()
147 {
148 TimelineToolCallStatus::Failed
149 } else {
150 TimelineToolCallStatus::Succeeded
151 }
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
156pub struct VerificationClaimPolicyFacts {
157 pub allow_land_publish_followup: bool,
158 pub allow_matching_workflow_action: bool,
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub struct VerificationClaimTrustFacts<'a> {
165 pub verified: bool,
166 pub recommended_action: &'a str,
167 pub remote_drift: &'a str,
168 pub workflow_status: &'a str,
169}
170
171pub fn repository_verification_allows_success_claim(
177 output_status: &str,
178 trust: VerificationClaimTrustFacts<'_>,
179 is_land_landed: bool,
180 recommended_matches_trust: bool,
181 policy: VerificationClaimPolicyFacts,
182) -> bool {
183 if trust.verified || matches!(output_status, "blocked" | "failed") {
184 return true;
185 }
186 if policy.allow_land_publish_followup
187 && is_land_landed
188 && trust.recommended_action == "heddle push"
189 && matches!(trust.remote_drift, "remote_untracked" | "remote_ahead")
190 {
191 return true;
192 }
193 if policy.allow_matching_workflow_action
194 && trust.workflow_status == "ready"
195 && recommended_matches_trust
196 {
197 return true;
198 }
199 false
200}
201
202pub fn raw_git_preservation_command() -> &'static str {
204 "heddle verify"
205}
206
207#[cfg(test)]
208mod tests {
209 use serde_json::json;
210
211 use super::*;
212
213 #[test]
214 fn parse_relay_payload_and_paths() {
215 let (v, warn) = parse_relay_payload("");
216 assert!(v.is_null());
217 assert!(warn.is_none());
218 let (v, warn) = parse_relay_payload("{");
219 assert!(v.is_null());
220 assert!(warn.is_some());
221 let (v, _) = parse_relay_payload(r#"{"tool":{"name":"edit"},"status":"failed"}"#);
222 assert_eq!(opencode_tool_name(&v), "edit");
223 assert_eq!(opencode_tool_status(&v), TimelineToolCallStatus::Failed);
224 assert_eq!(value_string(&v, &["tool", "name"]).as_deref(), Some("edit"));
225 }
226
227 #[test]
228 fn verification_claim_and_land_rewrite() {
229 let verified = VerificationClaimTrustFacts {
230 verified: true,
231 recommended_action: "",
232 remote_drift: "",
233 workflow_status: "",
234 };
235 assert!(repository_verification_allows_success_claim(
236 "completed",
237 verified,
238 false,
239 false,
240 VerificationClaimPolicyFacts::default()
241 ));
242 let unverified = VerificationClaimTrustFacts {
243 verified: false,
244 recommended_action: "",
245 remote_drift: "",
246 workflow_status: "",
247 };
248 assert!(!repository_verification_allows_success_claim(
249 "completed",
250 unverified,
251 false,
252 false,
253 VerificationClaimPolicyFacts::default()
254 ));
255 let land_push = VerificationClaimTrustFacts {
256 verified: false,
257 recommended_action: "heddle push",
258 remote_drift: "remote_ahead",
259 workflow_status: "",
260 };
261 assert!(repository_verification_allows_success_claim(
262 "landed",
263 land_push,
264 true,
265 false,
266 VerificationClaimPolicyFacts {
267 allow_land_publish_followup: true,
268 allow_matching_workflow_action: false,
269 }
270 ));
271 let _ = json!({});
272 assert_eq!(raw_git_preservation_command(), "heddle verify");
273 }
274}