use serde::Serialize; use crate::cli::robot::RobotMeta; use crate::core::time::ms_to_iso; use super::types::{ ActivityEventType, AttentionState, MeActivityEvent, MeDashboard, MeIssue, MeMention, MeMr, MeSummary, SinceCheckEvent, SinceCheckGroup, SinceLastCheck, }; // ─── Robot JSON Output (Task #18) ──────────────────────────────────────────── /// Print the full me dashboard as robot-mode JSON. pub fn print_me_json( dashboard: &MeDashboard, elapsed_ms: u64, fields: Option<&[String]>, gitlab_base_url: &str, ) -> crate::core::error::Result<()> { let envelope = MeJsonEnvelope { ok: true, data: MeDataJson::from_dashboard(dashboard), meta: RobotMeta::with_base_url(elapsed_ms, gitlab_base_url), }; let mut value = serde_json::to_value(&envelope) .map_err(|e| crate::core::error::LoreError::Other(format!("JSON serialization: {e}")))?; // Apply --fields filtering (Task #19) if let Some(f) = fields { let expanded = crate::cli::robot::expand_fields_preset(f, "me_items"); // Filter issue/MR arrays with the items preset for key in &["open_issues", "open_mrs_authored", "reviewing_mrs"] { crate::cli::robot::filter_fields(&mut value, key, &expanded); } // Mentioned-in gets its own preset (needs entity_type + state to disambiguate) let mentions_expanded = crate::cli::robot::expand_fields_preset(f, "me_mentions"); crate::cli::robot::filter_fields(&mut value, "mentioned_in", &mentions_expanded); // Activity gets its own minimal preset let activity_expanded = crate::cli::robot::expand_fields_preset(f, "me_activity"); crate::cli::robot::filter_fields(&mut value, "activity", &activity_expanded); } let json = serde_json::to_string(&value) .map_err(|e| crate::core::error::LoreError::Other(format!("JSON serialization: {e}")))?; println!("{json}"); Ok(()) } /// Print `--reset-cursor` response using standard robot envelope. pub fn print_cursor_reset_json(elapsed_ms: u64) -> crate::core::error::Result<()> { let value = cursor_reset_envelope_json(elapsed_ms); let json = serde_json::to_string(&value) .map_err(|e| crate::core::error::LoreError::Other(format!("JSON serialization: {e}")))?; println!("{json}"); Ok(()) } fn cursor_reset_envelope_json(elapsed_ms: u64) -> serde_json::Value { serde_json::json!({ "ok": true, "data": { "cursor_reset": true }, "meta": { "elapsed_ms": elapsed_ms } }) } // ─── JSON Envelope ─────────────────────────────────────────────────────────── #[derive(Serialize)] struct MeJsonEnvelope { ok: bool, data: MeDataJson, meta: RobotMeta, } #[derive(Serialize)] struct MeDataJson { username: String, since_iso: Option, summary: SummaryJson, #[serde(skip_serializing_if = "Option::is_none")] since_last_check: Option, open_issues: Vec, open_mrs_authored: Vec, reviewing_mrs: Vec, mentioned_in: Vec, activity: Vec, } impl MeDataJson { fn from_dashboard(d: &MeDashboard) -> Self { Self { username: d.username.clone(), since_iso: d.since_ms.map(ms_to_iso), summary: SummaryJson::from(&d.summary), since_last_check: d.since_last_check.as_ref().map(SinceLastCheckJson::from), open_issues: d.open_issues.iter().map(IssueJson::from).collect(), open_mrs_authored: d.open_mrs_authored.iter().map(MrJson::from).collect(), reviewing_mrs: d.reviewing_mrs.iter().map(MrJson::from).collect(), mentioned_in: d.mentioned_in.iter().map(MentionJson::from).collect(), activity: d.activity.iter().map(ActivityJson::from).collect(), } } } // ─── Summary ───────────────────────────────────────────────────────────────── #[derive(Serialize)] struct SummaryJson { project_count: usize, open_issue_count: usize, authored_mr_count: usize, reviewing_mr_count: usize, mentioned_in_count: usize, needs_attention_count: usize, } impl From<&MeSummary> for SummaryJson { fn from(s: &MeSummary) -> Self { Self { project_count: s.project_count, open_issue_count: s.open_issue_count, authored_mr_count: s.authored_mr_count, reviewing_mr_count: s.reviewing_mr_count, mentioned_in_count: s.mentioned_in_count, needs_attention_count: s.needs_attention_count, } } } // ─── Issue ─────────────────────────────────────────────────────────────────── #[derive(Serialize)] struct IssueJson { project: String, iid: i64, title: String, state: String, attention_state: String, attention_reason: String, status_name: Option, labels: Vec, updated_at_iso: String, web_url: Option, } impl From<&MeIssue> for IssueJson { fn from(i: &MeIssue) -> Self { Self { project: i.project_path.clone(), iid: i.iid, title: i.title.clone(), state: "opened".to_string(), attention_state: attention_state_str(&i.attention_state), attention_reason: i.attention_reason.clone(), status_name: i.status_name.clone(), labels: i.labels.clone(), updated_at_iso: ms_to_iso(i.updated_at), web_url: i.web_url.clone(), } } } // ─── MR ────────────────────────────────────────────────────────────────────── #[derive(Serialize)] struct MrJson { project: String, iid: i64, title: String, state: String, attention_state: String, attention_reason: String, draft: bool, detailed_merge_status: Option, author_username: Option, labels: Vec, updated_at_iso: String, web_url: Option, } impl From<&MeMr> for MrJson { fn from(m: &MeMr) -> Self { Self { project: m.project_path.clone(), iid: m.iid, title: m.title.clone(), state: "opened".to_string(), attention_state: attention_state_str(&m.attention_state), attention_reason: m.attention_reason.clone(), draft: m.draft, detailed_merge_status: m.detailed_merge_status.clone(), author_username: m.author_username.clone(), labels: m.labels.clone(), updated_at_iso: ms_to_iso(m.updated_at), web_url: m.web_url.clone(), } } } // ─── Mention ───────────────────────────────────────────────────────────── #[derive(Serialize)] struct MentionJson { entity_type: String, project: String, iid: i64, title: String, state: String, attention_state: String, attention_reason: String, updated_at_iso: String, web_url: Option, } impl From<&MeMention> for MentionJson { fn from(m: &MeMention) -> Self { Self { entity_type: m.entity_type.clone(), project: m.project_path.clone(), iid: m.iid, title: m.title.clone(), state: m.state.clone(), attention_state: attention_state_str(&m.attention_state), attention_reason: m.attention_reason.clone(), updated_at_iso: ms_to_iso(m.updated_at), web_url: m.web_url.clone(), } } } // ─── Activity ──────────────────────────────────────────────────────────────── #[derive(Serialize)] struct ActivityJson { timestamp_iso: String, event_type: String, entity_type: String, entity_iid: i64, project: String, actor: Option, is_own: bool, summary: String, body_preview: Option, } impl From<&MeActivityEvent> for ActivityJson { fn from(e: &MeActivityEvent) -> Self { Self { timestamp_iso: ms_to_iso(e.timestamp), event_type: event_type_str(&e.event_type), entity_type: e.entity_type.clone(), entity_iid: e.entity_iid, project: e.project_path.clone(), actor: e.actor.clone(), is_own: e.is_own, summary: e.summary.clone(), body_preview: e.body_preview.clone(), } } } // ─── Since Last Check ──────────────────────────────────────────────────────── #[derive(Serialize)] struct SinceLastCheckJson { cursor_iso: String, total_event_count: usize, groups: Vec, } impl From<&SinceLastCheck> for SinceLastCheckJson { fn from(s: &SinceLastCheck) -> Self { Self { cursor_iso: ms_to_iso(s.cursor_ms), total_event_count: s.total_event_count, groups: s.groups.iter().map(SinceCheckGroupJson::from).collect(), } } } #[derive(Serialize)] struct SinceCheckGroupJson { entity_type: String, entity_iid: i64, entity_title: String, project: String, events: Vec, } impl From<&SinceCheckGroup> for SinceCheckGroupJson { fn from(g: &SinceCheckGroup) -> Self { Self { entity_type: g.entity_type.clone(), entity_iid: g.entity_iid, entity_title: g.entity_title.clone(), project: g.project_path.clone(), events: g.events.iter().map(SinceCheckEventJson::from).collect(), } } } #[derive(Serialize)] struct SinceCheckEventJson { timestamp_iso: String, event_type: String, actor: Option, summary: String, body_preview: Option, } impl From<&SinceCheckEvent> for SinceCheckEventJson { fn from(e: &SinceCheckEvent) -> Self { Self { timestamp_iso: ms_to_iso(e.timestamp), event_type: event_type_str(&e.event_type), actor: e.actor.clone(), summary: e.summary.clone(), body_preview: e.body_preview.clone(), } } } // ─── Helpers ───────────────────────────────────────────────────────────────── /// Convert `AttentionState` to its programmatic string representation. fn attention_state_str(state: &AttentionState) -> String { match state { AttentionState::NeedsAttention => "needs_attention", AttentionState::NotStarted => "not_started", AttentionState::AwaitingResponse => "awaiting_response", AttentionState::Stale => "stale", AttentionState::NotReady => "not_ready", } .to_string() } /// Convert `ActivityEventType` to its programmatic string representation. fn event_type_str(event_type: &ActivityEventType) -> String { match event_type { ActivityEventType::Note => "note", ActivityEventType::StatusChange => "status_change", ActivityEventType::LabelChange => "label_change", ActivityEventType::Assign => "assign", ActivityEventType::Unassign => "unassign", ActivityEventType::ReviewRequest => "review_request", ActivityEventType::MilestoneChange => "milestone_change", } .to_string() } // ─── Tests ─────────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; #[test] fn attention_state_str_all_variants() { assert_eq!( attention_state_str(&AttentionState::NeedsAttention), "needs_attention" ); assert_eq!( attention_state_str(&AttentionState::NotStarted), "not_started" ); assert_eq!( attention_state_str(&AttentionState::AwaitingResponse), "awaiting_response" ); assert_eq!(attention_state_str(&AttentionState::Stale), "stale"); assert_eq!(attention_state_str(&AttentionState::NotReady), "not_ready"); } #[test] fn event_type_str_all_variants() { assert_eq!(event_type_str(&ActivityEventType::Note), "note"); assert_eq!( event_type_str(&ActivityEventType::StatusChange), "status_change" ); assert_eq!( event_type_str(&ActivityEventType::LabelChange), "label_change" ); assert_eq!(event_type_str(&ActivityEventType::Assign), "assign"); assert_eq!(event_type_str(&ActivityEventType::Unassign), "unassign"); assert_eq!( event_type_str(&ActivityEventType::ReviewRequest), "review_request" ); assert_eq!( event_type_str(&ActivityEventType::MilestoneChange), "milestone_change" ); } #[test] fn issue_json_from_me_issue() { let issue = MeIssue { iid: 42, title: "Fix auth bug".to_string(), project_path: "group/repo".to_string(), attention_state: AttentionState::NeedsAttention, attention_reason: "Others commented recently; you haven't replied".to_string(), status_name: Some("In progress".to_string()), labels: vec!["bug".to_string()], updated_at: 1_700_000_000_000, web_url: Some("https://gitlab.com/group/repo/-/issues/42".to_string()), }; let json = IssueJson::from(&issue); assert_eq!(json.iid, 42); assert_eq!(json.attention_state, "needs_attention"); assert_eq!( json.attention_reason, "Others commented recently; you haven't replied" ); assert_eq!(json.state, "opened"); assert_eq!(json.status_name, Some("In progress".to_string())); } #[test] fn mr_json_from_me_mr() { let mr = MeMr { iid: 99, title: "Add feature".to_string(), project_path: "group/repo".to_string(), attention_state: AttentionState::AwaitingResponse, attention_reason: "You replied moments ago; awaiting others".to_string(), draft: true, detailed_merge_status: Some("mergeable".to_string()), author_username: Some("alice".to_string()), labels: vec![], updated_at: 1_700_000_000_000, web_url: None, }; let json = MrJson::from(&mr); assert_eq!(json.iid, 99); assert_eq!(json.attention_state, "awaiting_response"); assert_eq!( json.attention_reason, "You replied moments ago; awaiting others" ); assert!(json.draft); assert_eq!(json.author_username, Some("alice".to_string())); } #[test] fn activity_json_from_event() { let event = MeActivityEvent { timestamp: 1_700_000_000_000, event_type: ActivityEventType::Note, entity_type: "issue".to_string(), entity_iid: 42, project_path: "group/repo".to_string(), actor: Some("bob".to_string()), is_own: false, summary: "Added a comment".to_string(), body_preview: Some("This looks good".to_string()), }; let json = ActivityJson::from(&event); assert_eq!(json.event_type, "note"); assert_eq!(json.entity_iid, 42); assert!(!json.is_own); assert_eq!(json.body_preview, Some("This looks good".to_string())); } #[test] fn cursor_reset_envelope_includes_meta_elapsed_ms() { let value = cursor_reset_envelope_json(17); assert_eq!(value["ok"], serde_json::json!(true)); assert_eq!(value["data"]["cursor_reset"], serde_json::json!(true)); assert_eq!(value["meta"]["elapsed_ms"], serde_json::json!(17)); } }