Skip to main content

kura_cli/commands/
observation.rs

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 observation workflow
10    Draft {
11        #[command(subcommand)]
12        command: ObservationDraftCommands,
13    },
14}
15
16#[derive(Subcommand)]
17pub enum ObservationDraftCommands {
18    /// List open persist-intent drafts
19    List {
20        /// Max items to return (default: 20)
21        #[arg(long)]
22        limit: Option<i64>,
23    },
24    /// Show one draft observation in detail
25    Show {
26        /// Draft observation event id
27        #[arg(long)]
28        id: Uuid,
29    },
30    /// Promote draft into a formal event and retract the draft
31    Promote(ObservationDraftPromoteArgs),
32    /// Resolve draft as durable observation and retract the draft
33    Resolve(ObservationDraftResolveArgs),
34    /// Dismiss non-actionable draft and retract it
35    Dismiss(ObservationDraftDismissArgs),
36}
37
38#[derive(Args)]
39pub struct ObservationDraftPromoteArgs {
40    /// Draft observation event id
41    #[arg(long)]
42    pub id: Uuid,
43    /// Formal target event type (e.g. set.logged)
44    #[arg(long)]
45    pub event_type: String,
46    /// Formal event payload as JSON string
47    #[arg(long, required_unless_present = "data_file")]
48    pub data: Option<String>,
49    /// Read formal event payload from file (use '-' for stdin)
50    #[arg(long, short = 'f', conflicts_with = "data")]
51    pub data_file: Option<String>,
52    /// Optional RFC3339 timestamp for formal event (default: now server-side)
53    #[arg(long)]
54    pub timestamp: Option<String>,
55    /// Optional metadata.source override
56    #[arg(long)]
57    pub source: Option<String>,
58    /// Optional metadata.agent override
59    #[arg(long)]
60    pub agent: Option<String>,
61    /// Optional metadata.device override
62    #[arg(long)]
63    pub device: Option<String>,
64    /// Optional metadata.session_id override
65    #[arg(long)]
66    pub session_id: Option<String>,
67    /// Optional metadata.idempotency_key override
68    #[arg(long)]
69    pub idempotency_key: Option<String>,
70    /// Optional retraction reason
71    #[arg(long)]
72    pub retract_reason: Option<String>,
73}
74
75#[derive(Args)]
76pub struct ObservationDraftResolveArgs {
77    /// Draft observation event id
78    #[arg(long)]
79    pub id: Uuid,
80    /// Stable non-provisional observation dimension (e.g. competition_note)
81    #[arg(long)]
82    pub dimension: String,
83    /// Optional observation value as JSON string
84    #[arg(long, conflicts_with = "value_file")]
85    pub value: Option<String>,
86    /// Optional observation value JSON from file (use '-' for stdin)
87    #[arg(long, short = 'f', conflicts_with = "value")]
88    pub value_file: Option<String>,
89    /// Optional context text override
90    #[arg(long)]
91    pub context_text: Option<String>,
92    /// Optional confidence override (0..1 recommended)
93    #[arg(long)]
94    pub confidence: Option<f64>,
95    /// Optional tags (repeat --tag)
96    #[arg(long = "tag")]
97    pub tags: Vec<String>,
98    /// Optional metadata.source override
99    #[arg(long)]
100    pub source: Option<String>,
101    /// Optional metadata.agent override
102    #[arg(long)]
103    pub agent: Option<String>,
104    /// Optional metadata.device override
105    #[arg(long)]
106    pub device: Option<String>,
107    /// Optional metadata.session_id override
108    #[arg(long)]
109    pub session_id: Option<String>,
110    /// Optional metadata.idempotency_key override
111    #[arg(long)]
112    pub idempotency_key: Option<String>,
113    /// Optional retraction reason
114    #[arg(long)]
115    pub retract_reason: Option<String>,
116}
117
118#[derive(Args)]
119pub struct ObservationDraftDismissArgs {
120    /// Draft observation event id
121    #[arg(long)]
122    pub id: Uuid,
123    /// Optional dismiss reason (e.g. duplicate, test, noise)
124    #[arg(long)]
125    pub reason: Option<String>,
126    /// Optional metadata.source override
127    #[arg(long)]
128    pub source: Option<String>,
129    /// Optional metadata.agent override
130    #[arg(long)]
131    pub agent: Option<String>,
132    /// Optional metadata.device override
133    #[arg(long)]
134    pub device: Option<String>,
135    /// Optional metadata.session_id override
136    #[arg(long)]
137    pub session_id: Option<String>,
138    /// Optional metadata.idempotency_key override
139    #[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}