Skip to main content

verbs/
harness_json.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Pure harness relay JSON helpers (no process/registry I/O).
3//!
4//! Payload parse, path-based value extraction, and OpenCode tool status
5//! classification. CLI still owns process detection and timeline mutation.
6
7use std::collections::BTreeMap;
8
9use objects::object::TimelineToolCallStatus;
10use serde_json::Value;
11
12/// Parse a harness relay payload string into JSON (or null + warning).
13pub 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
28/// Walk nested object path segments; return string/bool/number as string.
29pub 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
42/// First successful [`value_string`] across alternate paths.
43pub fn first_value_string(value: &Value, paths: &[&[&str]]) -> Option<String> {
44    paths.iter().find_map(|path| value_string(value, path))
45}
46
47/// String array at a nested path.
48pub 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
61/// Join string array elements with commas.
62pub fn value_array_join(value: &Value, path: &[&str]) -> Option<String> {
63    value_string_array(value, path).map(|items| items.join(","))
64}
65
66/// Nested u64 value.
67pub 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
75/// Nested u64 as string.
76pub fn value_u64_string(value: &Value, path: &[&str]) -> Option<String> {
77    value_u64(value, path).map(|v| v.to_string())
78}
79
80/// Nested f64 cost in dollars → micros as string.
81pub fn value_cost_micros(value: &Value, path: &[&str]) -> Option<String> {
82    value_cost_micros_u64(value, path).map(|v| v.to_string())
83}
84
85/// Nested f64 cost in dollars → micros.
86pub 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
94/// Append unique non-empty strings.
95pub 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
103/// Build a BTreeMap from optional pairs.
104pub 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
113/// OpenCode tool name from common payload shapes.
114pub 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
128/// Classify OpenCode tool status from payload fields.
129pub 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/// Operator verification claim policy (pure facts for success-claim gate).
155#[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/// Trust-side facts for [`repository_verification_allows_success_claim`]
162/// (grouped so the claim helper stays under clippy's arg limit).
163#[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
171/// Whether operator output may claim success given verification state.
172///
173/// `is_land_landed` is true when action is Land and status is landed.
174/// Matching-workflow path requires trust workflow ready and recommended action
175/// equality (caller supplies those facts).
176pub 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
202/// Raw Git operation handoff recovery primary command.
203pub 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}