use crate::cli::render::{self, Icons, Theme}; use serde::Serialize; use crate::Config; use crate::cli::progress::stage_spinner_v2; use crate::core::db::create_connection; use crate::core::error::{LoreError, 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::embedding::ollama::{OllamaClient, OllamaConfig}; use crate::timeline::collect::collect_events; use crate::timeline::expand::expand_timeline; use crate::timeline::seed::{seed_timeline, seed_timeline_direct}; use crate::timeline::{ EntityRef, ExpandedEntityRef, TimelineEvent, TimelineEventType, TimelineResult, UnresolvedRef, }; /// Parameters for running the timeline pipeline. pub struct TimelineParams { pub query: String, pub project: Option, pub since: Option, pub depth: u32, pub no_mentions: bool, pub limit: usize, pub max_seeds: usize, pub max_entities: usize, pub max_evidence: usize, pub robot_mode: bool, } /// Parsed timeline query: either a search string or a direct entity reference. enum TimelineQuery { Search(String), EntityDirect { entity_type: String, iid: i64 }, } /// Parse the timeline query for entity-direct patterns. /// /// Recognized patterns (case-insensitive prefix): /// - `issue:N`, `i:N` -> issue /// - `mr:N`, `m:N` -> merge_request /// - Anything else -> search query fn parse_timeline_query(query: &str) -> TimelineQuery { let query = query.trim(); if let Some((prefix, rest)) = query.split_once(':') { let prefix_lower = prefix.to_ascii_lowercase(); if let Ok(iid) = rest.trim().parse::() { match prefix_lower.as_str() { "issue" | "i" => { return TimelineQuery::EntityDirect { entity_type: "issue".to_owned(), iid, }; } "mr" | "m" => { return TimelineQuery::EntityDirect { entity_type: "merge_request".to_owned(), iid, }; } _ => {} } } } TimelineQuery::Search(query.to_owned()) } /// Run the full timeline pipeline: SEED -> EXPAND -> COLLECT. pub async 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() .map(|s| { parse_since(s).ok_or_else(|| { LoreError::Other(format!( "Invalid --since value: '{s}'. Use a duration (7d, 2w, 6m) or date (2024-01-15)" )) }) }) .transpose()?; // Parse query for entity-direct syntax (issue:N, mr:N, i:N, m:N) let parsed_query = parse_timeline_query(¶ms.query); let seed_result = match parsed_query { TimelineQuery::EntityDirect { entity_type, iid } => { // Direct seeding: synchronous, no Ollama needed let spinner = stage_spinner_v2( Icons::search(), "Resolve", "Resolving entity...", params.robot_mode, ); let result = seed_timeline_direct(&conn, &entity_type, iid, project_id)?; spinner.finish_and_clear(); result } TimelineQuery::Search(ref query) => { // Construct OllamaClient for hybrid search (same pattern as run_search) let ollama_cfg = &config.embedding; let client = OllamaClient::new(OllamaConfig { base_url: ollama_cfg.base_url.clone(), model: ollama_cfg.model.clone(), ..OllamaConfig::default() }); // Stage 1+2: SEED + HYDRATE (hybrid search with FTS fallback) let spinner = stage_spinner_v2( Icons::search(), "Seed", "Seeding timeline...", params.robot_mode, ); let result = seed_timeline( &conn, Some(&client), query, project_id, since_ms, params.max_seeds, params.max_evidence, ) .await?; spinner.finish_and_clear(); result } }; // Stage 3: EXPAND let spinner = stage_spinner_v2( Icons::sync(), "Expand", "Expanding cross-references...", params.robot_mode, ); let expand_result = expand_timeline( &conn, &seed_result.seed_entities, params.depth, !params.no_mentions, params.max_entities, )?; spinner.finish_and_clear(); // Stage 4: COLLECT let spinner = stage_spinner_v2( Icons::sync(), "Collect", "Collecting events...", params.robot_mode, ); let (events, total_before_limit) = collect_events( &conn, &seed_result.seed_entities, &expand_result.expanded_entities, &seed_result.evidence_notes, &seed_result.matched_discussions, since_ms, params.limit, )?; spinner.finish_and_clear(); Ok(TimelineResult { query: params.query.clone(), search_mode: seed_result.search_mode, events, total_filtered_events: total_before_limit, 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!( "{}", Theme::bold().render(&format!( "Timeline: \"{}\" ({} events across {} entities)", result.query, result.events.len(), entity_count, )) ); println!("{}", "\u{2500}".repeat(60)); println!(); if result.events.is_empty() { println!( " {}", Theme::dim().render("No events found for this query.") ); println!(); return; } for event in &result.events { print_timeline_event(event); } println!(); println!("{}", "\u{2500}".repeat(60)); print_timeline_footer(result); } fn print_timeline_event(event: &TimelineEvent) { let date = render::format_date(event.timestamp); let tag = format_event_tag(&event.event_type); let entity_icon = match event.entity_type.as_str() { "issue" => Icons::issue_opened(), "merge_request" => Icons::mr_opened(), _ => "", }; 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 = render::truncate(&event.summary, 50); println!("{date} {tag} {entity_icon}{entity_ref:7} {summary:50} {actor}{expanded_marker}"); // Show snippet for evidence notes if let TimelineEventType::NoteEvidence { snippet, .. } = &event.event_type && !snippet.is_empty() { let mut lines = render::wrap_lines(snippet, 60); lines.truncate(4); for line in lines { println!( " \"{}\"", Theme::dim().render(&line) ); } } // Show full discussion thread if let TimelineEventType::DiscussionThread { notes, .. } = &event.event_type { let bar = "\u{2500}".repeat(44); println!(" \u{2500}\u{2500} Discussion {bar}"); for note in notes { let note_date = render::format_date(note.created_at); let author = note .author .as_deref() .map(|a| format!("@{a}")) .unwrap_or_else(|| "unknown".to_owned()); println!(" {} ({note_date}):", Theme::bold().render(&author)); for line in render::wrap_lines(¬e.body, 60) { println!(" {line}"); } } println!(" {}", "\u{2500}".repeat(60)); } } 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!(); } /// Format event tag: pad plain text to TAG_WIDTH, then apply style. const TAG_WIDTH: usize = 11; fn format_event_tag(event_type: &TimelineEventType) -> String { let (label, style) = match event_type { TimelineEventType::Created => ("CREATED", Theme::success()), TimelineEventType::StateChanged { state } => match state.as_str() { "closed" => ("CLOSED", Theme::error()), "reopened" => ("REOPENED", Theme::warning()), _ => return style_padded(&state.to_uppercase(), TAG_WIDTH, Theme::dim()), }, TimelineEventType::LabelAdded { .. } => ("LABEL+", Theme::info()), TimelineEventType::LabelRemoved { .. } => ("LABEL-", Theme::info()), TimelineEventType::MilestoneSet { .. } => ("MILESTONE+", Theme::accent()), TimelineEventType::MilestoneRemoved { .. } => ("MILESTONE-", Theme::accent()), TimelineEventType::Merged => ("MERGED", Theme::info()), TimelineEventType::NoteEvidence { .. } => ("NOTE", Theme::dim()), TimelineEventType::DiscussionThread { .. } => ("THREAD", Theme::warning()), TimelineEventType::CrossReferenced { .. } => ("REF", Theme::dim()), }; style_padded(label, TAG_WIDTH, style) } /// Pad text to width, then apply lipgloss style (so ANSI codes don't break alignment). fn style_padded(text: &str, width: usize, style: lipgloss::Style) -> String { let padded = format!("{: String { match entity_type { "issue" => format!("#{iid}"), "merge_request" => format!("!{iid}"), _ => format!("{entity_type}:{iid}"), } } // ─── Robot JSON output ─────────────────────────────────────────────────────── /// Render timeline as robot-mode JSON in {ok, data, meta} envelope. pub fn print_timeline_json_with_meta( result: &TimelineResult, total_filtered_events: usize, depth: u32, include_mentions: bool, fields: Option<&[String]>, ) { let output = TimelineJsonEnvelope { ok: true, data: TimelineDataJson::from_result(result), meta: TimelineMetaJson { search_mode: result.search_mode.clone(), expansion_depth: depth, include_mentions, total_entities: result.seed_entities.len() + result.expanded_entities.len(), total_events: total_filtered_events, evidence_notes_included: count_evidence_notes(&result.events), discussion_threads_included: count_discussion_threads(&result.events), unresolved_references: result.unresolved_references.len(), showing: result.events.len(), }, }; let mut value = match serde_json::to_value(&output) { Ok(v) => v, Err(e) => { eprintln!("Error serializing timeline JSON: {e}"); return; } }; if let Some(f) = fields { let expanded = crate::cli::robot::expand_fields_preset(f, "timeline"); crate::cli::robot::filter_fields(&mut value, "events", &expanded); } match serde_json::to_string(&value) { Ok(json) => println!("{json}"), Err(e) => eprintln!("Error serializing to 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::DiscussionThread { discussion_id, notes, } => ( "discussion_thread".to_owned(), serde_json::json!({ "discussion_id": discussion_id, "note_count": notes.len(), "notes": notes.iter().map(|n| serde_json::json!({ "note_id": n.note_id, "author": n.author, "body": n.body, "created_at": ms_to_iso(n.created_at), })).collect::>(), }), ), TimelineEventType::CrossReferenced { target } => ( "cross_referenced".to_owned(), serde_json::json!({ "target": target }), ), } } #[derive(Serialize)] struct TimelineMetaJson { search_mode: String, expansion_depth: u32, include_mentions: bool, total_entities: usize, total_events: usize, evidence_notes_included: usize, discussion_threads_included: usize, unresolved_references: usize, showing: usize, } fn count_evidence_notes(events: &[TimelineEvent]) -> usize { events .iter() .filter(|e| matches!(e.event_type, TimelineEventType::NoteEvidence { .. })) .count() } fn count_discussion_threads(events: &[TimelineEvent]) -> usize { events .iter() .filter(|e| matches!(e.event_type, TimelineEventType::DiscussionThread { .. })) .count() } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_issue_colon_number() { let q = parse_timeline_query("issue:42"); assert!( matches!(q, TimelineQuery::EntityDirect { ref entity_type, iid } if entity_type == "issue" && iid == 42) ); } #[test] fn test_parse_i_colon_number() { let q = parse_timeline_query("i:42"); assert!( matches!(q, TimelineQuery::EntityDirect { ref entity_type, iid } if entity_type == "issue" && iid == 42) ); } #[test] fn test_parse_mr_colon_number() { let q = parse_timeline_query("mr:99"); assert!( matches!(q, TimelineQuery::EntityDirect { ref entity_type, iid } if entity_type == "merge_request" && iid == 99) ); } #[test] fn test_parse_m_colon_number() { let q = parse_timeline_query("m:99"); assert!( matches!(q, TimelineQuery::EntityDirect { ref entity_type, iid } if entity_type == "merge_request" && iid == 99) ); } #[test] fn test_parse_case_insensitive() { let q = parse_timeline_query("ISSUE:42"); assert!( matches!(q, TimelineQuery::EntityDirect { ref entity_type, iid } if entity_type == "issue" && iid == 42) ); let q = parse_timeline_query("MR:99"); assert!( matches!(q, TimelineQuery::EntityDirect { ref entity_type, iid } if entity_type == "merge_request" && iid == 99) ); let q = parse_timeline_query("Issue:7"); assert!( matches!(q, TimelineQuery::EntityDirect { ref entity_type, iid } if entity_type == "issue" && iid == 7) ); } #[test] fn test_parse_search_fallback() { let q = parse_timeline_query("switch health"); assert!(matches!(q, TimelineQuery::Search(ref s) if s == "switch health")); } #[test] fn test_parse_non_numeric_falls_back_to_search() { let q = parse_timeline_query("issue:abc"); assert!(matches!(q, TimelineQuery::Search(_))); } #[test] fn test_parse_unknown_prefix_falls_back_to_search() { let q = parse_timeline_query("foo:42"); assert!(matches!(q, TimelineQuery::Search(_))); } #[test] fn test_parse_whitespace_trimmed() { let q = parse_timeline_query(" issue:42 "); assert!( matches!(q, TimelineQuery::EntityDirect { ref entity_type, iid } if entity_type == "issue" && iid == 42) ); } }