use console::style; use serde::Serialize; use crate::Config; use crate::core::db::create_connection; use crate::core::error::Result; use crate::core::paths::get_db_path; use crate::core::project::resolve_project; use crate::core::time::{ms_to_iso, parse_since}; use crate::core::timeline::{ EntityRef, ExpandedEntityRef, TimelineEvent, TimelineEventType, TimelineResult, UnresolvedRef, }; use crate::core::timeline_collect::collect_events; use crate::core::timeline_expand::expand_timeline; use crate::core::timeline_seed::seed_timeline; /// Parameters for running the timeline pipeline. pub struct TimelineParams { pub query: String, pub project: Option, pub since: Option, pub depth: u32, pub expand_mentions: bool, pub limit: usize, pub max_seeds: usize, pub max_entities: usize, pub max_evidence: usize, } /// Run the full timeline pipeline: SEED -> EXPAND -> COLLECT. pub fn run_timeline(config: &Config, params: &TimelineParams) -> Result { let db_path = get_db_path(config.storage.db_path.as_deref()); let conn = create_connection(&db_path)?; let project_id = params .project .as_deref() .map(|p| resolve_project(&conn, p)) .transpose()?; let since_ms = params.since.as_deref().and_then(parse_since); // Stage 1+2: SEED + HYDRATE let seed_result = seed_timeline( &conn, ¶ms.query, project_id, since_ms, params.max_seeds, params.max_evidence, )?; // Stage 3: EXPAND let expand_result = expand_timeline( &conn, &seed_result.seed_entities, params.depth, params.expand_mentions, params.max_entities, )?; // Stage 4: COLLECT let events = collect_events( &conn, &seed_result.seed_entities, &expand_result.expanded_entities, &seed_result.evidence_notes, since_ms, params.limit, )?; Ok(TimelineResult { query: params.query.clone(), events, seed_entities: seed_result.seed_entities, expanded_entities: expand_result.expanded_entities, unresolved_references: expand_result.unresolved_references, }) } // ─── Human output ──────────────────────────────────────────────────────────── /// Render timeline as colored human-readable output. pub fn print_timeline(result: &TimelineResult) { let entity_count = result.seed_entities.len() + result.expanded_entities.len(); println!(); println!( "{}", style(format!( "Timeline: \"{}\" ({} events across {} entities)", result.query, result.events.len(), entity_count, )) .bold() ); println!("{}", "─".repeat(60)); println!(); if result.events.is_empty() { println!(" {}", style("No events found for this query.").dim()); println!(); return; } for event in &result.events { print_timeline_event(event); } println!(); println!("{}", "─".repeat(60)); print_timeline_footer(result); } fn print_timeline_event(event: &TimelineEvent) { let date = format_date(event.timestamp); let tag = format_event_tag(&event.event_type); let entity_ref = format_entity_ref(&event.entity_type, event.entity_iid); let actor = event .actor .as_deref() .map(|a| format!("@{a}")) .unwrap_or_default(); let expanded_marker = if event.is_seed { "" } else { " [expanded]" }; let summary = truncate_summary(&event.summary, 50); println!("{date} {tag:12} {entity_ref:7} {summary:50} {actor}{expanded_marker}"); // Show snippet for evidence notes if let TimelineEventType::NoteEvidence { snippet, .. } = &event.event_type && !snippet.is_empty() { for line in wrap_snippet(snippet, 60) { println!( " \"{}\"", style(line).dim() ); } } } fn print_timeline_footer(result: &TimelineResult) { println!( " Seed entities: {}", result .seed_entities .iter() .map(|e| format_entity_ref(&e.entity_type, e.entity_iid)) .collect::>() .join(", ") ); if !result.expanded_entities.is_empty() { println!( " Expanded: {} entities via cross-references", result.expanded_entities.len() ); } if !result.unresolved_references.is_empty() { println!( " Unresolved: {} external references", result.unresolved_references.len() ); } println!(); } fn format_event_tag(event_type: &TimelineEventType) -> String { match event_type { TimelineEventType::Created => style("CREATED").green().to_string(), TimelineEventType::StateChanged { state } => match state.as_str() { "closed" => style("CLOSED").red().to_string(), "reopened" => style("REOPENED").yellow().to_string(), _ => style(state.to_uppercase()).dim().to_string(), }, TimelineEventType::LabelAdded { .. } => style("LABEL+").blue().to_string(), TimelineEventType::LabelRemoved { .. } => style("LABEL-").blue().to_string(), TimelineEventType::MilestoneSet { .. } => style("MILESTONE+").magenta().to_string(), TimelineEventType::MilestoneRemoved { .. } => style("MILESTONE-").magenta().to_string(), TimelineEventType::Merged => style("MERGED").cyan().to_string(), TimelineEventType::NoteEvidence { .. } => style("NOTE").dim().to_string(), TimelineEventType::CrossReferenced { .. } => style("REF").dim().to_string(), } } fn format_entity_ref(entity_type: &str, iid: i64) -> String { match entity_type { "issue" => format!("#{iid}"), "merge_request" => format!("!{iid}"), _ => format!("{entity_type}:{iid}"), } } fn format_date(ms: i64) -> String { let iso = ms_to_iso(ms); iso.split('T').next().unwrap_or(&iso).to_string() } fn truncate_summary(s: &str, max: usize) -> String { if s.chars().count() <= max { s.to_owned() } else { let truncated: String = s.chars().take(max - 3).collect(); format!("{truncated}...") } } fn wrap_snippet(text: &str, width: usize) -> Vec { let mut lines = Vec::new(); let mut current = String::new(); for word in text.split_whitespace() { if current.is_empty() { current = word.to_string(); } else if current.len() + 1 + word.len() <= width { current.push(' '); current.push_str(word); } else { lines.push(current); current = word.to_string(); } } if !current.is_empty() { lines.push(current); } // Cap at 4 lines lines.truncate(4); lines } // ─── Robot JSON output ─────────────────────────────────────────────────────── /// Render timeline as robot-mode JSON in {ok, data, meta} envelope. pub fn print_timeline_json(result: &TimelineResult, total_events_before_limit: usize) { let output = TimelineJsonEnvelope { ok: true, data: TimelineDataJson::from_result(result), meta: TimelineMetaJson { search_mode: "lexical".to_owned(), expansion_depth: infer_max_depth(&result.expanded_entities), expand_mentions: false, // caller should pass this, but we infer from data total_entities: result.seed_entities.len() + result.expanded_entities.len(), total_events: total_events_before_limit, evidence_notes_included: count_evidence_notes(&result.events), unresolved_references: result.unresolved_references.len(), showing: result.events.len(), }, }; match serde_json::to_string(&output) { Ok(json) => println!("{json}"), Err(e) => eprintln!("Error serializing timeline JSON: {e}"), } } /// Extended version that accepts explicit meta values from the caller. pub fn print_timeline_json_with_meta( result: &TimelineResult, total_events_before_limit: usize, depth: u32, expand_mentions: bool, ) { let output = TimelineJsonEnvelope { ok: true, data: TimelineDataJson::from_result(result), meta: TimelineMetaJson { search_mode: "lexical".to_owned(), expansion_depth: depth, expand_mentions, total_entities: result.seed_entities.len() + result.expanded_entities.len(), total_events: total_events_before_limit, evidence_notes_included: count_evidence_notes(&result.events), unresolved_references: result.unresolved_references.len(), showing: result.events.len(), }, }; match serde_json::to_string(&output) { Ok(json) => println!("{json}"), Err(e) => eprintln!("Error serializing timeline JSON: {e}"), } } #[derive(Serialize)] struct TimelineJsonEnvelope { ok: bool, data: TimelineDataJson, meta: TimelineMetaJson, } #[derive(Serialize)] struct TimelineDataJson { query: String, event_count: usize, seed_entities: Vec, expanded_entities: Vec, unresolved_references: Vec, events: Vec, } impl TimelineDataJson { fn from_result(result: &TimelineResult) -> Self { Self { query: result.query.clone(), event_count: result.events.len(), seed_entities: result.seed_entities.iter().map(EntityJson::from).collect(), expanded_entities: result .expanded_entities .iter() .map(ExpandedEntityJson::from) .collect(), unresolved_references: result .unresolved_references .iter() .map(UnresolvedRefJson::from) .collect(), events: result.events.iter().map(EventJson::from).collect(), } } } #[derive(Serialize)] struct EntityJson { #[serde(rename = "type")] entity_type: String, iid: i64, project: String, } impl From<&EntityRef> for EntityJson { fn from(e: &EntityRef) -> Self { Self { entity_type: e.entity_type.clone(), iid: e.entity_iid, project: e.project_path.clone(), } } } #[derive(Serialize)] struct ExpandedEntityJson { #[serde(rename = "type")] entity_type: String, iid: i64, project: String, depth: u32, via: ViaJson, } impl From<&ExpandedEntityRef> for ExpandedEntityJson { fn from(e: &ExpandedEntityRef) -> Self { Self { entity_type: e.entity_ref.entity_type.clone(), iid: e.entity_ref.entity_iid, project: e.entity_ref.project_path.clone(), depth: e.depth, via: ViaJson { from: EntityJson::from(&e.via_from), reference_type: e.via_reference_type.clone(), source_method: e.via_source_method.clone(), }, } } } #[derive(Serialize)] struct ViaJson { from: EntityJson, reference_type: String, source_method: String, } #[derive(Serialize)] struct UnresolvedRefJson { source: EntityJson, target_project: Option, target_type: String, target_iid: Option, reference_type: String, } impl From<&UnresolvedRef> for UnresolvedRefJson { fn from(r: &UnresolvedRef) -> Self { Self { source: EntityJson::from(&r.source), target_project: r.target_project.clone(), target_type: r.target_type.clone(), target_iid: r.target_iid, reference_type: r.reference_type.clone(), } } } #[derive(Serialize)] struct EventJson { timestamp: String, entity_type: String, entity_iid: i64, project: String, event_type: String, summary: String, actor: Option, url: Option, is_seed: bool, details: serde_json::Value, } impl From<&TimelineEvent> for EventJson { fn from(e: &TimelineEvent) -> Self { let (event_type, details) = event_type_to_json(&e.event_type); Self { timestamp: ms_to_iso(e.timestamp), entity_type: e.entity_type.clone(), entity_iid: e.entity_iid, project: e.project_path.clone(), event_type, summary: e.summary.clone(), actor: e.actor.clone(), url: e.url.clone(), is_seed: e.is_seed, details, } } } fn event_type_to_json(event_type: &TimelineEventType) -> (String, serde_json::Value) { match event_type { TimelineEventType::Created => ("created".to_owned(), serde_json::json!({})), TimelineEventType::StateChanged { state } => ( "state_changed".to_owned(), serde_json::json!({ "state": state }), ), TimelineEventType::LabelAdded { label } => ( "label_added".to_owned(), serde_json::json!({ "label": label }), ), TimelineEventType::LabelRemoved { label } => ( "label_removed".to_owned(), serde_json::json!({ "label": label }), ), TimelineEventType::MilestoneSet { milestone } => ( "milestone_set".to_owned(), serde_json::json!({ "milestone": milestone }), ), TimelineEventType::MilestoneRemoved { milestone } => ( "milestone_removed".to_owned(), serde_json::json!({ "milestone": milestone }), ), TimelineEventType::Merged => ("merged".to_owned(), serde_json::json!({})), TimelineEventType::NoteEvidence { note_id, snippet, discussion_id, } => ( "note_evidence".to_owned(), serde_json::json!({ "note_id": note_id, "snippet": snippet, "discussion_id": discussion_id, }), ), TimelineEventType::CrossReferenced { target } => ( "cross_referenced".to_owned(), serde_json::json!({ "target": target }), ), } } #[derive(Serialize)] struct TimelineMetaJson { search_mode: String, expansion_depth: u32, expand_mentions: bool, total_entities: usize, total_events: usize, evidence_notes_included: usize, unresolved_references: usize, showing: usize, } fn infer_max_depth(expanded: &[ExpandedEntityRef]) -> u32 { expanded.iter().map(|e| e.depth).max().unwrap_or(0) } fn count_evidence_notes(events: &[TimelineEvent]) -> usize { events .iter() .filter(|e| matches!(e.event_type, TimelineEventType::NoteEvidence { .. })) .count() }