-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathenvelope.rs
More file actions
240 lines (224 loc) Β· 8.84 KB
/
Copy pathenvelope.rs
File metadata and controls
240 lines (224 loc) Β· 8.84 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
//! Shared constructors for fledge's two documented `--json` envelope dialects.
//!
//! Every `--json` output is `{schema_version, ...}` in one of two shapes (see
//! AGENTS.md Β§ "Machine-readable surface"):
//!
//! - **resource dialect** β `{schema_version, <resource>: [...]}`, used by the
//! pillar list/query commands (`plugins list`, `lanes search`, `templates
//! list`, β¦). The resource key (`plugins`, `results`, `templates`) is the
//! discriminator.
//! - **action dialect** β `{schema_version, action: "<verb>", ...}`, used by the
//! cross-cutting commands (`doctor`, `run`, `ai`, `release`, β¦). The `action`
//! string discriminates between commands sharing a similar shape.
//!
//! Building these by hand at ~60 call sites let shapes drift β the three
//! `search` commands shipped divergent `results[]` entries, for instance. These
//! helpers centralize the outer envelope so `schema_version` is never mistyped
//! and the dialect is chosen explicitly rather than copy-pasted.
//!
//! Provides both dialect builders: [`resource`] (pillar list/query commands)
//! and [`action`] (cross-cutting commands).
use serde::Serialize;
use serde_json::{Map, Value};
/// Build a **resource-dialect** envelope: `{schema_version, <resource_key>: items}`.
///
/// `items` is serialized in place β pass a `Vec<Value>`, a slice of any
/// `Serialize` type, or a pre-built `Value`.
///
/// ```ignore
/// let out = envelope::resource(PLUGINS_SEARCH_SCHEMA, "results", entries);
/// ```
pub fn resource(schema_version: u32, resource_key: &str, items: impl Serialize) -> Value {
let mut map = Map::new();
map.insert("schema_version".to_string(), Value::from(schema_version));
map.insert(
resource_key.to_string(),
serde_json::to_value(items).unwrap_or(Value::Null),
);
Value::Object(map)
}
/// Build an **action-dialect** envelope: `{schema_version, action, ...fields}`.
///
/// `schema_version` and `action` are inserted first; the keys of `fields` are
/// merged in. `fields` must be a JSON object β any other `Value` contributes no
/// extra keys (the envelope still carries `schema_version` and `action`).
///
/// The serialized output is identical to a hand-rolled `json!({"schema_version":
/// β¦, "action": β¦, β¦fields})`: serde_json orders object keys the same way
/// regardless of insertion order, so migrating a call site to this helper is a
/// byte-for-byte-compatible refactor.
///
/// ```ignore
/// let out = envelope::action(WORK_START_SCHEMA, "work_start", serde_json::json!({
/// "branch": branch_name,
/// "base": base_branch,
/// }));
/// ```
pub fn action(schema_version: u32, action: &str, fields: Value) -> Value {
let mut map = Map::new();
map.insert("schema_version".to_string(), Value::from(schema_version));
map.insert("action".to_string(), Value::from(action));
if let Value::Object(obj) = fields {
map.extend(obj);
}
Value::Object(map)
}
/// Build a **flat versioned** envelope: `{schema_version, ...fields}` β for the
/// handful of commands whose `--json` shape is neither the resource nor the
/// action dialect (e.g. `lanes run`/`lanes validate`, which carry named fields
/// but no `action` key or single resource array).
///
/// Like [`action`] minus the `action` key: `fields` must be a JSON object; any
/// other `Value` contributes no extra keys. Serialization is byte-for-byte
/// identical to a hand-rolled `json!` with the same fields (or to inserting
/// `schema_version` into an already-serialized struct).
pub fn versioned(schema_version: u32, fields: Value) -> Value {
let mut map = Map::new();
map.insert("schema_version".to_string(), Value::from(schema_version));
if let Value::Object(obj) = fields {
map.extend(obj);
}
Value::Object(map)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resource_wraps_items_under_key_with_schema_version() {
let out = resource(3, "results", vec![serde_json::json!({"name": "a"})]);
assert_eq!(out["schema_version"], 3);
assert!(out.get("action").is_none());
assert_eq!(out["results"][0]["name"], "a");
}
#[test]
fn resource_accepts_empty_and_typed_items() {
let empty = resource(1, "plugins", Vec::<Value>::new());
assert_eq!(empty["plugins"], serde_json::json!([]));
// A typed slice serializes the same as hand-rolled Values.
let typed = resource(1, "results", ["x", "y"]);
assert_eq!(typed["results"], serde_json::json!(["x", "y"]));
}
#[test]
fn resource_is_byte_identical_to_hand_rolled_json() {
// Same migration guarantee the action/versioned dialects assert: the
// resource dialect must serialize identically to the hand-rolled form
// it replaced, so adopting the helper is never a wire change.
let built = resource(
1,
"plugins",
vec![serde_json::json!({ "name": "github", "version": "0.4.0" })],
);
let hand = serde_json::json!({
"schema_version": 1,
"plugins": [{ "name": "github", "version": "0.4.0" }],
});
assert_eq!(
serde_json::to_string_pretty(&built).unwrap(),
serde_json::to_string_pretty(&hand).unwrap()
);
}
#[test]
fn versioned_flattens_a_serialized_struct_alongside_schema_version() {
// The shape `plugins validate` / `lanes validate` emit: a report struct
// is serialized to a Value, then wrapped. The report's own fields must
// stay at the top level next to `schema_version`, not nested under a
// key, and the result must match the hand-rolled equivalent.
#[derive(Serialize)]
struct Report {
path: String,
lane_count: usize,
errors: Vec<String>,
warnings: Vec<String>,
}
let report = Report {
path: "fledge.toml".to_string(),
lane_count: 3,
errors: vec![],
warnings: vec!["unpinned step".to_string()],
};
let built = versioned(1, serde_json::to_value(&report).unwrap());
let hand = serde_json::json!({
"schema_version": 1,
"path": "fledge.toml",
"lane_count": 3,
"errors": [],
"warnings": ["unpinned step"],
});
assert_eq!(
serde_json::to_string_pretty(&built).unwrap(),
serde_json::to_string_pretty(&hand).unwrap()
);
}
#[test]
fn action_leads_with_schema_version_and_action_then_merges_fields() {
let out = action(
2,
"release",
serde_json::json!({ "dry_run": true, "version": "1.2.3" }),
);
assert_eq!(out["schema_version"], 2);
assert_eq!(out["action"], "release");
assert_eq!(out["dry_run"], true);
assert_eq!(out["version"], "1.2.3");
}
#[test]
fn action_is_byte_identical_to_hand_rolled_json() {
// The migration guarantee: swapping a hand-rolled envelope for action()
// must not change a single byte of serialized output.
let built = action(
1,
"work_start",
serde_json::json!({
"branch": "t/feat/demo",
"base": "main",
"type": "feat",
"prefix": Value::Null,
"issue": Value::Null,
}),
);
let hand = serde_json::json!({
"schema_version": 1,
"action": "work_start",
"branch": "t/feat/demo",
"base": "main",
"type": "feat",
"prefix": Value::Null,
"issue": Value::Null,
});
assert_eq!(
serde_json::to_string_pretty(&built).unwrap(),
serde_json::to_string_pretty(&hand).unwrap()
);
}
#[test]
fn action_survives_non_object_fields() {
let out = action(1, "noop", Value::Null);
assert_eq!(out["schema_version"], 1);
assert_eq!(out["action"], "noop");
assert_eq!(out.as_object().unwrap().len(), 2);
}
#[test]
fn versioned_prepends_schema_version_without_action() {
let out = versioned(1, serde_json::json!({ "lane": "ci", "success": true }));
assert_eq!(out["schema_version"], 1);
assert!(out.get("action").is_none());
assert_eq!(out["lane"], "ci");
assert_eq!(out["success"], true);
}
#[test]
fn versioned_is_byte_identical_to_hand_rolled_json() {
let built = versioned(
1,
serde_json::json!({ "path": ".fledge/lanes/x.toml", "lane_count": 2 }),
);
let hand = serde_json::json!({
"schema_version": 1,
"path": ".fledge/lanes/x.toml",
"lane_count": 2,
});
assert_eq!(
serde_json::to_string_pretty(&built).unwrap(),
serde_json::to_string_pretty(&hand).unwrap()
);
}
}