| 1 | //! JSON keys on the wire: `snake_case` in every body that leaves g1t. |
| 2 | //! |
| 3 | //! Services talk to each other, and to the site, in the contracts' own |
| 4 | //! `camelCase`. Public bodies (API responses and errors, MCP tool results, |
| 5 | //! webhook payloads) are converted on their way out, here, so the |
| 6 | //! contracts stay as they are. |
| 7 | //! |
| 8 | //! Only keys that read as `camelCase` identifiers (`isPrivate`, `prUrl`, |
| 9 | //! `last4`) are converted; anything else (`G1T_TOKEN`, `fail-fast`, |
| 10 | //! `content-type`, a path) is left as it is. Values are never changed. |
| 11 | //! |
| 12 | //! People's own names are never renamed. A field in [`USER_KEYED`], or |
| 13 | //! named `by_…`, whose value is an object is a map keyed by data, such as |
| 14 | //! a workflow's inputs or a variable's name: it is passed through whole, |
| 15 | //! keys and values as given. A surface that carries more of them names |
| 16 | //! its own with [`snake_case_keeping`]. |
| 17 | |
| 18 | use serde_json::{Map, Value}; |
| 19 | |
| 20 | /// Fields whose object value is keyed by data rather than by g1t, wherever |
| 21 | /// they appear: workflow `inputs` and `on.workflow_dispatch.inputs` |
| 22 | /// (`dispatch`), `env`, `secrets`, `variables` and `vars` by name, a job's |
| 23 | /// `matrix`, `needs` and `outputs`, an action's `with`, guardrail `rules` |
| 24 | /// and `minutes` by id and kind of run, HTTP `headers`, `metadata`, and |
| 25 | /// `labels` by name. |
| 26 | pub const USER_KEYED: &[&str] = &[ |
| 27 | "inputs", |
| 28 | "dispatch", |
| 29 | "env", |
| 30 | "secrets", |
| 31 | "variables", |
| 32 | "vars", |
| 33 | "matrix", |
| 34 | "needs", |
| 35 | "outputs", |
| 36 | "with", |
| 37 | "rules", |
| 38 | "minutes", |
| 39 | "headers", |
| 40 | "metadata", |
| 41 | "labels", |
| 42 | ]; |
| 43 | |
| 44 | /// Whether a key is a `camelCase` identifier with something to convert. |
| 45 | pub fn is_camel_case(key: &str) -> bool { |
| 46 | let mut chars = key.chars(); |
| 47 | chars.next().is_some_and(|first| first.is_ascii_lowercase()) |
| 48 | && key.chars().all(|c| c.is_ascii_alphanumeric()) |
| 49 | && key.chars().any(|c| c.is_ascii_uppercase()) |
| 50 | } |
| 51 | |
| 52 | /// One key in `snake_case`: `prUrl` is `pr_url`, `headSHA` is `head_sha`, |
| 53 | /// `last4` stays `last4`. A key that is not a `camelCase` identifier is |
| 54 | /// returned as it is. |
| 55 | pub fn snake_case_key(key: &str) -> String { |
| 56 | if !is_camel_case(key) { |
| 57 | return key.to_owned(); |
| 58 | } |
| 59 | let chars: Vec<char> = key.chars().collect(); |
| 60 | let mut out = String::with_capacity(key.len() + 4); |
| 61 | for (i, &c) in chars.iter().enumerate() { |
| 62 | if c.is_ascii_uppercase() { |
| 63 | let previous = chars[i - 1]; |
| 64 | let next_is_lower = chars.get(i + 1).is_some_and(char::is_ascii_lowercase); |
| 65 | // A new word: after a lowercase letter or a digit, or the last |
| 66 | // capital of an acronym that starts the next word (`HTTPServer`). |
| 67 | if previous.is_ascii_lowercase() |
| 68 | || previous.is_ascii_digit() |
| 69 | || previous.is_ascii_uppercase() && next_is_lower |
| 70 | { |
| 71 | out.push('_'); |
| 72 | } |
| 73 | out.push(c.to_ascii_lowercase()); |
| 74 | } else { |
| 75 | out.push(c); |
| 76 | } |
| 77 | } |
| 78 | out |
| 79 | } |
| 80 | |
| 81 | fn user_keyed(key: &str, extra: &[&str]) -> bool { |
| 82 | USER_KEYED.contains(&key) || extra.contains(&key) || key.starts_with("by_") |
| 83 | } |
| 84 | |
| 85 | /// `value` with every key in `snake_case`, as it is sent out of g1t. |
| 86 | pub fn snake_case(value: Value) -> Value { |
| 87 | snake_case_keeping(value, &[]) |
| 88 | } |
| 89 | |
| 90 | /// As [`snake_case`], with `extra` field names (in their `snake_case` |
| 91 | /// spelling) whose object values are passed through as well. |
| 92 | pub fn snake_case_keeping(value: Value, extra: &[&str]) -> Value { |
| 93 | match value { |
| 94 | Value::Object(fields) => { |
| 95 | let mut out = Map::with_capacity(fields.len()); |
| 96 | for (given, value) in fields { |
| 97 | let key = snake_case_key(&given); |
| 98 | let value = if value.is_object() && user_keyed(&key, extra) { |
| 99 | value |
| 100 | } else { |
| 101 | snake_case_keeping(value, extra) |
| 102 | }; |
| 103 | // A key sent in both spellings keeps the one given in |
| 104 | // `snake_case`. |
| 105 | if given != key && out.contains_key(&key) { |
| 106 | continue; |
| 107 | } |
| 108 | out.insert(key, value); |
| 109 | } |
| 110 | Value::Object(out) |
| 111 | } |
| 112 | Value::Array(items) => Value::Array( |
| 113 | items |
| 114 | .into_iter() |
| 115 | .map(|item| snake_case_keeping(item, extra)) |
| 116 | .collect(), |
| 117 | ), |
| 118 | other => other, |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | /// The `camelCase` keys that would survive in `value`, as paths, outside |
| 123 | /// the maps [`snake_case`] passes through. Empty for anything it returned. |
| 124 | pub fn camel_case_keys(value: &Value) -> Vec<String> { |
| 125 | fn walk(value: &Value, path: &str, extra: &[&str], found: &mut Vec<String>) { |
| 126 | match value { |
| 127 | Value::Object(fields) => { |
| 128 | for (key, value) in fields { |
| 129 | let here = format!("{path}.{key}"); |
| 130 | if is_camel_case(key) { |
| 131 | found.push(here.clone()); |
| 132 | } |
| 133 | if !(value.is_object() && user_keyed(key, extra)) { |
| 134 | walk(value, &here, extra, found); |
| 135 | } |
| 136 | } |
| 137 | } |
| 138 | Value::Array(items) => { |
| 139 | for item in items { |
| 140 | walk(item, &format!("{path}[]"), extra, found); |
| 141 | } |
| 142 | } |
| 143 | _ => {} |
| 144 | } |
| 145 | } |
| 146 | let mut found = Vec::new(); |
| 147 | walk(value, "", &[], &mut found); |
| 148 | found |
| 149 | } |
| 150 | |
| 151 | #[cfg(test)] |
| 152 | mod tests { |
| 153 | use super::*; |
| 154 | use serde_json::json; |
| 155 | |
| 156 | #[test] |
| 157 | fn keys_are_converted() { |
| 158 | for (camel, snake) in [ |
| 159 | ("isPrivate", "is_private"), |
| 160 | ("prUrl", "pr_url"), |
| 161 | ("createdAt", "created_at"), |
| 162 | ("last4", "last4"), |
| 163 | ("sha256Hex", "sha256_hex"), |
| 164 | ("headSHA", "head_sha"), |
| 165 | ("HTTPServer", "HTTPServer"), |
| 166 | ("httpServerURL", "http_server_url"), |
| 167 | ("costUsd", "cost_usd"), |
| 168 | ("id", "id"), |
| 169 | ] { |
| 170 | assert_eq!(snake_case_key(camel), snake, "{camel}"); |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | #[test] |
| 175 | fn data_shaped_keys_are_left_alone() { |
| 176 | for key in ["G1T_TOKEN", "fail-fast", "content-type", "src/main.rs", "Title", "a b", ""] { |
| 177 | assert_eq!(snake_case_key(key), key); |
| 178 | } |
| 179 | } |
| 180 | |
| 181 | #[test] |
| 182 | fn nested_objects_and_arrays_are_converted() { |
| 183 | let sent = snake_case(json!({ |
| 184 | "pullRequests": [ |
| 185 | { "prUrl": "x", "checkRuns": [{ "exitCode": 0, "durationMs": 5 }] }, |
| 186 | [{ "deepNested": true }], |
| 187 | ], |
| 188 | "lastRun": { "startedAt": null }, |
| 189 | })); |
| 190 | assert_eq!( |
| 191 | sent, |
| 192 | json!({ |
| 193 | "pull_requests": [ |
| 194 | { "pr_url": "x", "check_runs": [{ "exit_code": 0, "duration_ms": 5 }] }, |
| 195 | [{ "deep_nested": true }], |
| 196 | ], |
| 197 | "last_run": { "started_at": null }, |
| 198 | }) |
| 199 | ); |
| 200 | assert!(camel_case_keys(&sent).is_empty()); |
| 201 | } |
| 202 | |
| 203 | #[test] |
| 204 | fn snake_case_input_is_unchanged() { |
| 205 | let body = json!({ "is_private": false, "items": [{ "created_at": "t", "last4": "4242" }] }); |
| 206 | assert_eq!(snake_case(body.clone()), body); |
| 207 | assert_eq!(snake_case(snake_case(json!({ "prUrl": 1 }))), json!({ "pr_url": 1 })); |
| 208 | } |
| 209 | |
| 210 | #[test] |
| 211 | fn the_snake_case_spelling_wins_when_both_are_given() { |
| 212 | assert_eq!( |
| 213 | snake_case(json!({ "keep_open": true, "keepOpen": false })), |
| 214 | json!({ "keep_open": true }) |
| 215 | ); |
| 216 | } |
| 217 | |
| 218 | #[test] |
| 219 | fn user_keyed_maps_pass_through_whole() { |
| 220 | let sent = snake_case(json!({ |
| 221 | "workflowId": "wf_1", |
| 222 | "inputs": { "logLevel": "debug", "dryRun": { "nestedKey": 1 } }, |
| 223 | "dispatch": { "targetEnv": { "type": "string" } }, |
| 224 | "env": { "nodeEnv": "x" }, |
| 225 | "secrets": { "apiKey": "…" }, |
| 226 | "variables": { "baseUrl": "…" }, |
| 227 | "rules": { "forcePush": false }, |
| 228 | "minutes": { "implementFeature": 30 }, |
| 229 | "headers": { "contentType": "application/json" }, |
| 230 | "metadata": { "workspaceId": "wsp_1" }, |
| 231 | "labels": { "goodFirstIssue": 3 }, |
| 232 | "byModel": { "claudeOpus": 1.5 }, |
| 233 | "data": { "issueId": "iss_1", "outputs": { "artifactId": "a" } }, |
| 234 | })); |
| 235 | assert_eq!( |
| 236 | sent, |
| 237 | json!({ |
| 238 | "workflow_id": "wf_1", |
| 239 | "inputs": { "logLevel": "debug", "dryRun": { "nestedKey": 1 } }, |
| 240 | "dispatch": { "targetEnv": { "type": "string" } }, |
| 241 | "env": { "nodeEnv": "x" }, |
| 242 | "secrets": { "apiKey": "…" }, |
| 243 | "variables": { "baseUrl": "…" }, |
| 244 | "rules": { "forcePush": false }, |
| 245 | "minutes": { "implementFeature": 30 }, |
| 246 | "headers": { "contentType": "application/json" }, |
| 247 | "metadata": { "workspaceId": "wsp_1" }, |
| 248 | "labels": { "goodFirstIssue": 3 }, |
| 249 | "by_model": { "claudeOpus": 1.5 }, |
| 250 | "data": { "issue_id": "iss_1", "outputs": { "artifactId": "a" } }, |
| 251 | }) |
| 252 | ); |
| 253 | assert!(camel_case_keys(&sent).is_empty()); |
| 254 | } |
| 255 | |
| 256 | #[test] |
| 257 | fn a_listed_name_holding_a_list_is_still_converted() { |
| 258 | // `secrets` is a map by name in one place and a list of findings in |
| 259 | // another: only a map is passed through. |
| 260 | assert_eq!( |
| 261 | snake_case(json!({ "secrets": [{ "updatedAt": "t" }], "labels": ["goodFirst"] })), |
| 262 | json!({ "secrets": [{ "updated_at": "t" }], "labels": ["goodFirst"] }) |
| 263 | ); |
| 264 | } |
| 265 | |
| 266 | #[test] |
| 267 | fn a_surface_can_name_more_maps() { |
| 268 | let sent = snake_case_keeping( |
| 269 | json!({ "timeoutMinutes": 5, "github": { "eventName": "push" } }), |
| 270 | &["github"], |
| 271 | ); |
| 272 | assert_eq!(sent, json!({ "timeout_minutes": 5, "github": { "eventName": "push" } })); |
| 273 | } |
| 274 | |
| 275 | #[test] |
| 276 | fn surviving_camel_case_is_found() { |
| 277 | assert_eq!( |
| 278 | camel_case_keys(&json!({ "a": [{ "bC": 1 }], "inputs": { "dE": 1 } })), |
| 279 | vec![".a[].bC".to_owned()] |
| 280 | ); |
| 281 | } |
| 282 | } |