Replace all console::style() calls in command modules with the centralized Theme API and render:: utility functions. This ensures consistent color behavior across the entire CLI, proper NO_COLOR/--color never support via the LoreRenderer singleton, and eliminates duplicated formatting code. Changes per module: - count.rs: Theme for table headers, render::format_number replacing local duplicate. Removed local format_number implementation. - doctor.rs: Theme::success/warning/error for check status symbols and messages. Unicode escapes for check/warning/cross symbols. - drift.rs: Theme::bold/error/success for drift detection headers and status messages. - embed.rs: Compact output format — headline with count, zero-suppressed detail lines, 'nothing to embed' short-circuit for no-op runs. - generate_docs.rs: Same compact pattern — headline + detail + hint for next step. No-op short-circuit when regenerated==0. - ingest.rs: Theme for project summaries, sync status, dry-run preview. All console::style -> Theme replacements. - list.rs: Replace comfy-table with render::LoreTable for issue/MR listing. Remove local colored_cell, colored_cell_hex, format_relative_time, truncate_with_ellipsis, and format_labels (all moved to render.rs). - list_tests.rs: Update test assertions to use render:: functions. - search.rs: Add render_snippet() for FTS5 <mark> tag highlighting via Theme::bold().underline(). Compact result layout with type badges. - show.rs: Theme for entity detail views, delegate format_date and wrap_text to render module. - stats.rs: Section-based layout using render::section_divider. Compact middle-dot format for document counts. Color-coded embedding coverage percentage (green >=95%, yellow >=50%, red <50%). - sync.rs: Compact sync summary — headline with counts and elapsed time, zero-suppressed detail lines, visually prominent error-only section. - sync_status.rs: Theme for run history headers, removed local format_number duplicate. - timeline.rs: Theme for headers/footers, render:: for date/truncate, standard format! padding replacing console::pad_str. - who.rs: Theme for all expert/workload/active/overlap/review output modes, render:: for relative time and truncation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
654 lines
21 KiB
Rust
654 lines
21 KiB
Rust
use crate::cli::render::{self, Theme};
|
|
use serde::Serialize;
|
|
|
|
use crate::Config;
|
|
use crate::cli::progress::stage_spinner;
|
|
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::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, seed_timeline_direct};
|
|
use crate::embedding::ollama::{OllamaClient, OllamaConfig};
|
|
|
|
/// Parameters for running the timeline pipeline.
|
|
pub struct TimelineParams {
|
|
pub query: String,
|
|
pub project: Option<String>,
|
|
pub since: Option<String>,
|
|
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::<i64>() {
|
|
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<TimelineResult> {
|
|
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(1, 3, "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(1, 3, "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(2, 3, "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(3, 3, "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_events_before_limit: 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_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);
|
|
let tag_padded = format!("{:<12}", tag);
|
|
println!("{date} {tag_padded} {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::<Vec<_>>()
|
|
.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 => Theme::success().render("CREATED"),
|
|
TimelineEventType::StateChanged { state } => match state.as_str() {
|
|
"closed" => Theme::error().render("CLOSED"),
|
|
"reopened" => Theme::warning().render("REOPENED"),
|
|
_ => Theme::dim().render(&state.to_uppercase()),
|
|
},
|
|
TimelineEventType::LabelAdded { .. } => Theme::info().render("LABEL+"),
|
|
TimelineEventType::LabelRemoved { .. } => Theme::info().render("LABEL-"),
|
|
TimelineEventType::MilestoneSet { .. } => Theme::accent().render("MILESTONE+"),
|
|
TimelineEventType::MilestoneRemoved { .. } => Theme::accent().render("MILESTONE-"),
|
|
TimelineEventType::Merged => Theme::info().render("MERGED"),
|
|
TimelineEventType::NoteEvidence { .. } => Theme::dim().render("NOTE"),
|
|
TimelineEventType::DiscussionThread { .. } => Theme::warning().render("THREAD"),
|
|
TimelineEventType::CrossReferenced { .. } => Theme::dim().render("REF"),
|
|
}
|
|
}
|
|
|
|
fn format_entity_ref(entity_type: &str, iid: i64) -> 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_events_before_limit: 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_events_before_limit,
|
|
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);
|
|
}
|
|
println!("{}", serde_json::to_string(&value).unwrap());
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct TimelineJsonEnvelope {
|
|
ok: bool,
|
|
data: TimelineDataJson,
|
|
meta: TimelineMetaJson,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct TimelineDataJson {
|
|
query: String,
|
|
event_count: usize,
|
|
seed_entities: Vec<EntityJson>,
|
|
expanded_entities: Vec<ExpandedEntityJson>,
|
|
unresolved_references: Vec<UnresolvedRefJson>,
|
|
events: Vec<EventJson>,
|
|
}
|
|
|
|
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<String>,
|
|
target_type: String,
|
|
target_iid: Option<i64>,
|
|
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<String>,
|
|
url: Option<String>,
|
|
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::<Vec<_>>(),
|
|
}),
|
|
),
|
|
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)
|
|
);
|
|
}
|
|
}
|