1use clap::{Args, Subcommand};
2use serde_json::json;
3use uuid::Uuid;
4
5use crate::util::{api_request, exit_error, read_json_from_file};
6
7#[derive(Subcommand)]
8pub enum ObservationCommands {
9 Draft {
11 #[command(subcommand)]
12 command: ObservationDraftCommands,
13 },
14}
15
16#[derive(Subcommand)]
17pub enum ObservationDraftCommands {
18 List {
20 #[arg(long)]
22 limit: Option<i64>,
23 },
24 Show {
26 #[arg(long)]
28 id: Uuid,
29 },
30 Promote(ObservationDraftPromoteArgs),
32 Resolve(ObservationDraftResolveArgs),
34 Dismiss(ObservationDraftDismissArgs),
36}
37
38#[derive(Args)]
39pub struct ObservationDraftPromoteArgs {
40 #[arg(long)]
42 pub id: Uuid,
43 #[arg(long)]
45 pub event_type: String,
46 #[arg(long, required_unless_present = "data_file")]
48 pub data: Option<String>,
49 #[arg(long, short = 'f', conflicts_with = "data")]
51 pub data_file: Option<String>,
52 #[arg(long)]
54 pub timestamp: Option<String>,
55 #[arg(long)]
57 pub source: Option<String>,
58 #[arg(long)]
60 pub agent: Option<String>,
61 #[arg(long)]
63 pub device: Option<String>,
64 #[arg(long)]
66 pub session_id: Option<String>,
67 #[arg(long)]
69 pub idempotency_key: Option<String>,
70 #[arg(long)]
72 pub retract_reason: Option<String>,
73}
74
75#[derive(Args)]
76pub struct ObservationDraftResolveArgs {
77 #[arg(long)]
79 pub id: Uuid,
80 #[arg(long)]
82 pub dimension: String,
83 #[arg(long, conflicts_with = "value_file")]
85 pub value: Option<String>,
86 #[arg(long, short = 'f', conflicts_with = "value")]
88 pub value_file: Option<String>,
89 #[arg(long)]
91 pub context_text: Option<String>,
92 #[arg(long)]
94 pub confidence: Option<f64>,
95 #[arg(long = "tag")]
97 pub tags: Vec<String>,
98 #[arg(long)]
100 pub source: Option<String>,
101 #[arg(long)]
103 pub agent: Option<String>,
104 #[arg(long)]
106 pub device: Option<String>,
107 #[arg(long)]
109 pub session_id: Option<String>,
110 #[arg(long)]
112 pub idempotency_key: Option<String>,
113 #[arg(long)]
115 pub retract_reason: Option<String>,
116}
117
118#[derive(Args)]
119pub struct ObservationDraftDismissArgs {
120 #[arg(long)]
122 pub id: Uuid,
123 #[arg(long)]
125 pub reason: Option<String>,
126 #[arg(long)]
128 pub source: Option<String>,
129 #[arg(long)]
131 pub agent: Option<String>,
132 #[arg(long)]
134 pub device: Option<String>,
135 #[arg(long)]
137 pub session_id: Option<String>,
138 #[arg(long)]
140 pub idempotency_key: Option<String>,
141}
142
143pub async fn run(api_url: &str, token: Option<&str>, command: ObservationCommands) -> i32 {
144 match command {
145 ObservationCommands::Draft { command } => draft(api_url, token, command).await,
146 }
147}
148
149async fn draft(api_url: &str, token: Option<&str>, command: ObservationDraftCommands) -> i32 {
150 match command {
151 ObservationDraftCommands::List { limit } => list_drafts(api_url, token, limit).await,
152 ObservationDraftCommands::Show { id } => show_draft(api_url, token, id).await,
153 ObservationDraftCommands::Promote(args) => promote_draft(api_url, token, args).await,
154 ObservationDraftCommands::Resolve(args) => resolve_draft(api_url, token, args).await,
155 ObservationDraftCommands::Dismiss(args) => dismiss_draft(api_url, token, args).await,
156 }
157}
158
159async fn list_drafts(api_url: &str, token: Option<&str>, limit: Option<i64>) -> i32 {
160 let mut query = Vec::new();
161 if let Some(limit) = limit {
162 query.push(("limit".to_string(), limit.to_string()));
163 }
164 api_request(
165 api_url,
166 reqwest::Method::GET,
167 "/v1/agent/observation-drafts",
168 token,
169 None,
170 &query,
171 &[],
172 false,
173 false,
174 )
175 .await
176}
177
178async fn show_draft(api_url: &str, token: Option<&str>, id: Uuid) -> i32 {
179 let path = format!("/v1/agent/observation-drafts/{id}");
180 api_request(
181 api_url,
182 reqwest::Method::GET,
183 &path,
184 token,
185 None,
186 &[],
187 &[],
188 false,
189 false,
190 )
191 .await
192}
193
194fn parse_data_payload(data: Option<&str>, data_file: Option<&str>) -> serde_json::Value {
195 if let Some(raw) = data {
196 return serde_json::from_str(raw).unwrap_or_else(|e| {
197 exit_error(
198 &format!("Invalid JSON in --data: {e}"),
199 Some("Provide valid JSON for --data"),
200 )
201 });
202 }
203 if let Some(path) = data_file {
204 return read_json_from_file(path).unwrap_or_else(|e| {
205 exit_error(
206 &e,
207 Some("Provide a valid JSON file for --data-file (or '-' for stdin)"),
208 )
209 });
210 }
211 exit_error(
212 "Either --data or --data-file is required",
213 Some("Provide formal event payload for promotion."),
214 );
215}
216
217fn parse_optional_json_payload(
218 value: Option<&str>,
219 value_file: Option<&str>,
220) -> Option<serde_json::Value> {
221 if let Some(raw) = value {
222 return Some(serde_json::from_str(raw).unwrap_or_else(|e| {
223 exit_error(
224 &format!("Invalid JSON in --value: {e}"),
225 Some("Provide valid JSON for --value"),
226 )
227 }));
228 }
229 if let Some(path) = value_file {
230 return Some(read_json_from_file(path).unwrap_or_else(|e| {
231 exit_error(
232 &e,
233 Some("Provide a valid JSON file for --value-file (or '-' for stdin)"),
234 )
235 }));
236 }
237 None
238}
239
240async fn promote_draft(
241 api_url: &str,
242 token: Option<&str>,
243 args: ObservationDraftPromoteArgs,
244) -> i32 {
245 let data_payload = parse_data_payload(args.data.as_deref(), args.data_file.as_deref());
246 let path = format!("/v1/agent/observation-drafts/{}/promote", args.id);
247 let mut body = json!({
248 "event_type": args.event_type,
249 "data": data_payload,
250 });
251 if let Some(timestamp) = args.timestamp {
252 body["timestamp"] = json!(timestamp);
253 }
254 if let Some(source) = args.source {
255 body["source"] = json!(source);
256 }
257 if let Some(agent) = args.agent {
258 body["agent"] = json!(agent);
259 }
260 if let Some(device) = args.device {
261 body["device"] = json!(device);
262 }
263 if let Some(session_id) = args.session_id {
264 body["session_id"] = json!(session_id);
265 }
266 if let Some(idempotency_key) = args.idempotency_key {
267 body["idempotency_key"] = json!(idempotency_key);
268 }
269 if let Some(retract_reason) = args.retract_reason {
270 body["retract_reason"] = json!(retract_reason);
271 }
272 api_request(
273 api_url,
274 reqwest::Method::POST,
275 &path,
276 token,
277 Some(body),
278 &[],
279 &[],
280 false,
281 false,
282 )
283 .await
284}
285
286async fn resolve_draft(
287 api_url: &str,
288 token: Option<&str>,
289 args: ObservationDraftResolveArgs,
290) -> i32 {
291 let value_payload =
292 parse_optional_json_payload(args.value.as_deref(), args.value_file.as_deref());
293 let path = format!(
294 "/v1/agent/observation-drafts/{}/resolve-as-observation",
295 args.id
296 );
297 let mut body = json!({
298 "dimension": args.dimension,
299 });
300 if let Some(value) = value_payload {
301 body["value"] = value;
302 }
303 if let Some(context_text) = args.context_text {
304 body["context_text"] = json!(context_text);
305 }
306 if let Some(confidence) = args.confidence {
307 body["confidence"] = json!(confidence);
308 }
309 if !args.tags.is_empty() {
310 body["tags"] = json!(args.tags);
311 }
312 if let Some(source) = args.source {
313 body["source"] = json!(source);
314 }
315 if let Some(agent) = args.agent {
316 body["agent"] = json!(agent);
317 }
318 if let Some(device) = args.device {
319 body["device"] = json!(device);
320 }
321 if let Some(session_id) = args.session_id {
322 body["session_id"] = json!(session_id);
323 }
324 if let Some(idempotency_key) = args.idempotency_key {
325 body["idempotency_key"] = json!(idempotency_key);
326 }
327 if let Some(retract_reason) = args.retract_reason {
328 body["retract_reason"] = json!(retract_reason);
329 }
330 api_request(
331 api_url,
332 reqwest::Method::POST,
333 &path,
334 token,
335 Some(body),
336 &[],
337 &[],
338 false,
339 false,
340 )
341 .await
342}
343
344async fn dismiss_draft(
345 api_url: &str,
346 token: Option<&str>,
347 args: ObservationDraftDismissArgs,
348) -> i32 {
349 let path = format!("/v1/agent/observation-drafts/{}/dismiss", args.id);
350 let mut body = serde_json::Map::new();
351 if let Some(reason) = args.reason {
352 body.insert("reason".to_string(), json!(reason));
353 }
354 if let Some(source) = args.source {
355 body.insert("source".to_string(), json!(source));
356 }
357 if let Some(agent) = args.agent {
358 body.insert("agent".to_string(), json!(agent));
359 }
360 if let Some(device) = args.device {
361 body.insert("device".to_string(), json!(device));
362 }
363 if let Some(session_id) = args.session_id {
364 body.insert("session_id".to_string(), json!(session_id));
365 }
366 if let Some(idempotency_key) = args.idempotency_key {
367 body.insert("idempotency_key".to_string(), json!(idempotency_key));
368 }
369
370 let request_body = if body.is_empty() {
371 None
372 } else {
373 Some(serde_json::Value::Object(body))
374 };
375
376 api_request(
377 api_url,
378 reqwest::Method::POST,
379 &path,
380 token,
381 request_body,
382 &[],
383 &[],
384 false,
385 false,
386 )
387 .await
388}
389
390#[cfg(test)]
391mod tests {
392 use super::{parse_data_payload, parse_optional_json_payload};
393 use serde_json::json;
394
395 #[test]
396 fn parse_data_payload_accepts_inline_json() {
397 let payload = parse_data_payload(Some(r#"{"reps": 5, "weight_kg": 100}"#), None);
398 assert_eq!(payload, json!({"reps": 5, "weight_kg": 100}));
399 }
400
401 #[test]
402 fn parse_optional_json_payload_returns_none_when_not_set() {
403 assert_eq!(parse_optional_json_payload(None, None), None);
404 }
405
406 #[test]
407 fn parse_optional_json_payload_accepts_inline_json() {
408 let payload = parse_optional_json_payload(Some(r#"{"note":"ok"}"#), None);
409 assert_eq!(payload, Some(json!({"note": "ok"})));
410 }
411}