8 Commits

Author SHA1 Message Date
Taylor Eernisse
ebf64816c9 fix(search): correct FTS5 raw mode fallback test assertion
Update test_raw_mode_leading_wildcard_falls_back_to_safe to match the
actual Safe mode behavior: OR is a recognized FTS5 boolean operator and
passes through unquoted, so the expected output is '"*" OR "auth"' not
'"*" "OR" "auth"'. The previous assertion was incorrect since the Safe
mode operator-passthrough logic was added.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 22:34:01 -05:00
Taylor Eernisse
450951dee1 feat(timeline): rename --expand-mentions to --no-mentions, default mentions on
Invert the timeline mention-expansion flag semantics. Previously, mention
edges were excluded by default and --expand-mentions opted in. Now mention
edges are included by default (matching the more common use case) and
--no-mentions opts out to reduce fan-out when needed.

This is a breaking CLI change but aligns with the principle that the
default behavior should produce the most useful output. Users who were
passing --expand-mentions get the same behavior without any flag. Users
who want reduced output can pass --no-mentions.

Updated: CLI args (TimelineArgs), autocorrect flag list, robot-docs
schema, README documentation and flag reference table.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 22:33:34 -05:00
Taylor Eernisse
81f049a7fa refactor(main): wire LoreRenderer init, migrate to Theme, improve UX polish
Wire the LoreRenderer singleton initialization into main.rs color mode
handling, replacing the console::style import with Theme throughout.

Key changes:

- Color initialization: LoreRenderer::init() called for all code paths
  (NO_COLOR, --color never/always/auto, unknown mode fallback) alongside
  the existing console::set_colors_enabled() calls. Both systems must
  agree since some transitive code still uses console (e.g. dialoguer).

- Tracing: Replace .with_target(false) with .event_format(CompactHumanFormat)
  for the stderr layer, producing the clean 'HH:MM:SS LEVEL  message' format.

- Error handling: handle_error() now shows machine-actionable recovery
  commands from gi_error.actions() below the hint, formatted with dim '$'
  prefix and bold command text.

- Deprecation warnings: All 'lore list', 'lore show', 'lore auth-test',
  'lore sync-status' warnings migrated to Theme::warning().

- Init wizard: All success/info/error messages migrated. Unicode check
  marks use explicit \u{2713} escapes instead of literal symbols.

- Embed command: Added progress bar with indicatif for embedding stage,
  showing position/total with steady tick. Elapsed time shown on completion.

- Generate-docs and ingest commands: Added 'Done in Xs' elapsed time and
  next-step hints (run embed after generate-docs, run generate-docs after
  ingest) for better workflow guidance.

- Sync output: Interrupt message and lock release migrated to Theme.

- Health command: Status labels and overall healthy/unhealthy styled.

- Robot-docs: Added drift command schema, updated sync flags to include
  --no-file-changes, updated who flags with new options.

- Timeline --expand-mentions -> --no-mentions flag rename wired through
  params and robot-docs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 22:33:09 -05:00
Taylor Eernisse
dd00a2b840 refactor(cli): migrate all command modules from console::style to Theme
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>
2026-02-13 22:32:35 -05:00
Taylor Eernisse
c6a5461d41 refactor(ingestion): compact log summaries and quieter shutdown messages
Migrate all ingestion completion logs to use nonzero_summary() for compact,
zero-suppressed output. Before: 8-14 individual key=value structured fields
per completion message. After: a single summary field like
'42 fetched · 3 labels · 12 notes' that only shows non-zero counters.

Also downgrade all 'Shutdown requested...' messages from info! to debug!.
These are emitted on every Ctrl+C and add noise to the partial results
output that immediately follows. They remain visible at -vv for debugging
graceful shutdown behavior.

Affected modules:
- issues.rs: issue ingestion completion
- merge_requests.rs: MR ingestion completion, full-sync cursor reset
- mr_discussions.rs: discussion ingestion completion
- orchestrator.rs: project-level issue and MR completion summaries,
  all shutdown-requested checkpoints across discussion sync, resource
  events drain, closes-issues drain, and MR diffs drain

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 22:31:57 -05:00
Taylor Eernisse
a7f86b26e4 refactor(core): compact human log format, quieter lock lifecycle, nonzero_summary helper
Three quality-of-life improvements to reduce log noise and improve readability:

1. logging.rs: Add CompactHumanFormat for stderr tracing output. Replaces the
   default format with a minimal 'HH:MM:SS LEVEL  message key=value' layout —
   no span context, no full timestamps, no target module. The JSON file log
   layer is unaffected. This makes watching 'lore sync' output much cleaner.

2. lock.rs: Downgrade AppLock acquire/release messages from info! to debug!.
   Lock lifecycle events (acquired new, acquired existing, released) are
   operational bookkeeping that clutters normal output. They remain visible
   at -vv verbosity for troubleshooting.

3. ingestion/mod.rs: Add nonzero_summary() utility that formats named counters
   as a compact middle-dot-separated string, suppressing zero values. Produces
   output like '42 fetched · 3 labels · 12 notes' instead of verbose key=value
   structured fields. Returns 'nothing to update' when all values are zero.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 22:31:30 -05:00
Taylor Eernisse
5ee8b0841c feat(cli): add centralized render module with semantic Theme and LoreRenderer
Introduce src/cli/render.rs as the single source of truth for all terminal
output styling and formatting utilities. Key components:

- LoreRenderer: global singleton initialized once at startup, resolving
  color mode (Auto/Always/Never) against TTY state and NO_COLOR env var.
  This fixes lipgloss's limitation of hardcoded TrueColor rendering by
  gating all style application through a colors_on() check.

- Theme: semantic style constants (success/warning/error/info/accent,
  entity refs, state colors, structural styles) that return plain
  Style::new() when colors are disabled. Replaces ad-hoc console::style()
  calls scattered across 15+ command modules.

- Shared formatting utilities consolidated from duplicated implementations:
  format_relative_time (was in list.rs and who.rs), format_number (was in
  count.rs and sync_status.rs), truncate (was truncate_with_ellipsis in
  list.rs and truncate_summary in timeline.rs), format_labels, format_date,
  wrap_indent, section_divider.

- LoreTable: lightweight table renderer replacing comfy-table with simple
  column alignment (Left/Right/Center), adaptive terminal width, and
  NO_COLOR-safe output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 22:31:02 -05:00
Taylor Eernisse
7062a3f1fd deps: replace comfy-table with lipgloss (charmed-lipgloss)
Switch from comfy-table to the lipgloss Rust port for terminal styling.
lipgloss provides a composable Style API better suited to our new semantic
theming approach (Theme::success(), Theme::error(), etc.) where we apply
styles to individual text spans rather than constructing styled table cells.
The comfy-table dependency was only used by the list command's human output
and is no longer needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 22:30:31 -05:00
30 changed files with 2161 additions and 1023 deletions

171
Cargo.lock generated
View File

@@ -169,6 +169,23 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "charmed-lipgloss"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45e10db01f5eaea11d98ca5c5cffd8cc4add7ac56d0128d91ba1f2a3757b6c5a"
dependencies = [
"bitflags",
"colored",
"crossterm",
"serde",
"serde_json",
"thiserror",
"toml",
"tracing",
"unicode-width 0.1.14",
]
[[package]]
name = "chrono"
version = "0.4.43"
@@ -239,14 +256,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "comfy-table"
version = "7.2.2"
name = "colored"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47"
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
dependencies = [
"crossterm",
"unicode-segmentation",
"unicode-width",
"lazy_static",
"windows-sys 0.52.0",
]
[[package]]
@@ -258,10 +274,19 @@ dependencies = [
"encode_unicode",
"libc",
"once_cell",
"unicode-width",
"unicode-width 0.2.2",
"windows-sys 0.61.2",
]
[[package]]
name = "convert_case"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -319,9 +344,13 @@ checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
dependencies = [
"bitflags",
"crossterm_winapi",
"derive_more",
"document-features",
"mio",
"parking_lot",
"rustix",
"signal-hook",
"signal-hook-mio",
"winapi",
]
@@ -371,6 +400,28 @@ dependencies = [
"powerfmt",
]
[[package]]
name = "derive_more"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
dependencies = [
"derive_more-impl",
]
[[package]]
name = "derive_more-impl"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
dependencies = [
"convert_case",
"proc-macro2",
"quote",
"rustc_version",
"syn",
]
[[package]]
name = "dialoguer"
version = "0.12.0"
@@ -976,7 +1027,7 @@ checksum = "9375e112e4b463ec1b1c6c011953545c65a30164fbab5b581df32b3abf0dcb88"
dependencies = [
"console",
"portable-atomic",
"unicode-width",
"unicode-width 0.2.2",
"unit-prefix",
"web-time",
]
@@ -1109,10 +1160,10 @@ name = "lore"
version = "0.8.2"
dependencies = [
"async-stream",
"charmed-lipgloss",
"chrono",
"clap",
"clap_complete",
"comfy-table",
"console",
"dialoguer",
"dirs",
@@ -1181,6 +1232,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.61.2",
]
@@ -1574,6 +1626,15 @@ dependencies = [
"sqlite-wasm-rs",
]
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "rustix"
version = "1.1.3"
@@ -1670,6 +1731,12 @@ dependencies = [
"libc",
]
[[package]]
name = "semver"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
[[package]]
name = "serde"
version = "1.0.228"
@@ -1713,6 +1780,15 @@ dependencies = [
"zmij",
]
[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
@@ -1757,6 +1833,27 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-mio"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
dependencies = [
"libc",
"mio",
"signal-hook",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.5"
@@ -2028,6 +2125,47 @@ dependencies = [
"tokio",
]
[[package]]
name = "toml"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
"serde_spanned",
"toml_datetime",
"toml_edit",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [
"serde",
]
[[package]]
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap",
"serde",
"serde_spanned",
"toml_datetime",
"toml_write",
"winnow",
]
[[package]]
name = "toml_write"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "tower"
version = "0.5.3"
@@ -2183,6 +2321,12 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-width"
version = "0.2.2"
@@ -2611,6 +2755,15 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.7.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
dependencies = [
"memchr",
]
[[package]]
name = "wiremock"
version = "0.6.5"

View File

@@ -25,7 +25,7 @@ clap_complete = "4"
dialoguer = "0.12"
console = "0.16"
indicatif = "0.18"
comfy-table = "7"
lipgloss = { package = "charmed-lipgloss", version = "0.1", default-features = false, features = ["native"] }
open = "5"
# HTTP

View File

@@ -400,7 +400,7 @@ lore timeline m:99 # Shorthand for mr:99
lore timeline "auth" -p group/repo # Scoped to a project
lore timeline "auth" --since 30d # Only recent events
lore timeline "migration" --depth 2 # Deeper cross-reference expansion
lore timeline "migration" --expand-mentions # Follow 'mentioned' edges (high fan-out)
lore timeline "migration" --no-mentions # Skip 'mentioned' edges (reduces fan-out)
lore timeline "deploy" -n 50 # Limit event count
lore timeline "auth" --max-seeds 5 # Fewer seed entities
```
@@ -414,7 +414,7 @@ The query can be either a search string (hybrid search finds matching entities)
| `-p` / `--project` | all | Scope to a specific project (fuzzy match) |
| `--since` | none | Only events after this date (7d, 2w, 6m, YYYY-MM-DD) |
| `--depth` | `1` | Cross-reference expansion depth (0 = seeds only) |
| `--expand-mentions` | off | Also follow "mentioned" edges during expansion |
| `--no-mentions` | off | Skip "mentioned" edges during expansion (reduces fan-out) |
| `-n` / `--limit` | `100` | Maximum events to display |
| `--max-seeds` | `10` | Maximum seed entities from search |
| `--max-entities` | `50` | Maximum entities discovered via cross-references |
@@ -427,7 +427,7 @@ Each stage displays a numbered progress spinner (e.g., `[1/3] Seeding timeline..
1. **SEED** -- Hybrid search (FTS5 lexical + Ollama vector similarity via Reciprocal Rank Fusion) identifies the most relevant issues and MRs. Falls back to lexical-only if Ollama is unavailable. Discussion notes matching the query are also discovered and attached to their parent entities.
2. **HYDRATE** -- Evidence notes are extracted: the top search-matched discussion notes with 200-character snippets explaining *why* each entity was surfaced. Matched discussions are collected as full thread candidates.
3. **EXPAND** -- Breadth-first traversal over the `entity_references` graph discovers related entities via "closes", "related", and optionally "mentioned" references up to the configured depth.
3. **EXPAND** -- Breadth-first traversal over the `entity_references` graph discovers related entities via "closes", "related", and "mentioned" references up to the configured depth. Use `--no-mentions` to exclude "mentioned" edges and reduce fan-out.
4. **COLLECT** -- Events are gathered for all discovered entities. Event types include: creation, state changes, label adds/removes, milestone assignments, merge events, evidence notes, and full discussion threads. Events are sorted chronologically with stable tiebreaking.
5. **RENDER** -- Events are formatted as human-readable text or structured JSON (robot mode).

View File

@@ -166,7 +166,7 @@ const COMMAND_FLAGS: &[(&str, &[&str])] = &[
"--project",
"--since",
"--depth",
"--expand-mentions",
"--no-mentions",
"--limit",
"--fields",
"--max-seeds",

View File

@@ -1,4 +1,4 @@
use console::style;
use crate::cli::render::{self, Theme};
use rusqlite::Connection;
use serde::Serialize;
@@ -178,27 +178,6 @@ fn count_notes(conn: &Connection, type_filter: Option<&str>) -> Result<CountResu
})
}
fn format_number(n: i64) -> String {
let (prefix, abs) = if n < 0 {
("-", n.unsigned_abs())
} else {
("", n.unsigned_abs())
};
let s = abs.to_string();
let chars: Vec<char> = s.chars().collect();
let mut result = String::from(prefix);
for (i, c) in chars.iter().enumerate() {
if i > 0 && (chars.len() - i).is_multiple_of(3) {
result.push(',');
}
result.push(*c);
}
result
}
#[derive(Serialize)]
struct CountJsonOutput {
ok: bool,
@@ -284,10 +263,10 @@ pub fn print_event_count_json(counts: &EventCounts, elapsed_ms: u64) {
pub fn print_event_count(counts: &EventCounts) {
println!(
"{:<20} {:>8} {:>8} {:>8}",
style("Event Type").cyan().bold(),
style("Issues").bold(),
style("MRs").bold(),
style("Total").bold()
Theme::info().bold().render("Event Type"),
Theme::bold().render("Issues"),
Theme::bold().render("MRs"),
Theme::bold().render("Total")
);
let state_total = counts.state_issue + counts.state_mr;
@@ -297,33 +276,33 @@ pub fn print_event_count(counts: &EventCounts) {
println!(
"{:<20} {:>8} {:>8} {:>8}",
"State events",
format_number(counts.state_issue as i64),
format_number(counts.state_mr as i64),
format_number(state_total as i64)
render::format_number(counts.state_issue as i64),
render::format_number(counts.state_mr as i64),
render::format_number(state_total as i64)
);
println!(
"{:<20} {:>8} {:>8} {:>8}",
"Label events",
format_number(counts.label_issue as i64),
format_number(counts.label_mr as i64),
format_number(label_total as i64)
render::format_number(counts.label_issue as i64),
render::format_number(counts.label_mr as i64),
render::format_number(label_total as i64)
);
println!(
"{:<20} {:>8} {:>8} {:>8}",
"Milestone events",
format_number(counts.milestone_issue as i64),
format_number(counts.milestone_mr as i64),
format_number(milestone_total as i64)
render::format_number(counts.milestone_issue as i64),
render::format_number(counts.milestone_mr as i64),
render::format_number(milestone_total as i64)
);
let total_issues = counts.state_issue + counts.label_issue + counts.milestone_issue;
let total_mrs = counts.state_mr + counts.label_mr + counts.milestone_mr;
println!(
"{:<20} {:>8} {:>8} {:>8}",
style("Total").bold(),
format_number(total_issues as i64),
format_number(total_mrs as i64),
style(format_number(counts.total() as i64)).bold()
Theme::bold().render("Total"),
render::format_number(total_issues as i64),
render::format_number(total_mrs as i64),
Theme::bold().render(&render::format_number(counts.total() as i64))
);
}
@@ -350,57 +329,56 @@ pub fn print_count_json(result: &CountResult, elapsed_ms: u64) {
}
pub fn print_count(result: &CountResult) {
let count_str = format_number(result.count);
let count_str = render::format_number(result.count);
if let Some(system_count) = result.system_count {
println!(
"{}: {} {}",
style(&result.entity).cyan(),
style(&count_str).bold(),
style(format!(
Theme::info().render(&result.entity),
Theme::bold().render(&count_str),
Theme::dim().render(&format!(
"(excluding {} system)",
format_number(system_count)
render::format_number(system_count)
))
.dim()
);
} else {
println!(
"{}: {}",
style(&result.entity).cyan(),
style(&count_str).bold()
Theme::info().render(&result.entity),
Theme::bold().render(&count_str)
);
}
if let Some(breakdown) = &result.state_breakdown {
println!(" opened: {}", format_number(breakdown.opened));
println!(" opened: {}", render::format_number(breakdown.opened));
if let Some(merged) = breakdown.merged {
println!(" merged: {}", format_number(merged));
println!(" merged: {}", render::format_number(merged));
}
println!(" closed: {}", format_number(breakdown.closed));
println!(" closed: {}", render::format_number(breakdown.closed));
if let Some(locked) = breakdown.locked
&& locked > 0
{
println!(" locked: {}", format_number(locked));
println!(" locked: {}", render::format_number(locked));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::render;
#[test]
fn format_number_handles_small_numbers() {
assert_eq!(format_number(0), "0");
assert_eq!(format_number(1), "1");
assert_eq!(format_number(100), "100");
assert_eq!(format_number(999), "999");
assert_eq!(render::format_number(0), "0");
assert_eq!(render::format_number(1), "1");
assert_eq!(render::format_number(100), "100");
assert_eq!(render::format_number(999), "999");
}
#[test]
fn format_number_adds_thousands_separators() {
assert_eq!(format_number(1000), "1,000");
assert_eq!(format_number(12345), "12,345");
assert_eq!(format_number(1234567), "1,234,567");
assert_eq!(render::format_number(1000), "1,000");
assert_eq!(render::format_number(12345), "12,345");
assert_eq!(render::format_number(1234567), "1,234,567");
}
}

View File

@@ -1,4 +1,4 @@
use console::style;
use crate::cli::render::Theme;
use serde::Serialize;
use crate::core::config::Config;
@@ -544,32 +544,33 @@ pub fn print_doctor_results(result: &DoctorResult) {
if result.success {
let ollama_ok = result.checks.ollama.result.status == CheckStatus::Ok;
if ollama_ok {
println!("{}", style("Status: Ready").green());
println!("{}", Theme::success().render("Status: Ready"));
} else {
println!(
"{} {}",
style("Status: Ready").green(),
style("(lexical search available, semantic search requires Ollama)").yellow()
Theme::success().render("Status: Ready"),
Theme::warning()
.render("(lexical search available, semantic search requires Ollama)")
);
}
} else {
println!("{}", style("Status: Not ready").red());
println!("{}", Theme::error().render("Status: Not ready"));
}
println!();
}
fn print_check(name: &str, result: &CheckResult) {
let symbol = match result.status {
CheckStatus::Ok => style("").green(),
CheckStatus::Warning => style("").yellow(),
CheckStatus::Error => style("").red(),
CheckStatus::Ok => Theme::success().render("\u{2713}"),
CheckStatus::Warning => Theme::warning().render("\u{26a0}"),
CheckStatus::Error => Theme::error().render("\u{2717}"),
};
let message = result.message.as_deref().unwrap_or("");
let message_styled = match result.status {
CheckStatus::Ok => message.to_string(),
CheckStatus::Warning => style(message).yellow().to_string(),
CheckStatus::Error => style(message).red().to_string(),
CheckStatus::Warning => Theme::warning().render(message),
CheckStatus::Error => Theme::error().render(message),
};
println!(" {symbol} {:<10} {message_styled}", name);

View File

@@ -1,10 +1,10 @@
use std::collections::HashMap;
use std::sync::LazyLock;
use console::style;
use regex::Regex;
use serde::Serialize;
use crate::cli::render::Theme;
use crate::cli::robot::RobotMeta;
use crate::core::config::Config;
use crate::core::db::create_connection;
@@ -420,7 +420,7 @@ pub fn print_drift_human(response: &DriftResponse) {
"Drift Analysis: {} #{}",
response.entity.entity_type, response.entity.iid
);
println!("{}", style(&header).bold());
println!("{}", Theme::bold().render(&header));
println!("{}", "-".repeat(header.len().min(60)));
println!("Title: {}", response.entity.title);
println!("Threshold: {:.2}", response.threshold);
@@ -428,7 +428,7 @@ pub fn print_drift_human(response: &DriftResponse) {
println!();
if response.drift_detected {
println!("{}", style("DRIFT DETECTED").red().bold());
println!("{}", Theme::error().bold().render("DRIFT DETECTED"));
if let Some(dp) = &response.drift_point {
println!(
" At note #{} by @{} ({}) - similarity {:.2}",
@@ -439,7 +439,7 @@ pub fn print_drift_human(response: &DriftResponse) {
println!(" Topics: {}", response.drift_topics.join(", "));
}
} else {
println!("{}", style("No drift detected").green());
println!("{}", Theme::success().render("No drift detected"));
}
println!();
@@ -447,7 +447,7 @@ pub fn print_drift_human(response: &DriftResponse) {
if !response.similarity_curve.is_empty() {
println!();
println!("{}", style("Similarity Curve:").bold());
println!("{}", Theme::bold().render("Similarity Curve:"));
for pt in &response.similarity_curve {
let bar_len = ((pt.similarity.max(0.0)) * 30.0) as usize;
let bar: String = "#".repeat(bar_len);

View File

@@ -1,4 +1,4 @@
use console::style;
use crate::cli::render::Theme;
use serde::Serialize;
use crate::Config;
@@ -96,16 +96,31 @@ pub async fn run_embed(
}
pub fn print_embed(result: &EmbedCommandResult) {
println!("{} Embedding complete", style("done").green().bold(),);
if result.docs_embedded == 0 && result.failed == 0 && result.skipped == 0 {
println!(
"\n {} nothing to embed",
Theme::success().bold().render("Embedding")
);
return;
}
println!(
" Embedded: {} documents ({} chunks)",
result.docs_embedded, result.chunks_embedded
"\n {} {} documents ({} chunks)",
Theme::success().bold().render("Embedded"),
Theme::bold().render(&result.docs_embedded.to_string()),
result.chunks_embedded
);
if result.failed > 0 {
println!(" Failed: {}", style(result.failed).red());
println!(
" {}",
Theme::error().render(&format!("{} failed", result.failed))
);
}
if result.skipped > 0 {
println!(" Skipped: {}", result.skipped);
println!(
" {}",
Theme::dim().render(&format!("{} skipped", result.skipped))
);
}
}

View File

@@ -1,4 +1,4 @@
use console::style;
use crate::cli::render::Theme;
use rusqlite::Connection;
use serde::Serialize;
use tracing::info;
@@ -185,19 +185,40 @@ pub fn print_generate_docs(result: &GenerateDocsResult) {
} else {
"incremental"
};
if result.regenerated == 0 && result.errored == 0 {
println!(
"\n {} no documents to update ({})",
Theme::success().bold().render("Docs"),
mode
);
return;
}
// Headline
println!(
"{} Document generation complete ({})",
style("done").green().bold(),
"\n {} {} documents ({})",
Theme::success().bold().render("Generated"),
Theme::bold().render(&result.regenerated.to_string()),
mode
);
if result.full_mode {
println!(" Seeded: {}", result.seeded);
// Detail line: compact middle-dot format, zero-suppressed
let mut details: Vec<String> = Vec::new();
if result.full_mode && result.seeded > 0 {
details.push(format!("{} seeded", result.seeded));
}
if result.unchanged > 0 {
details.push(format!("{} unchanged", result.unchanged));
}
if !details.is_empty() {
println!(" {}", Theme::dim().render(&details.join(" \u{b7} ")));
}
println!(" Regenerated: {}", result.regenerated);
println!(" Unchanged: {}", result.unchanged);
if result.errored > 0 {
println!(" Errored: {}", style(result.errored).red());
println!(
" {}",
Theme::error().render(&format!("{} errored", result.errored))
);
}
}

View File

@@ -1,7 +1,7 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use console::style;
use crate::cli::render::Theme;
use indicatif::{ProgressBar, ProgressStyle};
use rusqlite::Connection;
use serde::Serialize;
@@ -293,7 +293,7 @@ async fn run_ingest_inner(
if display.show_text {
println!(
"{}",
style("Full sync: resetting cursors to fetch all data...").yellow()
Theme::warning().render("Full sync: resetting cursors to fetch all data...")
);
}
for (local_project_id, _, path) in &projects {
@@ -341,7 +341,10 @@ async fn run_ingest_inner(
"merge requests"
};
if display.show_text {
println!("{}", style(format!("Ingesting {type_label}...")).blue());
println!(
"{}",
Theme::info().render(&format!("Ingesting {type_label}..."))
);
println!();
}
@@ -746,7 +749,7 @@ fn print_issue_project_summary(path: &str, result: &IngestProjectResult) {
println!(
" {}: {} issues fetched{}",
style(path).cyan(),
Theme::info().render(path),
result.issues_upserted,
labels_str
);
@@ -761,7 +764,7 @@ fn print_issue_project_summary(path: &str, result: &IngestProjectResult) {
if result.issues_skipped_discussion_sync > 0 {
println!(
" {} unchanged issues (discussion sync skipped)",
style(result.issues_skipped_discussion_sync).dim()
Theme::dim().render(&result.issues_skipped_discussion_sync.to_string())
);
}
}
@@ -784,7 +787,7 @@ fn print_mr_project_summary(path: &str, result: &IngestMrProjectResult) {
println!(
" {}: {} MRs fetched{}{}",
style(path).cyan(),
Theme::info().render(path),
result.mrs_upserted,
labels_str,
assignees_str
@@ -808,7 +811,7 @@ fn print_mr_project_summary(path: &str, result: &IngestMrProjectResult) {
if result.mrs_skipped_discussion_sync > 0 {
println!(
" {} unchanged MRs (discussion sync skipped)",
style(result.mrs_skipped_discussion_sync).dim()
Theme::dim().render(&result.mrs_skipped_discussion_sync.to_string())
);
}
}
@@ -942,21 +945,19 @@ pub fn print_ingest_summary(result: &IngestResult) {
if result.resource_type == "issues" {
println!(
"{}",
style(format!(
Theme::success().render(&format!(
"Total: {} issues, {} discussions, {} notes",
result.issues_upserted, result.discussions_fetched, result.notes_upserted
))
.green()
);
if result.issues_skipped_discussion_sync > 0 {
println!(
"{}",
style(format!(
Theme::dim().render(&format!(
"Skipped discussion sync for {} unchanged issues.",
result.issues_skipped_discussion_sync
))
.dim()
);
}
} else {
@@ -968,24 +969,22 @@ pub fn print_ingest_summary(result: &IngestResult) {
println!(
"{}",
style(format!(
Theme::success().render(&format!(
"Total: {} MRs, {} discussions, {} notes{}",
result.mrs_upserted,
result.discussions_fetched,
result.notes_upserted,
diffnotes_str
))
.green()
);
if result.mrs_skipped_discussion_sync > 0 {
println!(
"{}",
style(format!(
Theme::dim().render(&format!(
"Skipped discussion sync for {} unchanged MRs.",
result.mrs_skipped_discussion_sync
))
.dim()
);
}
}
@@ -1006,8 +1005,8 @@ pub fn print_ingest_summary(result: &IngestResult) {
pub fn print_dry_run_preview(preview: &DryRunPreview) {
println!(
"{} {}",
style("Dry Run Preview").cyan().bold(),
style("(no changes will be made)").yellow()
Theme::info().bold().render("Dry Run Preview"),
Theme::warning().render("(no changes will be made)")
);
println!();
@@ -1017,27 +1016,31 @@ pub fn print_dry_run_preview(preview: &DryRunPreview) {
"merge requests"
};
println!(" Resource type: {}", style(type_label).white().bold());
println!(" Resource type: {}", Theme::bold().render(type_label));
println!(
" Sync mode: {}",
if preview.sync_mode == "full" {
style("full (all data will be re-fetched)").yellow()
Theme::warning().render("full (all data will be re-fetched)")
} else {
style("incremental (only changes since last sync)").green()
Theme::success().render("incremental (only changes since last sync)")
}
);
println!(" Projects: {}", preview.projects.len());
println!();
println!("{}", style("Projects to sync:").cyan().bold());
println!("{}", Theme::info().bold().render("Projects to sync:"));
for project in &preview.projects {
let sync_status = if !project.has_cursor {
style("initial sync").yellow()
Theme::warning().render("initial sync")
} else {
style("incremental").green()
Theme::success().render("incremental")
};
println!(" {} ({})", style(&project.path).white(), sync_status);
println!(
" {} ({})",
Theme::bold().render(&project.path),
sync_status
);
println!(" Existing {}: {}", type_label, project.existing_count);
if let Some(ref last_synced) = project.last_synced {

View File

@@ -1,4 +1,4 @@
use comfy_table::{Attribute, Cell, Color, ContentArrangement, Table};
use crate::cli::render::{self, Align, StyledCell, Table as LoreTable, Theme};
use rusqlite::Connection;
use serde::Serialize;
@@ -9,39 +9,7 @@ use crate::core::error::{LoreError, Result};
use crate::core::path_resolver::escape_like as note_escape_like;
use crate::core::paths::get_db_path;
use crate::core::project::resolve_project;
use crate::core::time::{ms_to_iso, now_ms, parse_since};
fn colored_cell(content: impl std::fmt::Display, color: Color) -> Cell {
let cell = Cell::new(content);
if console::colors_enabled() {
cell.fg(color)
} else {
cell
}
}
fn colored_cell_hex(content: &str, hex: Option<&str>) -> Cell {
if !console::colors_enabled() {
return Cell::new(content);
}
let Some(hex) = hex else {
return Cell::new(content);
};
let hex = hex.trim_start_matches('#');
if hex.len() != 6 {
return Cell::new(content);
}
let Ok(r) = u8::from_str_radix(&hex[0..2], 16) else {
return Cell::new(content);
};
let Ok(g) = u8::from_str_radix(&hex[2..4], 16) else {
return Cell::new(content);
};
let Ok(b) = u8::from_str_radix(&hex[4..6], 16) else {
return Cell::new(content);
};
Cell::new(content).fg(Color::Rgb { r, g, b })
}
use crate::core::time::{ms_to_iso, parse_since};
#[derive(Debug, Serialize)]
pub struct IssueListRow {
@@ -669,60 +637,6 @@ fn query_mrs(conn: &Connection, filters: &MrListFilters) -> Result<MrListResult>
Ok(MrListResult { mrs, total_count })
}
fn format_relative_time(ms_epoch: i64) -> String {
let now = now_ms();
let diff = now - ms_epoch;
if diff < 0 {
return "in the future".to_string();
}
match diff {
d if d < 60_000 => "just now".to_string(),
d if d < 3_600_000 => format!("{} min ago", d / 60_000),
d if d < 86_400_000 => {
let n = d / 3_600_000;
format!("{n} {} ago", if n == 1 { "hour" } else { "hours" })
}
d if d < 604_800_000 => {
let n = d / 86_400_000;
format!("{n} {} ago", if n == 1 { "day" } else { "days" })
}
d if d < 2_592_000_000 => {
let n = d / 604_800_000;
format!("{n} {} ago", if n == 1 { "week" } else { "weeks" })
}
_ => {
let n = diff / 2_592_000_000;
format!("{n} {} ago", if n == 1 { "month" } else { "months" })
}
}
}
fn truncate_with_ellipsis(s: &str, max_width: usize) -> String {
if s.chars().count() <= max_width {
s.to_string()
} else {
let truncated: String = s.chars().take(max_width.saturating_sub(3)).collect();
format!("{truncated}...")
}
}
fn format_labels(labels: &[String], max_shown: usize) -> String {
if labels.is_empty() {
return String::new();
}
let shown: Vec<&str> = labels.iter().take(max_shown).map(|s| s.as_str()).collect();
let overflow = labels.len().saturating_sub(max_shown);
if overflow > 0 {
format!("[{} +{}]", shown.join(", "), overflow)
} else {
format!("[{}]", shown.join(", "))
}
}
fn format_assignees(assignees: &[String]) -> String {
if assignees.is_empty() {
return "-".to_string();
@@ -732,7 +646,7 @@ fn format_assignees(assignees: &[String]) -> String {
let shown: Vec<String> = assignees
.iter()
.take(max_shown)
.map(|s| format!("@{}", truncate_with_ellipsis(s, 10)))
.map(|s| format!("@{}", render::truncate(s, 10)))
.collect();
let overflow = assignees.len().saturating_sub(max_shown);
@@ -757,7 +671,7 @@ fn format_discussions(total: i64, unresolved: i64) -> String {
fn format_branches(target: &str, source: &str, max_width: usize) -> String {
let full = format!("{} <- {}", target, source);
truncate_with_ellipsis(&full, max_width)
render::truncate(&full, max_width)
}
pub fn print_list_issues(result: &ListResult) {
@@ -774,64 +688,55 @@ pub fn print_list_issues(result: &ListResult) {
let has_any_status = result.issues.iter().any(|i| i.status_name.is_some());
let mut header = vec![
Cell::new("IID").add_attribute(Attribute::Bold),
Cell::new("Title").add_attribute(Attribute::Bold),
Cell::new("State").add_attribute(Attribute::Bold),
];
let mut headers = vec!["IID", "Title", "State"];
if has_any_status {
header.push(Cell::new("Status").add_attribute(Attribute::Bold));
headers.push("Status");
}
header.extend([
Cell::new("Assignee").add_attribute(Attribute::Bold),
Cell::new("Labels").add_attribute(Attribute::Bold),
Cell::new("Disc").add_attribute(Attribute::Bold),
Cell::new("Updated").add_attribute(Attribute::Bold),
]);
headers.extend(["Assignee", "Labels", "Disc", "Updated"]);
let mut table = Table::new();
table
.set_content_arrangement(ContentArrangement::Dynamic)
.set_header(header);
let mut table = LoreTable::new().headers(&headers).align(0, Align::Right);
for issue in &result.issues {
let title = truncate_with_ellipsis(&issue.title, 45);
let relative_time = format_relative_time(issue.updated_at);
let labels = format_labels(&issue.labels, 2);
let title = render::truncate(&issue.title, 45);
let relative_time = render::format_relative_time(issue.updated_at);
let labels = render::format_labels(&issue.labels, 2);
let assignee = format_assignees(&issue.assignees);
let discussions = format_discussions(issue.discussion_count, issue.unresolved_count);
let state_cell = if issue.state == "opened" {
colored_cell(&issue.state, Color::Green)
StyledCell::styled(&issue.state, Theme::success())
} else {
colored_cell(&issue.state, Color::DarkGrey)
StyledCell::styled(&issue.state, Theme::dim())
};
let mut row = vec![
colored_cell(format!("#{}", issue.iid), Color::Cyan),
Cell::new(title),
StyledCell::styled(format!("#{}", issue.iid), Theme::info()),
StyledCell::plain(title),
state_cell,
];
if has_any_status {
match &issue.status_name {
Some(status) => {
row.push(colored_cell_hex(status, issue.status_color.as_deref()));
row.push(StyledCell::plain(render::style_with_hex(
status,
issue.status_color.as_deref(),
)));
}
None => {
row.push(Cell::new(""));
row.push(StyledCell::plain(""));
}
}
}
row.extend([
colored_cell(assignee, Color::Magenta),
colored_cell(labels, Color::Yellow),
Cell::new(discussions),
colored_cell(relative_time, Color::DarkGrey),
StyledCell::styled(assignee, Theme::accent()),
StyledCell::styled(labels, Theme::warning()),
StyledCell::plain(discussions),
StyledCell::styled(relative_time, Theme::dim()),
]);
table.add_row(row);
}
println!("{table}");
println!("{}", table.render());
}
pub fn print_list_issues_json(result: &ListResult, elapsed_ms: u64, fields: Option<&[String]>) {
@@ -883,53 +788,46 @@ pub fn print_list_mrs(result: &MrListResult) {
result.total_count
);
let mut table = Table::new();
table
.set_content_arrangement(ContentArrangement::Dynamic)
.set_header(vec![
Cell::new("IID").add_attribute(Attribute::Bold),
Cell::new("Title").add_attribute(Attribute::Bold),
Cell::new("State").add_attribute(Attribute::Bold),
Cell::new("Author").add_attribute(Attribute::Bold),
Cell::new("Branches").add_attribute(Attribute::Bold),
Cell::new("Disc").add_attribute(Attribute::Bold),
Cell::new("Updated").add_attribute(Attribute::Bold),
]);
let mut table = LoreTable::new()
.headers(&[
"IID", "Title", "State", "Author", "Branches", "Disc", "Updated",
])
.align(0, Align::Right);
for mr in &result.mrs {
let title = if mr.draft {
format!("[DRAFT] {}", truncate_with_ellipsis(&mr.title, 38))
format!("[DRAFT] {}", render::truncate(&mr.title, 38))
} else {
truncate_with_ellipsis(&mr.title, 45)
render::truncate(&mr.title, 45)
};
let relative_time = format_relative_time(mr.updated_at);
let relative_time = render::format_relative_time(mr.updated_at);
let branches = format_branches(&mr.target_branch, &mr.source_branch, 25);
let discussions = format_discussions(mr.discussion_count, mr.unresolved_count);
let state_cell = match mr.state.as_str() {
"opened" => colored_cell(&mr.state, Color::Green),
"merged" => colored_cell(&mr.state, Color::Magenta),
"closed" => colored_cell(&mr.state, Color::Red),
"locked" => colored_cell(&mr.state, Color::Yellow),
_ => colored_cell(&mr.state, Color::DarkGrey),
"opened" => StyledCell::styled(&mr.state, Theme::success()),
"merged" => StyledCell::styled(&mr.state, Theme::accent()),
"closed" => StyledCell::styled(&mr.state, Theme::error()),
"locked" => StyledCell::styled(&mr.state, Theme::warning()),
_ => StyledCell::styled(&mr.state, Theme::dim()),
};
table.add_row(vec![
colored_cell(format!("!{}", mr.iid), Color::Cyan),
Cell::new(title),
StyledCell::styled(format!("!{}", mr.iid), Theme::info()),
StyledCell::plain(title),
state_cell,
colored_cell(
format!("@{}", truncate_with_ellipsis(&mr.author_username, 12)),
Color::Magenta,
StyledCell::styled(
format!("@{}", render::truncate(&mr.author_username, 12)),
Theme::accent(),
),
colored_cell(branches, Color::Blue),
Cell::new(discussions),
colored_cell(relative_time, Color::DarkGrey),
StyledCell::styled(branches, Theme::info()),
StyledCell::plain(discussions),
StyledCell::styled(relative_time, Theme::dim()),
]);
}
println!("{table}");
println!("{}", table.render());
}
pub fn print_list_mrs_json(result: &MrListResult, elapsed_ms: u64, fields: Option<&[String]>) {
@@ -1016,18 +914,17 @@ pub fn print_list_notes(result: &NoteListResult) {
result.total_count
);
let mut table = Table::new();
table
.set_content_arrangement(ContentArrangement::Dynamic)
.set_header(vec![
Cell::new("ID").add_attribute(Attribute::Bold),
Cell::new("Author").add_attribute(Attribute::Bold),
Cell::new("Type").add_attribute(Attribute::Bold),
Cell::new("Body").add_attribute(Attribute::Bold),
Cell::new("Path:Line").add_attribute(Attribute::Bold),
Cell::new("Parent").add_attribute(Attribute::Bold),
Cell::new("Created").add_attribute(Attribute::Bold),
]);
let mut table = LoreTable::new()
.headers(&[
"ID",
"Author",
"Type",
"Body",
"Path:Line",
"Parent",
"Created",
])
.align(0, Align::Right);
for note in &result.notes {
let body = note
@@ -1037,24 +934,24 @@ pub fn print_list_notes(result: &NoteListResult) {
.unwrap_or_default();
let path = format_note_path(note.position_new_path.as_deref(), note.position_new_line);
let parent = format_note_parent(note.noteable_type.as_deref(), note.parent_iid);
let relative_time = format_relative_time(note.created_at);
let relative_time = render::format_relative_time(note.created_at);
let note_type = format_note_type(note.note_type.as_deref());
table.add_row(vec![
colored_cell(note.gitlab_id, Color::Cyan),
colored_cell(
format!("@{}", truncate_with_ellipsis(&note.author_username, 12)),
Color::Magenta,
StyledCell::styled(note.gitlab_id.to_string(), Theme::info()),
StyledCell::styled(
format!("@{}", render::truncate(&note.author_username, 12)),
Theme::accent(),
),
Cell::new(note_type),
Cell::new(body),
Cell::new(path),
Cell::new(parent),
colored_cell(relative_time, Color::DarkGrey),
StyledCell::plain(note_type),
StyledCell::plain(body),
StyledCell::plain(path),
StyledCell::plain(parent),
StyledCell::styled(relative_time, Theme::dim()),
]);
}
println!("{table}");
println!("{}", table.render());
}
pub fn print_list_notes_json(result: &NoteListResult, elapsed_ms: u64, fields: Option<&[String]>) {

View File

@@ -1,47 +1,52 @@
use super::*;
use crate::cli::render;
use crate::core::time::now_ms;
#[test]
fn truncate_leaves_short_strings_alone() {
assert_eq!(truncate_with_ellipsis("short", 10), "short");
assert_eq!(render::truncate("short", 10), "short");
}
#[test]
fn truncate_adds_ellipsis_to_long_strings() {
assert_eq!(
truncate_with_ellipsis("this is a very long title", 15),
render::truncate("this is a very long title", 15),
"this is a ve..."
);
}
#[test]
fn truncate_handles_exact_length() {
assert_eq!(truncate_with_ellipsis("exactly10!", 10), "exactly10!");
assert_eq!(render::truncate("exactly10!", 10), "exactly10!");
}
#[test]
fn relative_time_formats_correctly() {
let now = now_ms();
assert_eq!(format_relative_time(now - 30_000), "just now");
assert_eq!(format_relative_time(now - 120_000), "2 min ago");
assert_eq!(format_relative_time(now - 7_200_000), "2 hours ago");
assert_eq!(format_relative_time(now - 172_800_000), "2 days ago");
assert_eq!(render::format_relative_time(now - 30_000), "just now");
assert_eq!(render::format_relative_time(now - 120_000), "2 min ago");
assert_eq!(render::format_relative_time(now - 7_200_000), "2 hours ago");
assert_eq!(
render::format_relative_time(now - 172_800_000),
"2 days ago"
);
}
#[test]
fn format_labels_empty() {
assert_eq!(format_labels(&[], 2), "");
assert_eq!(render::format_labels(&[], 2), "");
}
#[test]
fn format_labels_single() {
assert_eq!(format_labels(&["bug".to_string()], 2), "[bug]");
assert_eq!(render::format_labels(&["bug".to_string()], 2), "[bug]");
}
#[test]
fn format_labels_multiple() {
let labels = vec!["bug".to_string(), "urgent".to_string()];
assert_eq!(format_labels(&labels, 2), "[bug, urgent]");
assert_eq!(render::format_labels(&labels, 2), "[bug, urgent]");
}
#[test]
@@ -52,7 +57,7 @@ fn format_labels_overflow() {
"wip".to_string(),
"blocked".to_string(),
];
assert_eq!(format_labels(&labels, 2), "[bug, urgent +2]");
assert_eq!(render::format_labels(&labels, 2), "[bug, urgent +2]");
}
#[test]

View File

@@ -1,6 +1,6 @@
use std::collections::HashMap;
use console::style;
use crate::cli::render::Theme;
use serde::Serialize;
use crate::Config;
@@ -309,68 +309,94 @@ fn parse_json_array(json: &str) -> Vec<String> {
.collect()
}
/// Render FTS snippet with `<mark>` tags as terminal bold+underline.
fn render_snippet(snippet: &str) -> String {
let mut result = String::new();
let mut remaining = snippet;
while let Some(start) = remaining.find("<mark>") {
result.push_str(&remaining[..start]);
remaining = &remaining[start + 6..];
if let Some(end) = remaining.find("</mark>") {
let highlighted = &remaining[..end];
result.push_str(&Theme::bold().underline().render(highlighted));
remaining = &remaining[end + 7..];
}
}
result.push_str(remaining);
result
}
pub fn print_search_results(response: &SearchResponse) {
if !response.warnings.is_empty() {
for w in &response.warnings {
eprintln!("{} {}", style("Warning:").yellow(), w);
eprintln!("{} {}", Theme::warning().render("Warning:"), w);
}
}
if response.results.is_empty() {
println!("No results found for '{}'", style(&response.query).bold());
println!(
"No results found for '{}'",
Theme::bold().render(&response.query)
);
return;
}
println!(
"{} results for '{}' ({})",
response.total_results,
style(&response.query).bold(),
response.mode
"\n {} results for '{}' {}",
Theme::bold().render(&response.total_results.to_string()),
Theme::bold().render(&response.query),
Theme::dim().render(&format!("({})", response.mode))
);
println!();
for (i, result) in response.results.iter().enumerate() {
let type_prefix = match result.source_type.as_str() {
"issue" => "Issue",
"merge_request" => "MR",
"discussion" => "Discussion",
"note" => "Note",
_ => &result.source_type,
let type_badge = match result.source_type.as_str() {
"issue" => Theme::info().render("issue"),
"merge_request" => Theme::accent().render("mr"),
"discussion" => Theme::info().render("disc"),
"note" => Theme::info().render("note"),
_ => Theme::dim().render(&result.source_type),
};
// Title line: rank, type badge, title
println!(
"[{}] {} - {} (score: {:.2})",
i + 1,
style(type_prefix).cyan(),
result.title,
result.score
" {} {} {}",
Theme::dim().render(&format!("{:>2}.", i + 1)),
type_badge,
Theme::bold().render(&result.title)
);
if let Some(ref url) = result.url {
println!(" {}", style(url).dim());
// Metadata: project, author, labels — compact middle-dot line
let mut meta_parts: Vec<String> = Vec::new();
meta_parts.push(result.project_path.clone());
if let Some(ref author) = result.author {
meta_parts.push(format!("@{author}"));
}
println!(
" {} | {}",
style(&result.project_path).dim(),
result
.author
.as_deref()
.map(|a| format!("@{}", a))
.unwrap_or_default()
);
if !result.labels.is_empty() {
println!(" Labels: {}", result.labels.join(", "));
let label_str = if result.labels.len() <= 3 {
result.labels.join(", ")
} else {
format!(
"{} +{}",
result.labels[..2].join(", "),
result.labels.len() - 2
)
};
meta_parts.push(label_str);
}
println!(
" {}",
Theme::dim().render(&meta_parts.join(" \u{b7} "))
);
let clean_snippet = result.snippet.replace("<mark>", "").replace("</mark>", "");
println!(" {}", style(clean_snippet).dim());
// Snippet with proper highlighting
let rendered = render_snippet(&result.snippet);
println!(" {}", Theme::dim().render(&rendered));
if let Some(ref explain) = result.explain {
println!(
" {} vector_rank={} fts_rank={} rrf_score={:.6}",
style("[explain]").magenta(),
" {} vec={} fts={} rrf={:.4}",
Theme::accent().render("explain"),
explain
.vector_rank
.map(|r| r.to_string())

View File

@@ -1,4 +1,4 @@
use console::style;
use crate::cli::render::{self, Theme};
use rusqlite::Connection;
use serde::Serialize;
@@ -606,65 +606,37 @@ fn get_mr_discussions(conn: &Connection, mr_id: i64) -> Result<Vec<MrDiscussionD
}
fn format_date(ms: i64) -> String {
let iso = ms_to_iso(ms);
iso.split('T').next().unwrap_or(&iso).to_string()
render::format_date(ms)
}
fn wrap_text(text: &str, width: usize, indent: &str) -> String {
let mut result = String::new();
let mut current_line = String::new();
for word in text.split_whitespace() {
if current_line.is_empty() {
current_line = word.to_string();
} else if current_line.len() + 1 + word.len() <= width {
current_line.push(' ');
current_line.push_str(word);
} else {
if !result.is_empty() {
result.push('\n');
result.push_str(indent);
}
result.push_str(&current_line);
current_line = word.to_string();
}
}
if !current_line.is_empty() {
if !result.is_empty() {
result.push('\n');
result.push_str(indent);
}
result.push_str(&current_line);
}
result
render::wrap_indent(text, width, indent)
}
pub fn print_show_issue(issue: &IssueDetail) {
let header = format!("Issue #{}: {}", issue.iid, issue.title);
println!("{}", style(&header).bold());
println!("{}", "".repeat(header.len().min(80)));
println!("{}", Theme::bold().render(&header));
println!("{}", "\u{2501}".repeat(header.len().min(80)));
println!();
println!("Ref: {}", style(&issue.references_full).dim());
println!("Project: {}", style(&issue.project_path).cyan());
println!("Ref: {}", Theme::dim().render(&issue.references_full));
println!("Project: {}", Theme::info().render(&issue.project_path));
let state_styled = if issue.state == "opened" {
style(&issue.state).green()
Theme::success().render(&issue.state)
} else {
style(&issue.state).dim()
Theme::dim().render(&issue.state)
};
println!("State: {}", state_styled);
if issue.confidential {
println!(" {}", style("CONFIDENTIAL").red().bold());
println!(" {}", Theme::error().bold().render("CONFIDENTIAL"));
}
if let Some(status) = &issue.status_name {
println!(
"Status: {}",
style_with_hex(status, issue.status_color.as_deref())
render::style_with_hex(status, issue.status_color.as_deref())
);
}
@@ -705,37 +677,37 @@ pub fn print_show_issue(issue: &IssueDetail) {
}
if issue.labels.is_empty() {
println!("Labels: {}", style("(none)").dim());
println!("Labels: {}", Theme::dim().render("(none)"));
} else {
println!("Labels: {}", issue.labels.join(", "));
}
if !issue.closing_merge_requests.is_empty() {
println!();
println!("{}", style("Development:").bold());
println!("{}", Theme::bold().render("Development:"));
for mr in &issue.closing_merge_requests {
let state_indicator = match mr.state.as_str() {
"merged" => style(&mr.state).green(),
"opened" => style(&mr.state).cyan(),
"closed" => style(&mr.state).red(),
_ => style(&mr.state).dim(),
"merged" => Theme::success().render(&mr.state),
"opened" => Theme::info().render(&mr.state),
"closed" => Theme::error().render(&mr.state),
_ => Theme::dim().render(&mr.state),
};
println!(" !{} {} ({})", mr.iid, mr.title, state_indicator);
}
}
if let Some(url) = &issue.web_url {
println!("URL: {}", style(url).dim());
println!("URL: {}", Theme::dim().render(url));
}
println!();
println!("{}", style("Description:").bold());
println!("{}", Theme::bold().render("Description:"));
if let Some(desc) = &issue.description {
let wrapped = wrap_text(desc, 76, " ");
println!(" {}", wrapped);
} else {
println!(" {}", style("(no description)").dim());
println!(" {}", Theme::dim().render("(no description)"));
}
println!();
@@ -747,11 +719,11 @@ pub fn print_show_issue(issue: &IssueDetail) {
.collect();
if user_discussions.is_empty() {
println!("{}", style("Discussions: (none)").dim());
println!("{}", Theme::dim().render("Discussions: (none)"));
} else {
println!(
"{}",
style(format!("Discussions ({}):", user_discussions.len())).bold()
Theme::bold().render(&format!("Discussions ({}):", user_discussions.len()))
);
println!();
@@ -762,7 +734,7 @@ pub fn print_show_issue(issue: &IssueDetail) {
if let Some(first_note) = user_notes.first() {
println!(
" {} ({}):",
style(format!("@{}", first_note.author_username)).cyan(),
Theme::info().render(&format!("@{}", first_note.author_username)),
format_date(first_note.created_at)
);
let wrapped = wrap_text(&first_note.body, 72, " ");
@@ -772,7 +744,7 @@ pub fn print_show_issue(issue: &IssueDetail) {
for reply in user_notes.iter().skip(1) {
println!(
" {} ({}):",
style(format!("@{}", reply.author_username)).cyan(),
Theme::info().render(&format!("@{}", reply.author_username)),
format_date(reply.created_at)
);
let wrapped = wrap_text(&reply.body, 68, " ");
@@ -787,24 +759,24 @@ pub fn print_show_issue(issue: &IssueDetail) {
pub fn print_show_mr(mr: &MrDetail) {
let draft_prefix = if mr.draft { "[Draft] " } else { "" };
let header = format!("MR !{}: {}{}", mr.iid, draft_prefix, mr.title);
println!("{}", style(&header).bold());
println!("{}", "".repeat(header.len().min(80)));
println!("{}", Theme::bold().render(&header));
println!("{}", "\u{2501}".repeat(header.len().min(80)));
println!();
println!("Project: {}", style(&mr.project_path).cyan());
println!("Project: {}", Theme::info().render(&mr.project_path));
let state_styled = match mr.state.as_str() {
"opened" => style(&mr.state).green(),
"merged" => style(&mr.state).magenta(),
"closed" => style(&mr.state).red(),
_ => style(&mr.state).dim(),
"opened" => Theme::success().render(&mr.state),
"merged" => Theme::accent().render(&mr.state),
"closed" => Theme::error().render(&mr.state),
_ => Theme::dim().render(&mr.state),
};
println!("State: {}", state_styled);
println!(
"Branches: {} -> {}",
style(&mr.source_branch).cyan(),
style(&mr.target_branch).yellow()
Theme::info().render(&mr.source_branch),
Theme::warning().render(&mr.target_branch)
);
println!("Author: @{}", mr.author_username);
@@ -843,23 +815,23 @@ pub fn print_show_mr(mr: &MrDetail) {
}
if mr.labels.is_empty() {
println!("Labels: {}", style("(none)").dim());
println!("Labels: {}", Theme::dim().render("(none)"));
} else {
println!("Labels: {}", mr.labels.join(", "));
}
if let Some(url) = &mr.web_url {
println!("URL: {}", style(url).dim());
println!("URL: {}", Theme::dim().render(url));
}
println!();
println!("{}", style("Description:").bold());
println!("{}", Theme::bold().render("Description:"));
if let Some(desc) = &mr.description {
let wrapped = wrap_text(desc, 76, " ");
println!(" {}", wrapped);
} else {
println!(" {}", style("(no description)").dim());
println!(" {}", Theme::dim().render("(no description)"));
}
println!();
@@ -871,11 +843,11 @@ pub fn print_show_mr(mr: &MrDetail) {
.collect();
if user_discussions.is_empty() {
println!("{}", style("Discussions: (none)").dim());
println!("{}", Theme::dim().render("Discussions: (none)"));
} else {
println!(
"{}",
style(format!("Discussions ({}):", user_discussions.len())).bold()
Theme::bold().render(&format!("Discussions ({}):", user_discussions.len()))
);
println!();
@@ -890,7 +862,7 @@ pub fn print_show_mr(mr: &MrDetail) {
println!(
" {} ({}):",
style(format!("@{}", first_note.author_username)).cyan(),
Theme::info().render(&format!("@{}", first_note.author_username)),
format_date(first_note.created_at)
);
let wrapped = wrap_text(&first_note.body, 72, " ");
@@ -900,7 +872,7 @@ pub fn print_show_mr(mr: &MrDetail) {
for reply in user_notes.iter().skip(1) {
println!(
" {} ({}):",
style(format!("@{}", reply.author_username)).cyan(),
Theme::info().render(&format!("@{}", reply.author_username)),
format_date(reply.created_at)
);
let wrapped = wrap_text(&reply.body, 68, " ");
@@ -926,39 +898,13 @@ fn print_diff_position(pos: &DiffNotePosition) {
println!(
" {} {}{}",
style("📍").dim(),
style(file_path).yellow(),
style(line_str).dim()
Theme::dim().render("\u{1f4cd}"),
Theme::warning().render(file_path),
Theme::dim().render(&line_str)
);
}
}
fn style_with_hex<'a>(text: &'a str, hex: Option<&str>) -> console::StyledObject<&'a str> {
let styled = console::style(text);
let Some(hex) = hex else { return styled };
let hex = hex.trim_start_matches('#');
if hex.len() != 6 {
return styled;
}
let Ok(r) = u8::from_str_radix(&hex[0..2], 16) else {
return styled;
};
let Ok(g) = u8::from_str_radix(&hex[2..4], 16) else {
return styled;
};
let Ok(b) = u8::from_str_radix(&hex[4..6], 16) else {
return styled;
};
styled.color256(ansi256_from_rgb(r, g, b))
}
fn ansi256_from_rgb(r: u8, g: u8, b: u8) -> u8 {
let ri = (u16::from(r) * 5 + 127) / 255;
let gi = (u16::from(g) * 5 + 127) / 255;
let bi = (u16::from(b) * 5 + 127) / 255;
(16 + 36 * ri + 6 * gi + bi) as u8
}
#[derive(Serialize)]
pub struct IssueDetailJson {
pub id: i64,
@@ -1387,8 +1333,9 @@ mod tests {
#[test]
fn test_ansi256_from_rgb() {
assert_eq!(ansi256_from_rgb(0, 0, 0), 16);
assert_eq!(ansi256_from_rgb(255, 255, 255), 231);
// Moved to render.rs — keeping basic hex sanity check
let result = render::style_with_hex("test", Some("#ff0000"));
assert!(!result.is_empty());
}
#[test]

View File

@@ -1,4 +1,4 @@
use console::style;
use crate::cli::render::{self, Theme};
use rusqlite::Connection;
use serde::Serialize;
@@ -322,124 +322,183 @@ fn table_exists(conn: &Connection, table: &str) -> bool {
> 0
}
fn section(title: &str) {
println!("{}", render::section_divider(title));
}
pub fn print_stats(result: &StatsResult) {
println!("{}", style("Documents").cyan().bold());
println!(" Total: {}", result.documents.total);
println!(" Issues: {}", result.documents.issues);
println!(" Merge Requests: {}", result.documents.merge_requests);
println!(" Discussions: {}", result.documents.discussions);
section("Documents");
let mut parts = vec![format!("{} total", result.documents.total)];
if result.documents.issues > 0 {
parts.push(format!("{} issues", result.documents.issues));
}
if result.documents.merge_requests > 0 {
parts.push(format!("{} MRs", result.documents.merge_requests));
}
if result.documents.discussions > 0 {
parts.push(format!("{} discussions", result.documents.discussions));
}
println!(" {}", parts.join(" \u{b7} "));
if result.documents.truncated > 0 {
println!(
" Truncated: {}",
style(result.documents.truncated).yellow()
" {}",
Theme::warning().render(&format!("{} truncated", result.documents.truncated))
);
}
println!();
println!("{}", style("Search Index").cyan().bold());
println!(" FTS indexed: {}", result.fts.indexed);
section("Search Index");
println!(" {} FTS indexed", result.fts.indexed);
let coverage_color = if result.embeddings.coverage_pct >= 95.0 {
Theme::success().render(&format!("{:.0}%", result.embeddings.coverage_pct))
} else if result.embeddings.coverage_pct >= 50.0 {
Theme::warning().render(&format!("{:.0}%", result.embeddings.coverage_pct))
} else {
Theme::error().render(&format!("{:.0}%", result.embeddings.coverage_pct))
};
println!(
" Embedding coverage: {:.1}% ({}/{})",
result.embeddings.coverage_pct,
result.embeddings.embedded_documents,
result.documents.total
" {} embedding coverage ({}/{})",
coverage_color, result.embeddings.embedded_documents, result.documents.total,
);
if result.embeddings.total_chunks > 0 {
println!(" Total chunks: {}", result.embeddings.total_chunks);
}
println!();
println!("{}", style("Queues").cyan().bold());
println!(
" Dirty sources: {} pending, {} failed",
result.queues.dirty_sources, result.queues.dirty_sources_failed
);
println!(
" Discussion fetch: {} pending, {} failed",
result.queues.pending_discussion_fetches, result.queues.pending_discussion_fetches_failed
);
if result.queues.pending_dependent_fetches > 0
|| result.queues.pending_dependent_fetches_failed > 0
|| result.queues.pending_dependent_fetches_stuck > 0
{
println!(
" Dependent fetch: {} pending, {} failed, {} stuck",
result.queues.pending_dependent_fetches,
result.queues.pending_dependent_fetches_failed,
result.queues.pending_dependent_fetches_stuck
" {}",
Theme::dim().render(&format!("{} chunks", result.embeddings.total_chunks))
);
}
// Queues: only show if there's anything to report
let has_queue_activity = result.queues.dirty_sources > 0
|| result.queues.dirty_sources_failed > 0
|| result.queues.pending_discussion_fetches > 0
|| result.queues.pending_discussion_fetches_failed > 0
|| result.queues.pending_dependent_fetches > 0
|| result.queues.pending_dependent_fetches_failed > 0;
if has_queue_activity {
section("Queues");
if result.queues.dirty_sources > 0 || result.queues.dirty_sources_failed > 0 {
let mut q = Vec::new();
if result.queues.dirty_sources > 0 {
q.push(format!("{} pending", result.queues.dirty_sources));
}
if result.queues.dirty_sources_failed > 0 {
q.push(
Theme::error()
.render(&format!("{} failed", result.queues.dirty_sources_failed)),
);
}
println!(" dirty sources: {}", q.join(", "));
}
if result.queues.pending_discussion_fetches > 0
|| result.queues.pending_discussion_fetches_failed > 0
{
let mut q = Vec::new();
if result.queues.pending_discussion_fetches > 0 {
q.push(format!(
"{} pending",
result.queues.pending_discussion_fetches
));
}
if result.queues.pending_discussion_fetches_failed > 0 {
q.push(Theme::error().render(&format!(
"{} failed",
result.queues.pending_discussion_fetches_failed
)));
}
println!(" discussion fetch: {}", q.join(", "));
}
if result.queues.pending_dependent_fetches > 0
|| result.queues.pending_dependent_fetches_failed > 0
{
let mut q = Vec::new();
if result.queues.pending_dependent_fetches > 0 {
q.push(format!(
"{} pending",
result.queues.pending_dependent_fetches
));
}
if result.queues.pending_dependent_fetches_failed > 0 {
q.push(Theme::error().render(&format!(
"{} failed",
result.queues.pending_dependent_fetches_failed
)));
}
if result.queues.pending_dependent_fetches_stuck > 0 {
q.push(Theme::warning().render(&format!(
"{} stuck",
result.queues.pending_dependent_fetches_stuck
)));
}
println!(" dependent fetch: {}", q.join(", "));
}
} else {
section("Queues");
println!(" {}", Theme::success().render("all clear"));
}
if let Some(ref integrity) = result.integrity {
println!();
let status = if integrity.ok {
style("OK").green().bold()
section("Integrity");
if integrity.ok {
println!(
" {} all checks passed",
Theme::success().render("\u{2713}")
);
} else {
style("ISSUES FOUND").red().bold()
};
println!("{} Integrity: {}", style("Check").cyan().bold(), status);
if integrity.fts_doc_mismatch {
println!(" {} FTS/document count mismatch", style("!").red());
}
if integrity.orphan_embeddings > 0 {
println!(
" {} {} orphan embeddings",
style("!").red(),
integrity.orphan_embeddings
);
}
if integrity.stale_metadata > 0 {
println!(
" {} {} stale embedding metadata",
style("!").red(),
integrity.stale_metadata
);
}
let orphan_events = integrity.orphan_state_events
+ integrity.orphan_label_events
+ integrity.orphan_milestone_events;
if orphan_events > 0 {
println!(
" {} {} orphan resource events (state: {}, label: {}, milestone: {})",
style("!").red(),
orphan_events,
integrity.orphan_state_events,
integrity.orphan_label_events,
integrity.orphan_milestone_events
);
}
if integrity.queue_stuck_locks > 0 {
println!(
" {} {} stuck queue locks",
style("!").yellow(),
integrity.queue_stuck_locks
);
}
if integrity.queue_max_attempts > 3 {
println!(
" {} max queue retry attempts: {}",
style("!").yellow(),
integrity.queue_max_attempts
);
if integrity.fts_doc_mismatch {
println!(
" {} FTS/document count mismatch",
Theme::error().render("\u{2717}")
);
}
if integrity.orphan_embeddings > 0 {
println!(
" {} {} orphan embeddings",
Theme::error().render("\u{2717}"),
integrity.orphan_embeddings
);
}
if integrity.stale_metadata > 0 {
println!(
" {} {} stale embedding metadata",
Theme::error().render("\u{2717}"),
integrity.stale_metadata
);
}
let orphan_events = integrity.orphan_state_events
+ integrity.orphan_label_events
+ integrity.orphan_milestone_events;
if orphan_events > 0 {
println!(
" {} {} orphan resource events",
Theme::error().render("\u{2717}"),
orphan_events
);
}
if integrity.queue_stuck_locks > 0 {
println!(
" {} {} stuck queue locks",
Theme::warning().render("!"),
integrity.queue_stuck_locks
);
}
}
if let Some(ref repair) = integrity.repair {
println!();
if repair.dry_run {
println!(
"{} {}",
style("Repair").cyan().bold(),
style("(dry run - no changes made)").yellow()
" {} {}",
Theme::bold().render("Repair"),
Theme::warning().render("(dry run)")
);
} else {
println!("{}", style("Repair").cyan().bold());
println!(" {}", Theme::bold().render("Repair"));
}
let action = if repair.dry_run {
style("would fix").yellow()
Theme::warning().render("would fix")
} else {
style("fixed").green()
Theme::success().render("fixed")
};
if repair.fts_rebuilt {
@@ -453,15 +512,17 @@ pub fn print_stats(result: &StatsResult) {
}
if repair.stale_cleared > 0 {
println!(
" {} {} stale metadata entries cleared",
" {} {} stale metadata cleared",
action, repair.stale_cleared
);
}
if !repair.fts_rebuilt && repair.orphans_deleted == 0 && repair.stale_cleared == 0 {
println!(" No issues to repair.");
println!(" {}", Theme::dim().render("nothing to repair"));
}
}
}
println!();
}
#[derive(Serialize)]

View File

@@ -1,4 +1,4 @@
use console::style;
use crate::cli::render::{self, Theme};
use indicatif::{ProgressBar, ProgressStyle};
use serde::Serialize;
use std::sync::Arc;
@@ -240,7 +240,7 @@ pub async fn run_sync(
embed_bar.finish_and_clear();
spinner.finish_and_clear();
if !options.robot_mode {
eprintln!(" {} Embedding skipped ({})", style("warn").yellow(), e);
eprintln!(" {} Embedding skipped ({})", Theme::warning().render("warn"), e);
}
warn!(error = %e, "Embedding stage failed (Ollama may be unavailable), continuing");
}
@@ -273,37 +273,58 @@ pub fn print_sync(
elapsed: std::time::Duration,
metrics: Option<&MetricsLayer>,
) {
println!("{} Sync complete:", style("done").green().bold(),);
println!(" Issues updated: {}", result.issues_updated);
println!(" MRs updated: {}", result.mrs_updated);
// Headline: what happened, how long
println!(
" Discussions fetched: {}",
result.discussions_fetched
"\n {} {} issues and {} MRs in {:.1}s",
Theme::success().bold().render("Synced"),
Theme::bold().render(&result.issues_updated.to_string()),
Theme::bold().render(&result.mrs_updated.to_string()),
elapsed.as_secs_f64()
);
if result.mr_diffs_fetched > 0 || result.mr_diffs_failed > 0 {
println!(" MR diffs fetched: {}", result.mr_diffs_fetched);
if result.mr_diffs_failed > 0 {
println!(" MR diffs failed: {}", result.mr_diffs_failed);
}
// Detail: supporting counts, compact middle-dot format, zero-suppressed
let mut details: Vec<String> = Vec::new();
if result.discussions_fetched > 0 {
details.push(format!("{} discussions", result.discussions_fetched));
}
if result.resource_events_fetched > 0 || result.resource_events_failed > 0 {
println!(
" Resource events fetched: {}",
result.resource_events_fetched
);
if result.resource_events_failed > 0 {
println!(
" Resource events failed: {}",
result.resource_events_failed
);
}
if result.resource_events_fetched > 0 {
details.push(format!("{} events", result.resource_events_fetched));
}
println!(
" Documents regenerated: {}",
result.documents_regenerated
);
println!(" Documents embedded: {}", result.documents_embedded);
println!(" Elapsed: {:.1}s", elapsed.as_secs_f64());
if result.mr_diffs_fetched > 0 {
details.push(format!("{} diffs", result.mr_diffs_fetched));
}
if !details.is_empty() {
println!(" {}", Theme::dim().render(&details.join(" \u{b7} ")));
}
// Documents: regeneration + embedding as a second detail line
let mut doc_parts: Vec<String> = Vec::new();
if result.documents_regenerated > 0 {
doc_parts.push(format!("{} docs regenerated", result.documents_regenerated));
}
if result.documents_embedded > 0 {
doc_parts.push(format!("{} embedded", result.documents_embedded));
}
if !doc_parts.is_empty() {
println!(" {}", Theme::dim().render(&doc_parts.join(" \u{b7} ")));
}
// Errors: visually prominent, only if non-zero
let mut errors: Vec<String> = Vec::new();
if result.resource_events_failed > 0 {
errors.push(format!("{} event failures", result.resource_events_failed));
}
if result.mr_diffs_failed > 0 {
errors.push(format!("{} diff failures", result.mr_diffs_failed));
}
if result.status_enrichment_errors > 0 {
errors.push(format!("{} status errors", result.status_enrichment_errors));
}
if !errors.is_empty() {
println!(" {}", Theme::error().render(&errors.join(" \u{b7} ")));
}
println!();
if let Some(metrics) = metrics {
let stages = metrics.extract_timings();
@@ -313,9 +334,12 @@ pub fn print_sync(
}
}
fn section(title: &str) {
println!("{}", render::section_divider(title));
}
fn print_timing_summary(stages: &[StageTiming]) {
println!();
println!("{}", style("Stage timing:").dim());
section("Timing");
for stage in stages {
for sub in &stage.sub_stages {
print_stage_line(sub, 1);
@@ -331,29 +355,25 @@ fn print_stage_line(stage: &StageTiming, depth: usize) {
stage.name.clone()
};
let pad_width = 30_usize.saturating_sub(indent.len() + name.len());
let dots = ".".repeat(pad_width.max(2));
let dots = Theme::dim().render(&".".repeat(pad_width.max(2)));
let mut suffix = String::new();
let time_str = Theme::bold().render(&format!("{:.1}s", stage.elapsed_ms as f64 / 1000.0));
let mut parts: Vec<String> = Vec::new();
if stage.items_processed > 0 {
suffix.push_str(&format!("{} items", stage.items_processed));
parts.push(format!("{} items", stage.items_processed));
}
if stage.errors > 0 {
if !suffix.is_empty() {
suffix.push_str(", ");
}
suffix.push_str(&format!("{} errors", stage.errors));
parts.push(Theme::error().render(&format!("{} errors", stage.errors)));
}
if stage.rate_limit_hits > 0 {
if !suffix.is_empty() {
suffix.push_str(", ");
}
suffix.push_str(&format!("{} rate limits", stage.rate_limit_hits));
parts.push(Theme::warning().render(&format!("{} rate limits", stage.rate_limit_hits)));
}
let time_str = format!("{:.1}s", stage.elapsed_ms as f64 / 1000.0);
if suffix.is_empty() {
if parts.is_empty() {
println!("{indent}{name} {dots} {time_str}");
} else {
let suffix = parts.join(" \u{b7} ");
println!("{indent}{name} {dots} {time_str} ({suffix})");
}
@@ -423,87 +443,52 @@ async fn run_sync_dry_run(config: &Config, options: &SyncOptions) -> Result<Sync
pub fn print_sync_dry_run(result: &SyncDryRunResult) {
println!(
"{} {}",
style("Sync Dry Run Preview").cyan().bold(),
style("(no changes will be made)").yellow()
"\n {} {}",
Theme::info().bold().render("Dry run"),
Theme::dim().render("(no changes will be made)")
);
println!();
println!("{}", style("Stage 1: Issues Ingestion").white().bold());
println!(
" Sync mode: {}",
if result.issues_preview.sync_mode == "full" {
style("full").yellow()
} else {
style("incremental").green()
}
);
println!(" Projects: {}", result.issues_preview.projects.len());
for project in &result.issues_preview.projects {
let sync_status = if !project.has_cursor {
style("initial sync").yellow()
} else {
style("incremental").green()
};
println!(
" {} ({}) - {} existing",
&project.path, sync_status, project.existing_count
);
}
println!();
println!(
"{}",
style("Stage 2: Merge Requests Ingestion").white().bold()
);
println!(
" Sync mode: {}",
if result.mrs_preview.sync_mode == "full" {
style("full").yellow()
} else {
style("incremental").green()
}
);
println!(" Projects: {}", result.mrs_preview.projects.len());
for project in &result.mrs_preview.projects {
let sync_status = if !project.has_cursor {
style("initial sync").yellow()
} else {
style("incremental").green()
};
println!(
" {} ({}) - {} existing",
&project.path, sync_status, project.existing_count
);
}
println!();
print_dry_run_entity("Issues", &result.issues_preview);
print_dry_run_entity("Merge Requests", &result.mrs_preview);
// Pipeline stages
section("Pipeline");
let mut stages: Vec<String> = Vec::new();
if result.would_generate_docs {
println!(
"{} {}",
style("Stage 3: Document Generation").white().bold(),
style("(would run)").green()
);
stages.push("generate-docs".to_string());
} else {
println!(
"{} {}",
style("Stage 3: Document Generation").white().bold(),
style("(skipped)").dim()
);
stages.push(Theme::dim().render("generate-docs (skip)"));
}
if result.would_embed {
println!(
"{} {}",
style("Stage 4: Embedding").white().bold(),
style("(would run)").green()
);
stages.push("embed".to_string());
} else {
println!(
"{} {}",
style("Stage 4: Embedding").white().bold(),
style("(skipped)").dim()
);
stages.push(Theme::dim().render("embed (skip)"));
}
println!(" {}", stages.join(" \u{b7} "));
}
fn print_dry_run_entity(label: &str, preview: &DryRunPreview) {
section(label);
let mode = if preview.sync_mode == "full" {
Theme::warning().render("full")
} else {
Theme::success().render("incremental")
};
println!(" {} \u{b7} {} projects", mode, preview.projects.len());
for project in &preview.projects {
let sync_status = if !project.has_cursor {
Theme::warning().render("initial sync")
} else {
Theme::success().render("incremental")
};
if project.existing_count > 0 {
println!(
" {} \u{b7} {} \u{b7} {} existing",
&project.path, sync_status, project.existing_count
);
} else {
println!(" {} \u{b7} {}", &project.path, sync_status);
}
}
}

View File

@@ -1,4 +1,4 @@
use console::style;
use crate::cli::render::{self, Theme};
use rusqlite::Connection;
use serde::Serialize;
@@ -166,27 +166,6 @@ fn format_duration(ms: i64) -> String {
}
}
fn format_number(n: i64) -> String {
let is_negative = n < 0;
let abs_n = n.unsigned_abs();
let s = abs_n.to_string();
let chars: Vec<char> = s.chars().collect();
let mut result = String::new();
if is_negative {
result.push('-');
}
for (i, c) in chars.iter().enumerate() {
if i > 0 && (chars.len() - i).is_multiple_of(3) {
result.push(',');
}
result.push(*c);
}
result
}
#[derive(Serialize)]
struct SyncStatusJsonOutput {
ok: bool,
@@ -293,14 +272,14 @@ pub fn print_sync_status_json(result: &SyncStatusResult, elapsed_ms: u64) {
}
pub fn print_sync_status(result: &SyncStatusResult) {
println!("{}", style("Recent Sync Runs").bold().underlined());
println!("{}", Theme::bold().underline().render("Recent Sync Runs"));
println!();
if result.runs.is_empty() {
println!(" {}", style("No sync runs recorded yet.").dim());
println!(" {}", Theme::dim().render("No sync runs recorded yet."));
println!(
" {}",
style("Run 'lore sync' or 'lore ingest' to start.").dim()
Theme::dim().render("Run 'lore sync' or 'lore ingest' to start.")
);
} else {
for run in &result.runs {
@@ -310,16 +289,16 @@ pub fn print_sync_status(result: &SyncStatusResult) {
println!();
println!("{}", style("Cursor Positions").bold().underlined());
println!("{}", Theme::bold().underline().render("Cursor Positions"));
println!();
if result.cursors.is_empty() {
println!(" {}", style("No cursors recorded yet.").dim());
println!(" {}", Theme::dim().render("No cursors recorded yet."));
} else {
for cursor in &result.cursors {
println!(
" {} ({}):",
style(&cursor.project_path).cyan(),
Theme::info().render(&cursor.project_path),
cursor.resource_type
);
@@ -328,7 +307,10 @@ pub fn print_sync_status(result: &SyncStatusResult) {
println!(" Last updated_at: {}", ms_to_iso(ts));
}
_ => {
println!(" Last updated_at: {}", style("Not started").dim());
println!(
" Last updated_at: {}",
Theme::dim().render("Not started")
);
}
}
@@ -340,40 +322,39 @@ pub fn print_sync_status(result: &SyncStatusResult) {
println!();
println!("{}", style("Data Summary").bold().underlined());
println!("{}", Theme::bold().underline().render("Data Summary"));
println!();
println!(
" Issues: {}",
style(format_number(result.summary.issue_count)).bold()
Theme::bold().render(&render::format_number(result.summary.issue_count))
);
println!(
" MRs: {}",
style(format_number(result.summary.mr_count)).bold()
Theme::bold().render(&render::format_number(result.summary.mr_count))
);
println!(
" Discussions: {}",
style(format_number(result.summary.discussion_count)).bold()
Theme::bold().render(&render::format_number(result.summary.discussion_count))
);
let user_notes = result.summary.note_count - result.summary.system_note_count;
println!(
" Notes: {} {}",
style(format_number(user_notes)).bold(),
style(format!(
Theme::bold().render(&render::format_number(user_notes)),
Theme::dim().render(&format!(
"(excluding {} system)",
format_number(result.summary.system_note_count)
render::format_number(result.summary.system_note_count)
))
.dim()
);
}
fn print_run_line(run: &SyncRunInfo) {
let status_styled = match run.status.as_str() {
"succeeded" => style(&run.status).green(),
"failed" => style(&run.status).red(),
"running" => style(&run.status).yellow(),
_ => style(&run.status).dim(),
"succeeded" => Theme::success().render(&run.status),
"failed" => Theme::error().render(&run.status),
"running" => Theme::warning().render(&run.status),
_ => Theme::dim().render(&run.status),
};
let run_label = run
@@ -386,9 +367,9 @@ fn print_run_line(run: &SyncRunInfo) {
let time = format_full_datetime(run.started_at);
let mut parts = vec![
format!("{}", style(run_label).bold()),
format!("{status_styled}"),
format!("{}", style(&run.command).dim()),
Theme::bold().render(&run_label),
status_styled,
Theme::dim().render(&run.command),
time,
];
@@ -403,16 +384,13 @@ fn print_run_line(run: &SyncRunInfo) {
}
if run.total_errors > 0 {
parts.push(format!(
"{}",
style(format!("{} errors", run.total_errors)).red()
));
parts.push(Theme::error().render(&format!("{} errors", run.total_errors)));
}
println!(" {}", parts.join(" | "));
if let Some(error) = &run.error {
println!(" {}", style(error).red());
println!(" {}", Theme::error().render(error));
}
}
@@ -448,7 +426,7 @@ mod tests {
#[test]
fn format_number_adds_thousands_separators() {
assert_eq!(format_number(1000), "1,000");
assert_eq!(format_number(1234567), "1,234,567");
assert_eq!(render::format_number(1000), "1,000");
assert_eq!(render::format_number(1234567), "1,234,567");
}
}

View File

@@ -1,4 +1,4 @@
use console::{Alignment, pad_str, style};
use crate::cli::render::{self, Theme};
use serde::Serialize;
use crate::Config;
@@ -22,7 +22,7 @@ pub struct TimelineParams {
pub project: Option<String>,
pub since: Option<String>,
pub depth: u32,
pub expand_mentions: bool,
pub no_mentions: bool,
pub limit: usize,
pub max_seeds: usize,
pub max_entities: usize,
@@ -133,7 +133,7 @@ pub async fn run_timeline(config: &Config, params: &TimelineParams) -> Result<Ti
&conn,
&seed_result.seed_entities,
params.depth,
params.expand_mentions,
!params.no_mentions,
params.max_entities,
)?;
spinner.finish_and_clear();
@@ -171,19 +171,21 @@ pub fn print_timeline(result: &TimelineResult) {
println!();
println!(
"{}",
style(format!(
Theme::bold().render(&format!(
"Timeline: \"{}\" ({} events across {} entities)",
result.query,
result.events.len(),
entity_count,
))
.bold()
);
println!("{}", "".repeat(60));
println!("{}", "\u{2500}".repeat(60));
println!();
if result.events.is_empty() {
println!(" {}", style("No events found for this query.").dim());
println!(
" {}",
Theme::dim().render("No events found for this query.")
);
println!();
return;
}
@@ -193,12 +195,12 @@ pub fn print_timeline(result: &TimelineResult) {
}
println!();
println!("{}", "".repeat(60));
println!("{}", "\u{2500}".repeat(60));
print_timeline_footer(result);
}
fn print_timeline_event(event: &TimelineEvent) {
let date = format_date(event.timestamp);
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
@@ -208,18 +210,20 @@ fn print_timeline_event(event: &TimelineEvent) {
.unwrap_or_default();
let expanded_marker = if event.is_seed { "" } else { " [expanded]" };
let summary = truncate_summary(&event.summary, 50);
let tag_padded = pad_str(&tag, 12, Alignment::Left, None);
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()
{
for line in wrap_snippet(snippet, 60) {
let mut lines = render::wrap_lines(snippet, 60);
lines.truncate(4);
for line in lines {
println!(
" \"{}\"",
style(line).dim()
Theme::dim().render(&line)
);
}
}
@@ -229,14 +233,14 @@ fn print_timeline_event(event: &TimelineEvent) {
let bar = "\u{2500}".repeat(44);
println!(" \u{2500}\u{2500} Discussion {bar}");
for note in notes {
let note_date = format_date(note.created_at);
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}):", style(author).bold());
for line in wrap_text(&note.body, 60) {
println!(" {} ({note_date}):", Theme::bold().render(&author));
for line in render::wrap_lines(&note.body, 60) {
println!(" {line}");
}
}
@@ -274,20 +278,20 @@ fn print_timeline_footer(result: &TimelineResult) {
fn format_event_tag(event_type: &TimelineEventType) -> String {
match event_type {
TimelineEventType::Created => style("CREATED").green().to_string(),
TimelineEventType::Created => Theme::success().render("CREATED"),
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(),
"closed" => Theme::error().render("CLOSED"),
"reopened" => Theme::warning().render("REOPENED"),
_ => Theme::dim().render(&state.to_uppercase()),
},
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::DiscussionThread { .. } => style("THREAD").yellow().to_string(),
TimelineEventType::CrossReferenced { .. } => style("REF").dim().to_string(),
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"),
}
}
@@ -299,48 +303,6 @@ fn format_entity_ref(entity_type: &str, iid: i64) -> String {
}
}
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_text(text: &str, width: usize) -> Vec<String> {
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);
}
lines
}
fn wrap_snippet(text: &str, width: usize) -> Vec<String> {
let mut lines = wrap_text(text, width);
lines.truncate(4);
lines
}
// ─── Robot JSON output ───────────────────────────────────────────────────────
/// Render timeline as robot-mode JSON in {ok, data, meta} envelope.
@@ -348,7 +310,7 @@ pub fn print_timeline_json_with_meta(
result: &TimelineResult,
total_events_before_limit: usize,
depth: u32,
expand_mentions: bool,
include_mentions: bool,
fields: Option<&[String]>,
) {
let output = TimelineJsonEnvelope {
@@ -357,7 +319,7 @@ pub fn print_timeline_json_with_meta(
meta: TimelineMetaJson {
search_mode: result.search_mode.clone(),
expansion_depth: depth,
expand_mentions,
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),
@@ -586,7 +548,7 @@ fn event_type_to_json(event_type: &TimelineEventType) -> (String, serde_json::Va
struct TimelineMetaJson {
search_mode: String,
expansion_depth: u32,
expand_mentions: bool,
include_mentions: bool,
total_entities: usize,
total_events: usize,
evidence_notes_included: usize,

View File

@@ -1,4 +1,4 @@
use console::style;
use crate::cli::render::{self, Theme};
use rusqlite::Connection;
use serde::Serialize;
use std::collections::{HashMap, HashSet};
@@ -1874,18 +1874,21 @@ fn print_scope_hint(project_path: Option<&str>) {
if project_path.is_none() {
println!(
" {}",
style("(aggregated across all projects; use -p to scope)").dim()
Theme::dim().render("(aggregated across all projects; use -p to scope)")
);
}
}
fn print_expert_human(r: &ExpertResult, project_path: Option<&str>) {
println!();
println!("{}", style(format!("Experts for {}", r.path_query)).bold());
println!(
"{}",
Theme::bold().render(&format!("Experts for {}", r.path_query))
);
println!("{}", "\u{2500}".repeat(60));
println!(
" {}",
style(format!(
Theme::dim().render(&format!(
"(matching {} {})",
r.path_match,
if r.path_match == "exact" {
@@ -1894,26 +1897,28 @@ fn print_expert_human(r: &ExpertResult, project_path: Option<&str>) {
"directory prefix"
}
))
.dim()
);
print_scope_hint(project_path);
println!();
if r.experts.is_empty() {
println!(" {}", style("No experts found for this path.").dim());
println!(
" {}",
Theme::dim().render("No experts found for this path.")
);
println!();
return;
}
println!(
" {:<16} {:>6} {:>12} {:>6} {:>12} {} {}",
style("Username").bold(),
style("Score").bold(),
style("Reviewed(MRs)").bold(),
style("Notes").bold(),
style("Authored(MRs)").bold(),
style("Last Seen").bold(),
style("MR Refs").bold(),
Theme::bold().render("Username"),
Theme::bold().render("Score"),
Theme::bold().render("Reviewed(MRs)"),
Theme::bold().render("Notes"),
Theme::bold().render("Authored(MRs)"),
Theme::bold().render("Last Seen"),
Theme::bold().render("MR Refs"),
);
for expert in &r.experts {
@@ -1946,12 +1951,12 @@ fn print_expert_human(r: &ExpertResult, project_path: Option<&str>) {
};
println!(
" {:<16} {:>6} {:>12} {:>6} {:>12} {:<12}{}{}",
style(format!("@{}", expert.username)).cyan(),
Theme::info().render(&format!("@{}", expert.username)),
expert.score,
reviews,
notes,
authored,
format_relative_time(expert.last_seen_ms),
render::format_relative_time(expert.last_seen_ms),
if mr_str.is_empty() {
String::new()
} else {
@@ -1971,17 +1976,17 @@ fn print_expert_human(r: &ExpertResult, project_path: Option<&str>) {
};
println!(
" {:<3} {:<30} {:>30} {:>10} {}",
style(&d.role).dim(),
Theme::dim().render(&d.role),
d.mr_ref,
truncate_str(&format!("\"{}\"", d.title), 30),
render::truncate(&format!("\"{}\"", d.title), 30),
notes_str,
style(format_relative_time(d.last_activity_ms)).dim(),
Theme::dim().render(&render::format_relative_time(d.last_activity_ms)),
);
}
if details.len() > MAX_DETAIL_DISPLAY {
println!(
" {}",
style(format!("+{} more", details.len() - MAX_DETAIL_DISPLAY)).dim()
Theme::dim().render(&format!("+{} more", details.len() - MAX_DETAIL_DISPLAY))
);
}
}
@@ -1989,7 +1994,7 @@ fn print_expert_human(r: &ExpertResult, project_path: Option<&str>) {
if r.truncated {
println!(
" {}",
style("(showing first -n; rerun with a higher --limit)").dim()
Theme::dim().render("(showing first -n; rerun with a higher --limit)")
);
}
println!();
@@ -1999,7 +2004,7 @@ fn print_workload_human(r: &WorkloadResult) {
println!();
println!(
"{}",
style(format!("@{} -- Workload Summary", r.username)).bold()
Theme::bold().render(&format!("@{} -- Workload Summary", r.username))
);
println!("{}", "\u{2500}".repeat(60));
@@ -2007,21 +2012,21 @@ fn print_workload_human(r: &WorkloadResult) {
println!();
println!(
" {} ({})",
style("Assigned Issues").bold(),
Theme::bold().render("Assigned Issues"),
r.assigned_issues.len()
);
for item in &r.assigned_issues {
println!(
" {} {} {}",
style(&item.ref_).cyan(),
truncate_str(&item.title, 40),
style(format_relative_time(item.updated_at)).dim(),
Theme::info().render(&item.ref_),
render::truncate(&item.title, 40),
Theme::dim().render(&render::format_relative_time(item.updated_at)),
);
}
if r.assigned_issues_truncated {
println!(
" {}",
style("(truncated; rerun with a higher --limit)").dim()
Theme::dim().render("(truncated; rerun with a higher --limit)")
);
}
}
@@ -2030,23 +2035,23 @@ fn print_workload_human(r: &WorkloadResult) {
println!();
println!(
" {} ({})",
style("Authored MRs").bold(),
Theme::bold().render("Authored MRs"),
r.authored_mrs.len()
);
for mr in &r.authored_mrs {
let draft = if mr.draft { " [draft]" } else { "" };
println!(
" {} {}{} {}",
style(&mr.ref_).cyan(),
truncate_str(&mr.title, 35),
style(draft).dim(),
style(format_relative_time(mr.updated_at)).dim(),
Theme::info().render(&mr.ref_),
render::truncate(&mr.title, 35),
Theme::dim().render(draft),
Theme::dim().render(&render::format_relative_time(mr.updated_at)),
);
}
if r.authored_mrs_truncated {
println!(
" {}",
style("(truncated; rerun with a higher --limit)").dim()
Theme::dim().render("(truncated; rerun with a higher --limit)")
);
}
}
@@ -2055,7 +2060,7 @@ fn print_workload_human(r: &WorkloadResult) {
println!();
println!(
" {} ({})",
style("Reviewing MRs").bold(),
Theme::bold().render("Reviewing MRs"),
r.reviewing_mrs.len()
);
for mr in &r.reviewing_mrs {
@@ -2066,16 +2071,16 @@ fn print_workload_human(r: &WorkloadResult) {
.unwrap_or_default();
println!(
" {} {}{} {}",
style(&mr.ref_).cyan(),
truncate_str(&mr.title, 30),
style(author).dim(),
style(format_relative_time(mr.updated_at)).dim(),
Theme::info().render(&mr.ref_),
render::truncate(&mr.title, 30),
Theme::dim().render(&author),
Theme::dim().render(&render::format_relative_time(mr.updated_at)),
);
}
if r.reviewing_mrs_truncated {
println!(
" {}",
style("(truncated; rerun with a higher --limit)").dim()
Theme::dim().render("(truncated; rerun with a higher --limit)")
);
}
}
@@ -2084,22 +2089,22 @@ fn print_workload_human(r: &WorkloadResult) {
println!();
println!(
" {} ({})",
style("Unresolved Discussions").bold(),
Theme::bold().render("Unresolved Discussions"),
r.unresolved_discussions.len()
);
for disc in &r.unresolved_discussions {
println!(
" {} {} {} {}",
style(&disc.entity_type).dim(),
style(&disc.ref_).cyan(),
truncate_str(&disc.entity_title, 35),
style(format_relative_time(disc.last_note_at)).dim(),
Theme::dim().render(&disc.entity_type),
Theme::info().render(&disc.ref_),
render::truncate(&disc.entity_title, 35),
Theme::dim().render(&render::format_relative_time(disc.last_note_at)),
);
}
if r.unresolved_discussions_truncated {
println!(
" {}",
style("(truncated; rerun with a higher --limit)").dim()
Theme::dim().render("(truncated; rerun with a higher --limit)")
);
}
}
@@ -2112,7 +2117,7 @@ fn print_workload_human(r: &WorkloadResult) {
println!();
println!(
" {}",
style("No open work items found for this user.").dim()
Theme::dim().render("No open work items found for this user.")
);
}
@@ -2123,7 +2128,7 @@ fn print_reviews_human(r: &ReviewsResult) {
println!();
println!(
"{}",
style(format!("@{} -- Review Patterns", r.username)).bold()
Theme::bold().render(&format!("@{} -- Review Patterns", r.username))
);
println!("{}", "\u{2500}".repeat(60));
println!();
@@ -2131,7 +2136,7 @@ fn print_reviews_human(r: &ReviewsResult) {
if r.total_diffnotes == 0 {
println!(
" {}",
style("No review comments found for this user.").dim()
Theme::dim().render("No review comments found for this user.")
);
println!();
return;
@@ -2139,24 +2144,24 @@ fn print_reviews_human(r: &ReviewsResult) {
println!(
" {} DiffNotes across {} MRs ({} categorized)",
style(r.total_diffnotes).bold(),
style(r.mrs_reviewed).bold(),
style(r.categorized_count).bold(),
Theme::bold().render(&r.total_diffnotes.to_string()),
Theme::bold().render(&r.mrs_reviewed.to_string()),
Theme::bold().render(&r.categorized_count.to_string()),
);
println!();
if !r.categories.is_empty() {
println!(
" {:<16} {:>6} {:>6}",
style("Category").bold(),
style("Count").bold(),
style("%").bold(),
Theme::bold().render("Category"),
Theme::bold().render("Count"),
Theme::bold().render("%"),
);
for cat in &r.categories {
println!(
" {:<16} {:>6} {:>5.1}%",
style(&cat.name).cyan(),
Theme::info().render(&cat.name),
cat.count,
cat.percentage,
);
@@ -2168,7 +2173,7 @@ fn print_reviews_human(r: &ReviewsResult) {
println!();
println!(
" {} {} uncategorized (no **prefix** convention)",
style("Note:").dim(),
Theme::dim().render("Note:"),
uncategorized,
);
}
@@ -2180,11 +2185,10 @@ fn print_active_human(r: &ActiveResult, project_path: Option<&str>) {
println!();
println!(
"{}",
style(format!(
Theme::bold().render(&format!(
"Active Discussions ({} unresolved in window)",
r.total_unresolved_in_window
))
.bold()
);
println!("{}", "\u{2500}".repeat(60));
print_scope_hint(project_path);
@@ -2193,7 +2197,7 @@ fn print_active_human(r: &ActiveResult, project_path: Option<&str>) {
if r.discussions.is_empty() {
println!(
" {}",
style("No active unresolved discussions in this time window.").dim()
Theme::dim().render("No active unresolved discussions in this time window.")
);
println!();
return;
@@ -2210,20 +2214,20 @@ fn print_active_human(r: &ActiveResult, project_path: Option<&str>) {
println!(
" {} {} {} {} notes {}",
style(format!("{prefix}{}", disc.entity_iid)).cyan(),
truncate_str(&disc.entity_title, 40),
style(format_relative_time(disc.last_note_at)).dim(),
Theme::info().render(&format!("{prefix}{}", disc.entity_iid)),
render::truncate(&disc.entity_title, 40),
Theme::dim().render(&render::format_relative_time(disc.last_note_at)),
disc.note_count,
style(&disc.project_path).dim(),
Theme::dim().render(&disc.project_path),
);
if !participants_str.is_empty() {
println!(" {}", style(participants_str).dim());
println!(" {}", Theme::dim().render(&participants_str));
}
}
if r.truncated {
println!(
" {}",
style("(showing first -n; rerun with a higher --limit)").dim()
Theme::dim().render("(showing first -n; rerun with a higher --limit)")
);
}
println!();
@@ -2231,11 +2235,14 @@ fn print_active_human(r: &ActiveResult, project_path: Option<&str>) {
fn print_overlap_human(r: &OverlapResult, project_path: Option<&str>) {
println!();
println!("{}", style(format!("Overlap for {}", r.path_query)).bold());
println!(
"{}",
Theme::bold().render(&format!("Overlap for {}", r.path_query))
);
println!("{}", "\u{2500}".repeat(60));
println!(
" {}",
style(format!(
Theme::dim().render(&format!(
"(matching {} {})",
r.path_match,
if r.path_match == "exact" {
@@ -2244,7 +2251,6 @@ fn print_overlap_human(r: &OverlapResult, project_path: Option<&str>) {
"directory prefix"
}
))
.dim()
);
print_scope_hint(project_path);
println!();
@@ -2252,7 +2258,7 @@ fn print_overlap_human(r: &OverlapResult, project_path: Option<&str>) {
if r.users.is_empty() {
println!(
" {}",
style("No overlapping users found for this path.").dim()
Theme::dim().render("No overlapping users found for this path.")
);
println!();
return;
@@ -2260,11 +2266,11 @@ fn print_overlap_human(r: &OverlapResult, project_path: Option<&str>) {
println!(
" {:<16} {:<6} {:>7} {:<12} {}",
style("Username").bold(),
style("Role").bold(),
style("MRs").bold(),
style("Last Seen").bold(),
style("MR Refs").bold(),
Theme::bold().render("Username"),
Theme::bold().render("Role"),
Theme::bold().render("MRs"),
Theme::bold().render("Last Seen"),
Theme::bold().render("MR Refs"),
);
for user in &r.users {
@@ -2283,10 +2289,10 @@ fn print_overlap_human(r: &OverlapResult, project_path: Option<&str>) {
println!(
" {:<16} {:<6} {:>7} {:<12} {}{}",
style(format!("@{}", user.username)).cyan(),
Theme::info().render(&format!("@{}", user.username)),
format_overlap_role(user),
user.touch_count,
format_relative_time(user.last_seen_at),
render::format_relative_time(user.last_seen_at),
mr_str,
overflow,
);
@@ -2294,7 +2300,7 @@ fn print_overlap_human(r: &OverlapResult, project_path: Option<&str>) {
if r.truncated {
println!(
" {}",
style("(showing first -n; rerun with a higher --limit)").dim()
Theme::dim().render("(showing first -n; rerun with a higher --limit)")
);
}
println!();
@@ -2532,47 +2538,6 @@ fn overlap_to_json(r: &OverlapResult) -> serde_json::Value {
})
}
// ─── Helper Functions ────────────────────────────────────────────────────────
fn format_relative_time(ms_epoch: i64) -> String {
let now = now_ms();
let diff = now - ms_epoch;
if diff < 0 {
return "in the future".to_string();
}
match diff {
d if d < 60_000 => "just now".to_string(),
d if d < 3_600_000 => format!("{} min ago", d / 60_000),
d if d < 86_400_000 => {
let n = d / 3_600_000;
format!("{n} {} ago", if n == 1 { "hour" } else { "hours" })
}
d if d < 604_800_000 => {
let n = d / 86_400_000;
format!("{n} {} ago", if n == 1 { "day" } else { "days" })
}
d if d < 2_592_000_000 => {
let n = d / 604_800_000;
format!("{n} {} ago", if n == 1 { "week" } else { "weeks" })
}
_ => {
let n = diff / 2_592_000_000;
format!("{n} {} ago", if n == 1 { "month" } else { "months" })
}
}
}
fn truncate_str(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_owned()
} else {
let truncated: String = s.chars().take(max.saturating_sub(3)).collect();
format!("{truncated}...")
}
}
// ─── Tests ───────────────────────────────────────────────────────────────────
#[cfg(test)]

View File

@@ -1,6 +1,7 @@
pub mod autocorrect;
pub mod commands;
pub mod progress;
pub mod render;
pub mod robot;
use clap::{Parser, Subcommand};
@@ -810,7 +811,8 @@ pub struct EmbedArgs {
lore timeline i:42 # Shorthand for issue:42
lore timeline mr:99 # Direct: MR !99 and related entities
lore timeline 'auth' --since 30d -p group/repo # Scoped to project and time
lore timeline 'migration' --depth 2 --expand-mentions # Deep cross-reference expansion")]
lore timeline 'migration' --depth 2 # Deep cross-reference expansion
lore timeline 'auth' --no-mentions # Only 'closes' and 'related' edges")]
pub struct TimelineArgs {
/// Search text or entity reference (issue:N, i:N, mr:N, m:N)
pub query: String,
@@ -827,9 +829,9 @@ pub struct TimelineArgs {
#[arg(long, default_value = "1", help_heading = "Expansion")]
pub depth: u32,
/// Also follow 'mentioned' edges during expansion (high fan-out)
#[arg(long = "expand-mentions", help_heading = "Expansion")]
pub expand_mentions: bool,
/// Skip 'mentioned' edges during expansion (only follow 'closes' and 'related')
#[arg(long = "no-mentions", help_heading = "Expansion")]
pub no_mentions: bool,
/// Maximum number of events to display
#[arg(

969
src/cli/render.rs Normal file
View File

@@ -0,0 +1,969 @@
use std::io::IsTerminal;
use std::sync::OnceLock;
use chrono::DateTime;
use lipgloss::Style;
use crate::core::time::{ms_to_iso, now_ms};
// ─── Color Mode ──────────────────────────────────────────────────────────────
/// Color mode derived from CLI `--color` flag and `NO_COLOR` env.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColorMode {
Auto,
Always,
Never,
}
/// Global renderer singleton, initialized once in `main.rs`.
static RENDERER: OnceLock<LoreRenderer> = OnceLock::new();
pub struct LoreRenderer {
/// Resolved at init time so we don't re-check TTY + NO_COLOR on every call.
colors: bool,
}
impl LoreRenderer {
/// Initialize the global renderer. Call once at startup.
pub fn init(mode: ColorMode) {
let colors = match mode {
ColorMode::Always => true,
ColorMode::Never => false,
ColorMode::Auto => {
std::io::stdout().is_terminal()
&& std::env::var("NO_COLOR").map_or(true, |v| v.is_empty())
}
};
let _ = RENDERER.set(LoreRenderer { colors });
}
/// Get the global renderer. Panics if `init` hasn't been called.
pub fn get() -> &'static LoreRenderer {
RENDERER
.get()
.expect("LoreRenderer::init must be called before get")
}
/// Whether color output is enabled.
pub fn colors_enabled(&self) -> bool {
self.colors
}
}
/// Check if colors are enabled. Returns false if `LoreRenderer` hasn't been
/// initialized (e.g. in tests), which is the safe default.
fn colors_on() -> bool {
RENDERER.get().is_some_and(LoreRenderer::colors_enabled)
}
// ─── Theme ───────────────────────────────────────────────────────────────────
/// Semantic style constants for the compact professional design language.
///
/// When colors are disabled (`--color never`, `NO_COLOR`, non-TTY), all methods
/// return a plain `Style::new()` that passes text through unchanged. This is
/// necessary because lipgloss's `Style::render()` uses a hardcoded TrueColor
/// renderer by default and does NOT auto-detect `NO_COLOR` or TTY state.
pub struct Theme;
impl Theme {
// Text emphasis
pub fn bold() -> Style {
if colors_on() {
Style::new().bold()
} else {
Style::new()
}
}
pub fn dim() -> Style {
if colors_on() {
Style::new().faint()
} else {
Style::new()
}
}
// Semantic colors
pub fn success() -> Style {
if colors_on() {
Style::new().foreground("#10b981")
} else {
Style::new()
}
}
pub fn warning() -> Style {
if colors_on() {
Style::new().foreground("#f59e0b")
} else {
Style::new()
}
}
pub fn error() -> Style {
if colors_on() {
Style::new().foreground("#ef4444")
} else {
Style::new()
}
}
pub fn info() -> Style {
if colors_on() {
Style::new().foreground("#06b6d4")
} else {
Style::new()
}
}
pub fn accent() -> Style {
if colors_on() {
Style::new().foreground("#a855f7")
} else {
Style::new()
}
}
// Entity styling
pub fn issue_ref() -> Style {
if colors_on() {
Style::new().foreground("#06b6d4")
} else {
Style::new()
}
}
pub fn mr_ref() -> Style {
if colors_on() {
Style::new().foreground("#a855f7")
} else {
Style::new()
}
}
pub fn username() -> Style {
if colors_on() {
Style::new().foreground("#06b6d4")
} else {
Style::new()
}
}
// State
pub fn state_opened() -> Style {
if colors_on() {
Style::new().foreground("#10b981")
} else {
Style::new()
}
}
pub fn state_closed() -> Style {
if colors_on() {
Style::new().faint()
} else {
Style::new()
}
}
pub fn state_merged() -> Style {
if colors_on() {
Style::new().foreground("#a855f7")
} else {
Style::new()
}
}
// Structure
pub fn section_title() -> Style {
if colors_on() {
Style::new().foreground("#06b6d4").bold()
} else {
Style::new()
}
}
pub fn header() -> Style {
if colors_on() {
Style::new().bold()
} else {
Style::new()
}
}
}
// ─── Shared Formatters ───────────────────────────────────────────────────────
/// Format an integer with thousands separators (commas).
pub fn format_number(n: i64) -> String {
let (prefix, abs) = if n < 0 {
("-", n.unsigned_abs())
} else {
("", n.unsigned_abs())
};
let s = abs.to_string();
let chars: Vec<char> = s.chars().collect();
let mut result = String::from(prefix);
for (i, c) in chars.iter().enumerate() {
if i > 0 && (chars.len() - i).is_multiple_of(3) {
result.push(',');
}
result.push(*c);
}
result
}
/// Format an epoch-ms timestamp as a human-friendly relative time string.
pub fn format_relative_time(ms_epoch: i64) -> String {
let now = now_ms();
let diff = now - ms_epoch;
if diff < 0 {
return "in the future".to_string();
}
match diff {
d if d < 60_000 => "just now".to_string(),
d if d < 3_600_000 => format!("{} min ago", d / 60_000),
d if d < 86_400_000 => {
let n = d / 3_600_000;
format!("{n} {} ago", if n == 1 { "hour" } else { "hours" })
}
d if d < 604_800_000 => {
let n = d / 86_400_000;
format!("{n} {} ago", if n == 1 { "day" } else { "days" })
}
d if d < 2_592_000_000 => {
let n = d / 604_800_000;
format!("{n} {} ago", if n == 1 { "week" } else { "weeks" })
}
_ => {
let n = diff / 2_592_000_000;
format!("{n} {} ago", if n == 1 { "month" } else { "months" })
}
}
}
/// Format an epoch-ms timestamp as YYYY-MM-DD.
pub fn format_date(ms: i64) -> String {
let iso = ms_to_iso(ms);
iso.split('T').next().unwrap_or(&iso).to_string()
}
/// Format an epoch-ms timestamp as YYYY-MM-DD HH:MM.
pub fn format_datetime(ms: i64) -> String {
DateTime::from_timestamp_millis(ms)
.map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
.unwrap_or_else(|| "unknown".to_string())
}
/// Truncate a string to `max` characters, appending "..." if truncated.
pub fn truncate(s: &str, max: usize) -> String {
if max < 4 {
return s.chars().take(max).collect();
}
if s.chars().count() <= max {
s.to_owned()
} else {
let truncated: String = s.chars().take(max.saturating_sub(3)).collect();
format!("{truncated}...")
}
}
/// Word-wrap text to `width`, prepending `indent` to continuation lines.
/// Returns a single string with embedded newlines.
pub fn wrap_indent(text: &str, width: usize, indent: &str) -> String {
let mut result = String::new();
let mut current_line = String::new();
for word in text.split_whitespace() {
if current_line.is_empty() {
current_line = word.to_string();
} else if current_line.len() + 1 + word.len() <= width {
current_line.push(' ');
current_line.push_str(word);
} else {
if !result.is_empty() {
result.push('\n');
result.push_str(indent);
}
result.push_str(&current_line);
current_line = word.to_string();
}
}
if !current_line.is_empty() {
if !result.is_empty() {
result.push('\n');
result.push_str(indent);
}
result.push_str(&current_line);
}
result
}
/// Word-wrap text to `width`, returning a Vec of lines.
pub fn wrap_lines(text: &str, width: usize) -> Vec<String> {
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);
}
lines
}
/// Render a section divider: `── Title ──────────────────────`
pub fn section_divider(title: &str) -> String {
let rule_len = 40_usize.saturating_sub(title.len() + 4);
format!(
"\n {} {} {}",
Theme::dim().render("\u{2500}\u{2500}"),
Theme::section_title().render(title),
Theme::dim().render(&"\u{2500}".repeat(rule_len)),
)
}
/// Apply a hex color (e.g. `"#ff6b6b"`) to text via lipgloss.
/// Returns styled text if hex is valid, or plain text otherwise.
pub fn style_with_hex(text: &str, hex: Option<&str>) -> String {
match hex {
Some(h) if h.len() >= 4 && colors_on() => Style::new().foreground(h).render(text),
_ => text.to_string(),
}
}
/// Format a slice of labels with overflow indicator.
/// e.g. `["bug", "urgent"]` with max 2 -> `"[bug, urgent]"`
/// e.g. `["a", "b", "c", "d"]` with max 2 -> `"[a, b +2]"`
pub fn format_labels(labels: &[String], max_shown: usize) -> String {
if labels.is_empty() {
return String::new();
}
let shown: Vec<&str> = labels.iter().take(max_shown).map(|s| s.as_str()).collect();
let overflow = labels.len().saturating_sub(max_shown);
if overflow > 0 {
format!("[{} +{}]", shown.join(", "), overflow)
} else {
format!("[{}]", shown.join(", "))
}
}
/// Format a duration in milliseconds as a human-friendly string.
pub fn format_duration_ms(ms: u64) -> String {
if ms < 1000 {
format!("{ms}ms")
} else {
format!("{:.1}s", ms as f64 / 1000.0)
}
}
// ─── Table Renderer ──────────────────────────────────────────────────────────
/// Column alignment for the table renderer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Align {
Left,
Right,
}
/// A cell in a table row, optionally styled.
pub struct StyledCell {
pub text: String,
pub style: Option<Style>,
}
impl StyledCell {
/// Create a plain (unstyled) cell.
pub fn plain(text: impl Into<String>) -> Self {
Self {
text: text.into(),
style: None,
}
}
/// Create a styled cell.
pub fn styled(text: impl Into<String>, style: Style) -> Self {
Self {
text: text.into(),
style: Some(style),
}
}
/// The visible width of the cell text (ANSI-unaware character count).
fn visible_width(&self) -> usize {
// Use char count since our text doesn't contain ANSI before rendering
self.text.chars().count()
}
}
/// A compact table renderer built on lipgloss.
///
/// Design: no borders, bold headers, thin `─` separator, 2-space column gaps.
#[derive(Default)]
pub struct Table {
headers: Vec<String>,
rows: Vec<Vec<StyledCell>>,
alignments: Vec<Align>,
max_widths: Vec<Option<usize>>,
}
impl Table {
pub fn new() -> Self {
Self::default()
}
/// Set column headers.
pub fn headers(mut self, h: &[&str]) -> Self {
self.headers = h.iter().map(|s| (*s).to_string()).collect();
// Initialize alignments and max_widths to match column count
self.alignments.resize(self.headers.len(), Align::Left);
self.max_widths.resize(self.headers.len(), None);
self
}
/// Add a data row.
pub fn add_row(&mut self, cells: Vec<StyledCell>) {
self.rows.push(cells);
}
/// Set alignment for a column.
pub fn align(mut self, col: usize, a: Align) -> Self {
if col < self.alignments.len() {
self.alignments[col] = a;
}
self
}
/// Set maximum width for a column (content will be truncated).
pub fn max_width(mut self, col: usize, w: usize) -> Self {
if col < self.max_widths.len() {
self.max_widths[col] = Some(w);
}
self
}
/// Render the table to a string.
pub fn render(&self) -> String {
if self.headers.is_empty() {
return String::new();
}
let col_count = self.headers.len();
let gap = " "; // 2-space gap between columns
// Compute column widths from content
let mut widths: Vec<usize> = self.headers.iter().map(|h| h.chars().count()).collect();
for row in &self.rows {
for (i, cell) in row.iter().enumerate() {
if i < col_count {
widths[i] = widths[i].max(cell.visible_width());
}
}
}
// Apply max_width constraints
for (i, max_w) in self.max_widths.iter().enumerate() {
if let Some(max) = max_w
&& i < widths.len()
{
widths[i] = widths[i].min(*max);
}
}
let mut out = String::new();
// Header row (bold)
let header_parts: Vec<String> = self
.headers
.iter()
.enumerate()
.map(|(i, h)| {
let w = widths.get(i).copied().unwrap_or(0);
let text = truncate(h, w);
pad_cell(
&text,
w,
self.alignments.get(i).copied().unwrap_or(Align::Left),
)
})
.collect();
out.push_str(&Theme::header().render(&header_parts.join(gap)));
out.push('\n');
// Separator
let total_width: usize =
widths.iter().sum::<usize>() + gap.len() * col_count.saturating_sub(1);
out.push_str(&Theme::dim().render(&"\u{2500}".repeat(total_width)));
out.push('\n');
// Data rows
for row in &self.rows {
let mut parts: Vec<String> = Vec::with_capacity(col_count);
for i in 0..col_count {
let w = widths.get(i).copied().unwrap_or(0);
let align = self.alignments.get(i).copied().unwrap_or(Align::Left);
if let Some(cell) = row.get(i) {
// Truncate the raw text first, then style it
let truncated = truncate(&cell.text, w);
let styled = match &cell.style {
Some(s) => s.render(&truncated),
None => truncated.clone(),
};
// Pad based on visible (unstyled) width
let visible_w = truncated.chars().count();
let padding = w.saturating_sub(visible_w);
match align {
Align::Left => {
parts.push(format!("{}{}", styled, " ".repeat(padding)));
}
Align::Right => {
parts.push(format!("{}{}", " ".repeat(padding), styled));
}
}
} else {
// Missing cell - empty padding
parts.push(" ".repeat(w));
}
}
out.push_str(&parts.join(gap));
out.push('\n');
}
out
}
}
/// Pad a plain string to a target width with the given alignment.
fn pad_cell(text: &str, width: usize, align: Align) -> String {
let visible = text.chars().count();
let padding = width.saturating_sub(visible);
match align {
Align::Left => format!("{}{}", text, " ".repeat(padding)),
Align::Right => format!("{}{}", " ".repeat(padding), text),
}
}
// ─── Tests ───────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
// ── format_number ──
#[test]
fn format_number_small_values() {
assert_eq!(format_number(0), "0");
assert_eq!(format_number(1), "1");
assert_eq!(format_number(100), "100");
assert_eq!(format_number(999), "999");
}
#[test]
fn format_number_thousands_separators() {
assert_eq!(format_number(1000), "1,000");
assert_eq!(format_number(12345), "12,345");
assert_eq!(format_number(1_234_567), "1,234,567");
}
#[test]
fn format_number_negative() {
assert_eq!(format_number(-1), "-1");
assert_eq!(format_number(-1000), "-1,000");
assert_eq!(format_number(-1_234_567), "-1,234,567");
}
// ── format_date ──
#[test]
fn format_date_extracts_date_part() {
// 2024-01-15T00:00:00Z in ms
let ms = 1_705_276_800_000;
let date = format_date(ms);
assert!(date.starts_with("2024-01-15"), "got: {date}");
}
#[test]
fn format_date_epoch_zero() {
assert_eq!(format_date(0), "1970-01-01");
}
// ── format_datetime ──
#[test]
fn format_datetime_basic() {
let ms = 1_705_312_800_000; // 2024-01-15T10:00:00Z
let dt = format_datetime(ms);
assert!(dt.starts_with("2024-01-15"), "got: {dt}");
assert!(dt.contains("10:00"), "got: {dt}");
}
// ── truncate ──
#[test]
fn truncate_within_limit() {
assert_eq!(truncate("hello", 10), "hello");
assert_eq!(truncate("hello", 5), "hello");
}
#[test]
fn truncate_over_limit() {
assert_eq!(truncate("hello world", 8), "hello...");
assert_eq!(truncate("abcdefghij", 7), "abcd...");
}
#[test]
fn truncate_very_short_max() {
assert_eq!(truncate("hello", 3), "hel");
assert_eq!(truncate("hello", 1), "h");
}
#[test]
fn truncate_unicode() {
// Multi-byte chars should not panic
let s = "caf\u{e9} latt\u{e9}";
let result = truncate(s, 6);
assert!(result.chars().count() <= 6, "got: {result}");
}
// ── wrap_indent ──
#[test]
fn wrap_indent_single_line() {
assert_eq!(wrap_indent("hello world", 80, " "), "hello world");
}
#[test]
fn wrap_indent_wraps_long_text() {
let result = wrap_indent("one two three four five", 10, " ");
assert!(result.contains('\n'), "expected wrapping, got: {result}");
// Continuation lines should have indent
let lines: Vec<&str> = result.lines().collect();
assert!(lines.len() >= 2);
assert!(
lines[1].starts_with(" "),
"expected indent on line 2: {:?}",
lines[1]
);
}
#[test]
fn wrap_indent_empty() {
assert_eq!(wrap_indent("", 80, " "), "");
}
// ── wrap_lines ──
#[test]
fn wrap_lines_single_line() {
assert_eq!(wrap_lines("hello world", 80), vec!["hello world"]);
}
#[test]
fn wrap_lines_wraps() {
let lines = wrap_lines("one two three four five", 10);
assert!(lines.len() >= 2, "expected wrapping, got: {lines:?}");
}
#[test]
fn wrap_lines_empty() {
let lines = wrap_lines("", 80);
assert!(lines.is_empty());
}
// ── section_divider ──
#[test]
fn section_divider_contains_title() {
let result = section_divider("Documents");
// Strip ANSI to check content
let plain = strip_ansi(&result);
assert!(plain.contains("Documents"), "got: {plain}");
assert!(
plain.contains("\u{2500}"),
"expected box-drawing chars in: {plain}"
);
}
// ── style_with_hex ──
#[test]
fn style_with_hex_none_returns_plain() {
assert_eq!(style_with_hex("hello", None), "hello");
}
#[test]
fn style_with_hex_invalid_returns_plain() {
assert_eq!(style_with_hex("hello", Some("")), "hello");
assert_eq!(style_with_hex("hello", Some("x")), "hello");
}
#[test]
fn style_with_hex_valid_contains_text() {
let result = style_with_hex("hello", Some("#ff0000"));
assert!(
result.contains("hello"),
"styled text should contain original: {result}"
);
}
// ── format_labels ──
#[test]
fn format_labels_empty() {
assert_eq!(format_labels(&[], 2), "");
}
#[test]
fn format_labels_single() {
assert_eq!(format_labels(&["bug".to_string()], 2), "[bug]");
}
#[test]
fn format_labels_multiple() {
let labels = vec!["bug".to_string(), "urgent".to_string()];
assert_eq!(format_labels(&labels, 2), "[bug, urgent]");
}
#[test]
fn format_labels_overflow() {
let labels = vec![
"bug".to_string(),
"urgent".to_string(),
"wip".to_string(),
"blocked".to_string(),
];
assert_eq!(format_labels(&labels, 2), "[bug, urgent +2]");
}
// ── format_duration_ms ──
#[test]
fn format_duration_ms_sub_second() {
assert_eq!(format_duration_ms(42), "42ms");
assert_eq!(format_duration_ms(999), "999ms");
}
#[test]
fn format_duration_ms_seconds() {
assert_eq!(format_duration_ms(1000), "1.0s");
assert_eq!(format_duration_ms(5200), "5.2s");
assert_eq!(format_duration_ms(12500), "12.5s");
}
// ── format_relative_time ──
// Note: these are harder to test deterministically since they depend on now_ms().
// We test the boundary behavior by checking that the function doesn't panic
// and returns reasonable-looking strings.
#[test]
fn format_relative_time_future() {
let future = now_ms() + 60_000;
assert_eq!(format_relative_time(future), "in the future");
}
#[test]
fn format_relative_time_just_now() {
let recent = now_ms() - 5_000;
assert_eq!(format_relative_time(recent), "just now");
}
#[test]
fn format_relative_time_minutes() {
let mins_ago = now_ms() - 300_000; // 5 minutes
let result = format_relative_time(mins_ago);
assert!(result.contains("min ago"), "got: {result}");
}
#[test]
fn format_relative_time_hours() {
let hours_ago = now_ms() - 7_200_000; // 2 hours
let result = format_relative_time(hours_ago);
assert!(result.contains("hours ago"), "got: {result}");
}
#[test]
fn format_relative_time_days() {
let days_ago = now_ms() - 172_800_000; // 2 days
let result = format_relative_time(days_ago);
assert!(result.contains("days ago"), "got: {result}");
}
// ── helpers ──
// ── Table ──
#[test]
fn table_empty_headers_returns_empty() {
let table = Table::new();
assert_eq!(table.render(), "");
}
#[test]
fn table_headers_only() {
let table = Table::new().headers(&["ID", "Name", "Status"]);
let result = table.render();
let plain = strip_ansi(&result);
assert!(plain.contains("ID"), "got: {plain}");
assert!(plain.contains("Name"), "got: {plain}");
assert!(plain.contains("Status"), "got: {plain}");
assert!(plain.contains("\u{2500}"), "expected separator in: {plain}");
// Should have header line + separator line
let lines: Vec<&str> = result.lines().collect();
assert_eq!(
lines.len(),
2,
"expected 2 lines (header + separator), got {}",
lines.len()
);
}
#[test]
fn table_single_row() {
let mut table = Table::new().headers(&["ID", "Title", "State"]);
table.add_row(vec![
StyledCell::plain("1"),
StyledCell::plain("Fix bug"),
StyledCell::plain("open"),
]);
let result = table.render();
let plain = strip_ansi(&result);
// 3 lines: header, separator, data
let lines: Vec<&str> = plain.lines().collect();
assert_eq!(
lines.len(),
3,
"expected 3 lines, got {}: {plain}",
lines.len()
);
assert!(lines[2].contains("Fix bug"), "data row missing: {plain}");
}
#[test]
fn table_multi_row() {
let mut table = Table::new().headers(&["ID", "Name"]);
table.add_row(vec![StyledCell::plain("1"), StyledCell::plain("Alice")]);
table.add_row(vec![StyledCell::plain("2"), StyledCell::plain("Bob")]);
table.add_row(vec![StyledCell::plain("3"), StyledCell::plain("Charlie")]);
let result = table.render();
let plain = strip_ansi(&result);
let lines: Vec<&str> = plain.lines().collect();
assert_eq!(
lines.len(),
5,
"expected 5 lines (1 header + 1 sep + 3 data): {plain}"
);
}
#[test]
fn table_right_align() {
let mut table = Table::new()
.headers(&["Name", "Score"])
.align(1, Align::Right);
table.add_row(vec![StyledCell::plain("Alice"), StyledCell::plain("95")]);
table.add_row(vec![StyledCell::plain("Bob"), StyledCell::plain("8")]);
let result = table.render();
let plain = strip_ansi(&result);
// The "8" should be right-aligned (padded with spaces on the left)
let data_lines: Vec<&str> = plain.lines().skip(2).collect();
let score_line = data_lines[1]; // Bob's line
// Score column should have leading space before "8"
assert!(
score_line.contains(" 8") || score_line.ends_with(" 8"),
"expected right-aligned '8': {score_line}"
);
}
#[test]
fn table_max_width_truncates() {
let mut table = Table::new().headers(&["ID", "Title"]).max_width(1, 10);
table.add_row(vec![
StyledCell::plain("1"),
StyledCell::plain("This is a very long title that should be truncated"),
]);
let result = table.render();
let plain = strip_ansi(&result);
// The title should be truncated to 10 chars (7 + "...")
assert!(plain.contains("..."), "expected truncation in: {plain}");
// The full title should NOT appear
assert!(
!plain.contains("should be truncated"),
"title not truncated: {plain}"
);
}
#[test]
fn table_styled_cells_dont_break_alignment() {
let mut table = Table::new().headers(&["Name", "Status"]);
table.add_row(vec![
StyledCell::plain("Alice"),
StyledCell::styled("open", Theme::success()),
]);
table.add_row(vec![
StyledCell::plain("Bob"),
StyledCell::styled("closed", Theme::dim()),
]);
let result = table.render();
let plain = strip_ansi(&result);
let lines: Vec<&str> = plain.lines().collect();
assert_eq!(lines.len(), 4); // header + sep + 2 rows
// Both data rows should exist
assert!(plain.contains("Alice"), "missing Alice: {plain}");
assert!(plain.contains("Bob"), "missing Bob: {plain}");
}
#[test]
fn table_missing_cells_padded() {
let mut table = Table::new().headers(&["A", "B", "C"]);
// Row with fewer cells than columns
table.add_row(vec![StyledCell::plain("1")]);
let result = table.render();
// Should not panic
let plain = strip_ansi(&result);
assert!(plain.contains("1"), "got: {plain}");
}
/// Strip ANSI escape codes (SGR sequences) for content assertions.
fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\x1b' {
// Consume `[`, then digits/semicolons, then the final letter
if chars.next() == Some('[') {
for c in chars.by_ref() {
if c.is_ascii_alphabetic() {
break;
}
}
}
} else {
out.push(c);
}
}
out
}
}

View File

@@ -4,7 +4,7 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::thread;
use std::time::Duration;
use tracing::{debug, error, info, warn};
use tracing::{debug, error, warn};
use uuid::Uuid;
use super::db::create_connection;
@@ -75,7 +75,7 @@ impl AppLock {
"INSERT INTO app_locks (name, owner, acquired_at, heartbeat_at) VALUES (?, ?, ?, ?)",
(&self.name, &self.owner, now, now),
)?;
info!(owner = %self.owner, "Lock acquired (new)");
debug!(owner = %self.owner, "Lock acquired (new)");
}
Some((existing_owner, acquired_at, heartbeat_at)) => {
let is_stale = now - heartbeat_at > self.stale_lock_ms;
@@ -85,7 +85,7 @@ impl AppLock {
"UPDATE app_locks SET owner = ?, acquired_at = ?, heartbeat_at = ? WHERE name = ?",
(&self.owner, now, now, &self.name),
)?;
info!(
debug!(
owner = %self.owner,
previous_owner = %existing_owner,
was_stale = is_stale,
@@ -125,7 +125,7 @@ impl AppLock {
"DELETE FROM app_locks WHERE name = ? AND owner = ?",
(&self.name, &self.owner),
) {
Ok(_) => info!(owner = %self.owner, "Lock released"),
Ok(_) => debug!(owner = %self.owner, "Lock released"),
Err(e) => error!(
owner = %self.owner,
error = %e,

View File

@@ -1,7 +1,45 @@
use std::fmt;
use std::fs;
use std::path::Path;
use tracing_subscriber::EnvFilter;
use tracing_subscriber::fmt::format::{FormatEvent, FormatFields};
use tracing_subscriber::registry::LookupSpan;
/// Compact stderr formatter: `HH:MM:SS LEVEL message key=value`
///
/// No span context, no full timestamps, no target — just the essentials.
/// The JSON file log is unaffected (it uses its own layer).
pub struct CompactHumanFormat;
impl<S, N> FormatEvent<S, N> for CompactHumanFormat
where
S: tracing::Subscriber + for<'a> LookupSpan<'a>,
N: for<'a> FormatFields<'a> + 'static,
{
fn format_event(
&self,
ctx: &tracing_subscriber::fmt::FmtContext<'_, S, N>,
mut writer: tracing_subscriber::fmt::format::Writer<'_>,
event: &tracing::Event<'_>,
) -> fmt::Result {
let now = chrono::Local::now();
let time = now.format("%H:%M:%S");
let level = *event.metadata().level();
let styled = match level {
tracing::Level::ERROR => console::style("ERROR").red().bold(),
tracing::Level::WARN => console::style(" WARN").yellow(),
tracing::Level::INFO => console::style(" INFO").green(),
tracing::Level::DEBUG => console::style("DEBUG").dim(),
tracing::Level::TRACE => console::style("TRACE").dim(),
};
write!(writer, "{time} {styled} ")?;
ctx.format_fields(writer.by_ref(), event)?;
writeln!(writer)
}
}
pub fn build_stderr_filter(verbose: u8, quiet: bool) -> EnvFilter {
if std::env::var("RUST_LOG").is_ok() {

View File

@@ -109,11 +109,13 @@ pub async fn ingest_issues(
result.issues_needing_discussion_sync = get_issues_needing_discussion_sync(conn, project_id)?;
info!(
fetched = result.fetched,
upserted = result.upserted,
labels_created = result.labels_created,
needing_sync = result.issues_needing_discussion_sync.len(),
"Issue ingestion complete"
summary = crate::ingestion::nonzero_summary(&[
("fetched", result.fetched),
("upserted", result.upserted),
("labels", result.labels_created),
("needing sync", result.issues_needing_discussion_sync.len()),
]),
"Issue ingestion"
);
Ok(result)

View File

@@ -50,7 +50,7 @@ pub async fn ingest_merge_requests(
if full_sync {
reset_sync_cursor(conn, project_id)?;
reset_discussion_watermarks(conn, project_id)?;
info!("Full sync: cursor and discussion watermarks reset");
debug!("Full sync: cursor and discussion watermarks reset");
}
let cursor = get_sync_cursor(conn, project_id)?;
@@ -122,12 +122,14 @@ pub async fn ingest_merge_requests(
}
info!(
fetched = result.fetched,
upserted = result.upserted,
labels_created = result.labels_created,
assignees_linked = result.assignees_linked,
reviewers_linked = result.reviewers_linked,
"MR ingestion complete"
summary = crate::ingestion::nonzero_summary(&[
("fetched", result.fetched),
("upserted", result.upserted),
("labels", result.labels_created),
("assignees", result.assignees_linked),
("reviewers", result.reviewers_linked),
]),
"MR ingestion"
);
Ok(result)

View File

@@ -14,6 +14,22 @@ pub use merge_requests::{
ingest_merge_requests,
};
pub use mr_discussions::{IngestMrDiscussionsResult, ingest_mr_discussions};
/// Format a set of named counters as a compact human-readable summary,
/// filtering out zero values and joining with middle-dot separators.
/// Returns `"nothing to update"` when all values are zero.
pub(crate) fn nonzero_summary(pairs: &[(&str, usize)]) -> String {
let parts: Vec<String> = pairs
.iter()
.filter(|(_, v)| *v > 0)
.map(|(k, v)| format!("{v} {k}"))
.collect();
if parts.is_empty() {
"nothing to update".to_string()
} else {
parts.join(" \u{b7} ")
}
}
pub use orchestrator::{
DrainResult, IngestMrProjectResult, IngestProjectResult, ProgressCallback, ProgressEvent,
ingest_project_issues, ingest_project_issues_with_progress, ingest_project_merge_requests,

View File

@@ -269,14 +269,14 @@ pub async fn ingest_mr_discussions(
}
info!(
mrs_processed = mrs.len(),
discussions_fetched = total_result.discussions_fetched,
discussions_upserted = total_result.discussions_upserted,
notes_upserted = total_result.notes_upserted,
notes_skipped = total_result.notes_skipped_bad_timestamp,
diffnotes = total_result.diffnotes_count,
pagination_succeeded = total_result.pagination_succeeded,
"MR discussion ingestion complete"
summary = crate::ingestion::nonzero_summary(&[
("MRs", mrs.len()),
("discussions", total_result.discussions_fetched),
("notes", total_result.notes_upserted),
("skipped", total_result.notes_skipped_bad_timestamp),
("diffnotes", total_result.diffnotes_count),
]),
"MR discussion ingestion"
);
Ok(total_result)

View File

@@ -207,7 +207,7 @@ pub async fn ingest_project_issues_with_progress(
}
if signal.is_cancelled() {
info!("Shutdown requested after status fetch, skipping DB write");
debug!("Shutdown requested after status fetch, skipping DB write");
emit(ProgressEvent::StatusEnrichmentComplete {
enriched: 0,
cleared: 0,
@@ -275,12 +275,12 @@ pub async fn ingest_project_issues_with_progress(
result.issues_skipped_discussion_sync = total_issues.saturating_sub(issues_needing_sync.len());
if signal.is_cancelled() {
info!("Shutdown requested, returning partial issue results");
debug!("Shutdown requested, returning partial issue results");
return Ok(result);
}
if issues_needing_sync.is_empty() {
info!("No issues need discussion sync");
debug!("No issues need discussion sync");
} else {
info!(
count = issues_needing_sync.len(),
@@ -314,7 +314,7 @@ pub async fn ingest_project_issues_with_progress(
}
if signal.is_cancelled() {
info!("Shutdown requested, returning partial issue results");
debug!("Shutdown requested, returning partial issue results");
return Ok(result);
}
@@ -348,16 +348,18 @@ pub async fn ingest_project_issues_with_progress(
}
info!(
issues_fetched = result.issues_fetched,
issues_upserted = result.issues_upserted,
labels_created = result.labels_created,
discussions_fetched = result.discussions_fetched,
notes_upserted = result.notes_upserted,
issues_synced = result.issues_synced_discussions,
issues_skipped = result.issues_skipped_discussion_sync,
resource_events_fetched = result.resource_events_fetched,
resource_events_failed = result.resource_events_failed,
"Project ingestion complete"
summary = crate::ingestion::nonzero_summary(&[
("fetched", result.issues_fetched),
("upserted", result.issues_upserted),
("labels", result.labels_created),
("discussions", result.discussions_fetched),
("notes", result.notes_upserted),
("synced", result.issues_synced_discussions),
("skipped", result.issues_skipped_discussion_sync),
("events", result.resource_events_fetched),
("event errors", result.resource_events_failed),
]),
"Project complete"
);
tracing::Span::current().record("items_processed", result.issues_upserted);
@@ -445,7 +447,7 @@ async fn sync_discussions_sequential(
for chunk in issues.chunks(batch_size) {
if signal.is_cancelled() {
info!("Shutdown requested during discussion sync, returning partial results");
debug!("Shutdown requested during discussion sync, returning partial results");
break;
}
for issue in chunk {
@@ -549,12 +551,12 @@ pub async fn ingest_project_merge_requests_with_progress(
result.mrs_skipped_discussion_sync = total_mrs.saturating_sub(mrs_needing_sync.len());
if signal.is_cancelled() {
info!("Shutdown requested, returning partial MR results");
debug!("Shutdown requested, returning partial MR results");
return Ok(result);
}
if mrs_needing_sync.is_empty() {
info!("No MRs need discussion sync");
debug!("No MRs need discussion sync");
} else {
info!(
count = mrs_needing_sync.len(),
@@ -592,7 +594,7 @@ pub async fn ingest_project_merge_requests_with_progress(
}
if signal.is_cancelled() {
info!("Shutdown requested, returning partial MR results");
debug!("Shutdown requested, returning partial MR results");
return Ok(result);
}
@@ -626,7 +628,7 @@ pub async fn ingest_project_merge_requests_with_progress(
}
if signal.is_cancelled() {
info!("Shutdown requested, returning partial MR results");
debug!("Shutdown requested, returning partial MR results");
return Ok(result);
}
@@ -679,7 +681,7 @@ pub async fn ingest_project_merge_requests_with_progress(
}
if signal.is_cancelled() {
info!("Shutdown requested, returning partial MR results");
debug!("Shutdown requested, returning partial MR results");
return Ok(result);
}
@@ -704,21 +706,23 @@ pub async fn ingest_project_merge_requests_with_progress(
}
info!(
mrs_fetched = result.mrs_fetched,
mrs_upserted = result.mrs_upserted,
labels_created = result.labels_created,
discussions_fetched = result.discussions_fetched,
notes_upserted = result.notes_upserted,
diffnotes = result.diffnotes_count,
mrs_synced = result.mrs_synced_discussions,
mrs_skipped = result.mrs_skipped_discussion_sync,
resource_events_fetched = result.resource_events_fetched,
resource_events_failed = result.resource_events_failed,
closes_issues_fetched = result.closes_issues_fetched,
closes_issues_failed = result.closes_issues_failed,
mr_diffs_fetched = result.mr_diffs_fetched,
mr_diffs_failed = result.mr_diffs_failed,
"MR project ingestion complete"
summary = crate::ingestion::nonzero_summary(&[
("fetched", result.mrs_fetched),
("upserted", result.mrs_upserted),
("labels", result.labels_created),
("discussions", result.discussions_fetched),
("notes", result.notes_upserted),
("diffnotes", result.diffnotes_count),
("synced", result.mrs_synced_discussions),
("skipped", result.mrs_skipped_discussion_sync),
("events", result.resource_events_fetched),
("event errors", result.resource_events_failed),
("closes", result.closes_issues_fetched),
("close errors", result.closes_issues_failed),
("diffs", result.mr_diffs_fetched),
("diff errors", result.mr_diffs_failed),
]),
"MR project complete"
);
tracing::Span::current().record("items_processed", result.mrs_upserted);
@@ -750,7 +754,7 @@ async fn sync_mr_discussions_sequential(
for chunk in mrs.chunks(batch_size) {
if signal.is_cancelled() {
info!("Shutdown requested during MR discussion sync, returning partial results");
debug!("Shutdown requested during MR discussion sync, returning partial results");
break;
}
let prefetch_futures = chunk.iter().map(|mr| {
@@ -947,7 +951,7 @@ async fn drain_resource_events(
loop {
if signal.is_cancelled() {
info!("Shutdown requested during resource events drain, returning partial results");
debug!("Shutdown requested during resource events drain, returning partial results");
break;
}
@@ -1269,7 +1273,7 @@ async fn drain_mr_closes_issues(
loop {
if signal.is_cancelled() {
info!("Shutdown requested during closes_issues drain, returning partial results");
debug!("Shutdown requested during closes_issues drain, returning partial results");
break;
}
@@ -1526,7 +1530,7 @@ async fn drain_mr_diffs(
loop {
if signal.is_cancelled() {
info!("Shutdown requested during mr_diffs drain, returning partial results");
debug!("Shutdown requested during mr_diffs drain, returning partial results");
break;
}

View File

@@ -1,5 +1,4 @@
use clap::Parser;
use console::style;
use dialoguer::{Confirm, Input};
use serde::Serialize;
use strsim::jaro_winkler;
@@ -26,6 +25,7 @@ use lore::cli::commands::{
run_list_issues, run_list_mrs, run_search, run_show_issue, run_show_mr, run_stats, run_sync,
run_sync_status, run_timeline, run_who,
};
use lore::cli::render::{ColorMode, LoreRenderer, Theme};
use lore::cli::robot::{RobotMeta, strip_schemas};
use lore::cli::{
Cli, Commands, CountArgs, EmbedArgs, GenerateDocsArgs, IngestArgs, IssuesArgs, MrsArgs,
@@ -116,7 +116,7 @@ async fn main() {
}
} else {
let stderr_layer = tracing_subscriber::fmt::layer()
.with_target(false)
.event_format(logging::CompactHumanFormat)
.with_writer(lore::cli::progress::SuspendingWriter)
.with_filter(stderr_filter);
@@ -146,13 +146,23 @@ async fn main() {
// I1: Respect NO_COLOR convention (https://no-color.org/)
if std::env::var("NO_COLOR").is_ok_and(|v| !v.is_empty()) {
LoreRenderer::init(ColorMode::Never);
console::set_colors_enabled(false);
} else {
match cli.color.as_str() {
"never" => console::set_colors_enabled(false),
"always" => console::set_colors_enabled(true),
"auto" => {}
"never" => {
LoreRenderer::init(ColorMode::Never);
console::set_colors_enabled(false);
}
"always" => {
LoreRenderer::init(ColorMode::Always);
console::set_colors_enabled(true);
}
"auto" => {
LoreRenderer::init(ColorMode::Auto);
}
other => {
LoreRenderer::init(ColorMode::Auto);
eprintln!("Warning: unknown color mode '{}', using auto", other);
}
}
@@ -277,8 +287,9 @@ async fn main() {
} else {
eprintln!(
"{}",
style("warning: 'lore list' is deprecated, use 'lore issues' or 'lore mrs'")
.yellow()
Theme::warning().render(
"warning: 'lore list' is deprecated, use 'lore issues' or 'lore mrs'"
)
);
}
handle_list_compat(
@@ -318,11 +329,10 @@ async fn main() {
} else {
eprintln!(
"{}",
style(format!(
Theme::warning().render(&format!(
"warning: 'lore show' is deprecated, use 'lore {}s {}'",
entity, iid
))
.yellow()
);
}
handle_show_compat(
@@ -342,7 +352,8 @@ async fn main() {
} else {
eprintln!(
"{}",
style("warning: 'lore auth-test' is deprecated, use 'lore auth'").yellow()
Theme::warning()
.render("warning: 'lore auth-test' is deprecated, use 'lore auth'")
);
}
handle_auth_test(cli.config.as_deref(), robot_mode).await
@@ -355,7 +366,8 @@ async fn main() {
} else {
eprintln!(
"{}",
style("warning: 'lore sync-status' is deprecated, use 'lore status'").yellow()
Theme::warning()
.render("warning: 'lore sync-status' is deprecated, use 'lore status'")
);
}
handle_sync_status_cmd(cli.config.as_deref(), robot_mode).await
@@ -397,9 +409,20 @@ fn handle_error(e: Box<dyn std::error::Error>, robot_mode: bool) -> ! {
);
std::process::exit(gi_error.exit_code());
} else {
eprintln!("{} {}", style("Error:").red(), gi_error);
eprintln!("{} {}", Theme::error().render("Error:"), gi_error);
if let Some(suggestion) = gi_error.suggestion() {
eprintln!("{} {}", style("Hint:").yellow(), suggestion);
eprintln!("{} {}", Theme::warning().render("Hint:"), suggestion);
}
let actions = gi_error.actions();
if !actions.is_empty() {
eprintln!();
for action in &actions {
eprintln!(
" {} {}",
Theme::dim().render("$"),
Theme::bold().render(action)
);
}
}
std::process::exit(gi_error.exit_code());
}
@@ -420,7 +443,7 @@ fn handle_error(e: Box<dyn std::error::Error>, robot_mode: bool) -> ! {
})
);
} else {
eprintln!("{} {}", style("Error:").red(), e);
eprintln!("{} {}", Theme::error().render("Error:"), e);
}
std::process::exit(1);
}
@@ -459,7 +482,7 @@ fn emit_correction_warnings(result: &CorrectionResult, robot_mode: bool) {
for c in &result.corrections {
eprintln!(
"{} {}",
style("Auto-corrected:").yellow(),
Theme::warning().render("Auto-corrected:"),
autocorrect::format_teaching_note(c)
);
}
@@ -984,7 +1007,7 @@ async fn handle_ingest(
if !robot_mode && !quiet {
println!(
"{}",
style("Ingesting all content (issues + merge requests)...").blue()
Theme::info().render("Ingesting all content (issues + merge requests)...")
);
println!();
}
@@ -1027,7 +1050,7 @@ async fn handle_ingest(
if !robot_mode {
eprintln!(
"{}",
style("Interrupted by Ctrl+C. Partial data has been saved.").yellow()
Theme::warning().render("Interrupted by Ctrl+C. Partial data has been saved.")
);
}
Ok(())
@@ -1037,6 +1060,12 @@ async fn handle_ingest(
let total_items: usize = stages.iter().map(|s| s.items_processed).sum();
let total_errors: usize = stages.iter().map(|s| s.errors).sum();
let _ = recorder.succeed(&recorder_conn, &stages, total_items, total_errors);
if !robot_mode && !quiet {
eprintln!(
"{}",
Theme::dim().render("Hint: Run 'lore generate-docs' to update searchable documents, then 'lore embed' for vectors.")
);
}
Ok(())
}
Err(e) => {
@@ -1311,11 +1340,10 @@ async fn handle_init(
if non_interactive {
eprintln!(
"{}",
style(format!(
Theme::error().render(&format!(
"Config file exists at {}. Use --force to overwrite.",
config_path.display()
))
.red()
);
std::process::exit(2);
}
@@ -1329,7 +1357,7 @@ async fn handle_init(
.interact()?;
if !confirm {
println!("{}", style("Cancelled.").yellow());
println!("{}", Theme::warning().render("Cancelled."));
std::process::exit(2);
}
confirmed_overwrite = true;
@@ -1408,7 +1436,7 @@ async fn handle_init(
None
};
println!("{}", style("\nValidating configuration...").blue());
println!("{}", Theme::info().render("Validating configuration..."));
let result = run_init(
InitInputs {
@@ -1427,35 +1455,43 @@ async fn handle_init(
println!(
"{}",
style(format!(
"\n Authenticated as @{} ({})",
Theme::success().render(&format!(
"\n\u{2713} Authenticated as @{} ({})",
result.user.username, result.user.name
))
.green()
);
for project in &result.projects {
println!(
"{}",
style(format!(" {} ({})", project.path, project.name)).green()
Theme::success().render(&format!("\u{2713} {} ({})", project.path, project.name))
);
}
if let Some(ref dp) = result.default_project {
println!("{}", style(format!("✓ Default project: {dp}")).green());
println!(
"{}",
Theme::success().render(&format!("\u{2713} Default project: {dp}"))
);
}
println!(
"{}",
style(format!("\n✓ Config written to {}", result.config_path)).green()
Theme::success().render(&format!(
"\n\u{2713} Config written to {}",
result.config_path
))
);
println!(
"{}",
style(format!("✓ Database initialized at {}", result.data_dir)).green()
Theme::success().render(&format!(
"\u{2713} Database initialized at {}",
result.data_dir
))
);
println!(
"{}",
style("\nSetup complete! Run 'lore doctor' to verify.").blue()
Theme::info().render("\nSetup complete! Run 'lore doctor' to verify.")
);
Ok(())
@@ -1518,9 +1554,9 @@ async fn handle_auth_test(
})
);
} else {
eprintln!("{} {}", style("Error:").red(), e);
eprintln!("{} {}", Theme::error().render("Error:"), e);
if let Some(suggestion) = e.suggestion() {
eprintln!("{} {}", style("Hint:").yellow(), suggestion);
eprintln!("{} {}", Theme::warning().render("Hint:"), suggestion);
}
}
std::process::exit(e.exit_code());
@@ -1647,7 +1683,7 @@ fn handle_backup(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>> {
} else {
eprintln!(
"{} The 'backup' command is not yet implemented.",
style("Error:").red()
Theme::error().render("Error:")
);
}
std::process::exit(1);
@@ -1669,7 +1705,7 @@ fn handle_reset(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>> {
} else {
eprintln!(
"{} The 'reset' command is not yet implemented.",
style("Error:").red()
Theme::error().render("Error:")
);
}
std::process::exit(1);
@@ -1728,11 +1764,11 @@ async fn handle_migrate(
} else {
eprintln!(
"{}",
style(format!("Database not found at {}", db_path.display())).red()
Theme::error().render(&format!("Database not found at {}", db_path.display()))
);
eprintln!(
"{}",
style("Run 'lore init' first to create the database.").yellow()
Theme::warning().render("Run 'lore init' first to create the database.")
);
}
std::process::exit(10);
@@ -1744,7 +1780,7 @@ async fn handle_migrate(
if !robot_mode {
println!(
"{}",
style(format!("Current schema version: {}", before_version)).blue()
Theme::info().render(&format!("Current schema version: {}", before_version))
);
}
@@ -1768,14 +1804,16 @@ async fn handle_migrate(
} else if after_version > before_version {
println!(
"{}",
style(format!(
Theme::success().render(&format!(
"Migrations applied: {} -> {}",
before_version, after_version
))
.green()
);
} else {
println!("{}", style("Database is already up to date.").green());
println!(
"{}",
Theme::success().render("Database is already up to date.")
);
}
Ok(())
@@ -1813,7 +1851,7 @@ async fn handle_timeline(
.map(String::from),
since: args.since,
depth: args.depth,
expand_mentions: args.expand_mentions,
no_mentions: args.no_mentions,
limit: args.limit,
max_seeds: args.max_seeds,
max_entities: args.max_entities,
@@ -1828,7 +1866,7 @@ async fn handle_timeline(
&result,
result.total_events_before_limit,
params.depth,
params.expand_mentions,
!params.no_mentions,
args.fields.as_deref(),
);
} else {
@@ -1900,10 +1938,25 @@ async fn handle_generate_docs(
let project = config.effective_project(args.project.as_deref());
let result = run_generate_docs(&config, args.full, project, None)?;
let elapsed = start.elapsed();
if robot_mode {
print_generate_docs_json(&result, start.elapsed().as_millis() as u64);
print_generate_docs_json(&result, elapsed.as_millis() as u64);
} else {
print_generate_docs(&result);
if elapsed.as_secs() >= 1 {
eprintln!(
"{}",
Theme::dim().render(&format!(" Done in {:.1}s", elapsed.as_secs_f64()))
);
}
if result.regenerated > 0 {
eprintln!(
"{}",
Theme::dim().render(
"Hint: Run 'lore embed' to update vector embeddings for changed documents."
)
);
}
}
Ok(())
}
@@ -1913,6 +1966,10 @@ async fn handle_embed(
args: EmbedArgs,
robot_mode: bool,
) -> Result<(), Box<dyn std::error::Error>> {
use indicatif::{ProgressBar, ProgressStyle};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
let start = std::time::Instant::now();
let config = Config::load(config_override)?;
let full = args.full && !args.no_full;
@@ -1928,11 +1985,45 @@ async fn handle_embed(
std::process::exit(130);
});
let result = run_embed(&config, full, retry_failed, None, &signal).await?;
let embed_bar = if robot_mode {
ProgressBar::hidden()
} else {
let b = lore::cli::progress::multi().add(ProgressBar::new(0));
b.set_style(
ProgressStyle::default_bar()
.template(" {spinner:.blue} Generating embeddings [{bar:30.cyan/dim}] {pos}/{len}")
.unwrap()
.progress_chars("=> "),
);
b
};
let bar_clone = embed_bar.clone();
let tick_started = Arc::new(AtomicBool::new(false));
let tick_clone = Arc::clone(&tick_started);
let progress_cb: Box<dyn Fn(usize, usize)> = Box::new(move |processed, total| {
if total > 0 {
if !tick_clone.swap(true, Ordering::Relaxed) {
bar_clone.enable_steady_tick(std::time::Duration::from_millis(100));
}
bar_clone.set_length(total as u64);
bar_clone.set_position(processed as u64);
}
});
let result = run_embed(&config, full, retry_failed, Some(progress_cb), &signal).await?;
embed_bar.finish_and_clear();
let elapsed = start.elapsed();
if robot_mode {
print_embed_json(&result, start.elapsed().as_millis() as u64);
print_embed_json(&result, elapsed.as_millis() as u64);
} else {
print_embed(&result);
if elapsed.as_secs() >= 1 {
eprintln!(
"{}",
Theme::dim().render(&format!(" Done in {:.1}s", elapsed.as_secs_f64()))
);
}
}
Ok(())
}
@@ -1962,7 +2053,7 @@ async fn handle_sync_cmd(
dry_run,
};
// For dry_run, skip recording and just show the preview
// For dry run, skip recording and just show the preview
if dry_run {
let signal = ShutdownSignal::new();
run_sync(&config, options, None, &signal).await?;
@@ -2003,13 +2094,13 @@ async fn handle_sync_cmd(
eprintln!();
eprintln!(
"{}",
console::style("Interrupted by Ctrl+C. Partial results:").yellow()
Theme::warning().render("Interrupted by Ctrl+C. Partial results:")
);
print_sync(&result, elapsed, Some(metrics));
if released > 0 {
eprintln!(
"{}",
console::style(format!("Released {released} locked jobs")).dim()
Theme::dim().render(&format!("Released {released} locked jobs"))
);
}
}
@@ -2121,9 +2212,9 @@ async fn handle_health(
} else {
let status = |ok: bool| {
if ok {
style("pass").green()
Theme::success().render("pass")
} else {
style("FAIL").red()
Theme::error().render("FAIL")
}
};
println!(
@@ -2135,13 +2226,13 @@ async fn handle_health(
println!("Schema: {} (v{})", status(schema_current), schema_version);
println!();
if healthy {
println!("{}", style("Healthy").green().bold());
println!("{}", Theme::success().bold().render("Healthy"));
} else {
println!(
"{}",
style("Unhealthy - run 'lore doctor' for details")
.red()
Theme::error()
.bold()
.render("Unhealthy - run 'lore doctor' for details")
);
}
}
@@ -2243,7 +2334,7 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
},
"sync": {
"description": "Full sync pipeline: ingest -> generate-docs -> embed",
"flags": ["--full", "--no-full", "--force", "--no-force", "--no-embed", "--no-docs", "--no-events", "--dry-run", "--no-dry-run"],
"flags": ["--full", "--no-full", "--force", "--no-force", "--no-embed", "--no-docs", "--no-events", "--no-file-changes", "--dry-run", "--no-dry-run"],
"example": "lore --robot sync",
"response_schema": {
"ok": "bool",
@@ -2382,7 +2473,7 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
},
"timeline": {
"description": "Chronological timeline of events matching a keyword query or entity reference",
"flags": ["<QUERY>", "-p/--project", "--since <duration>", "--depth <n>", "--expand-mentions", "-n/--limit", "--fields <list>", "--max-seeds", "--max-entities", "--max-evidence"],
"flags": ["<QUERY>", "-p/--project", "--since <duration>", "--depth <n>", "--no-mentions", "-n/--limit", "--fields <list>", "--max-seeds", "--max-entities", "--max-evidence"],
"query_syntax": {
"search": "Any text -> hybrid search seeding (FTS + vector)",
"entity_direct": "issue:N, i:N, mr:N, m:N -> direct entity seeding (no search, no Ollama)"
@@ -2397,7 +2488,7 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
},
"who": {
"description": "People intelligence: experts, workload, active discussions, overlap, review patterns",
"flags": ["<target>", "--path <path>", "--active", "--overlap <path>", "--reviews", "--since <duration>", "-p/--project", "-n/--limit", "--fields <list>"],
"flags": ["<target>", "--path <path>", "--active", "--overlap <path>", "--reviews", "--since <duration>", "-p/--project", "-n/--limit", "--fields <list>", "--detail", "--no-detail", "--as-of <date>", "--explain-score", "--include-bots", "--all-history"],
"modes": {
"expert": "lore who <file-path> -- Who knows about this area? (also: --path for root files)",
"workload": "lore who <username> -- What is someone working on?",
@@ -2423,6 +2514,16 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
"active_minimal": ["entity_type", "iid", "title", "participants"]
}
},
"drift": {
"description": "Detect discussion divergence from original issue intent",
"flags": ["<entity_type: issues>", "<IID>", "--threshold <0.0-1.0>", "-p/--project <path>"],
"example": "lore --robot drift issues 42 --threshold 0.4",
"response_schema": {
"ok": "bool",
"data": {"entity_type": "string", "iid": "int", "title": "string", "threshold": "float", "divergent_discussions": "[{discussion_id:string, similarity:float, snippet:string}]"},
"meta": {"elapsed_ms": "int"}
}
},
"notes": {
"description": "List notes from discussions with rich filtering",
"flags": ["--limit/-n <N>", "--author/-a <username>", "--note-type <type>", "--contains <text>", "--for-issue <iid>", "--for-mr <iid>", "-p/--project <path>", "--since <period>", "--until <period>", "--path <filepath>", "--resolution <any|unresolved|resolved>", "--sort <created|updated>", "--asc", "--include-system", "--note-id <id>", "--gitlab-note-id <id>", "--discussion-id <id>", "--format <table|json|jsonl|csv>", "--fields <list|minimal>", "--open"],
@@ -2511,7 +2612,7 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
"temporal_intelligence": [
"lore --robot sync",
"lore --robot timeline '<keyword>' --since 30d",
"lore --robot timeline '<keyword>' --depth 2 --expand-mentions"
"lore --robot timeline '<keyword>' --depth 2"
],
"people_intelligence": [
"lore --robot who src/path/to/feature/",
@@ -2762,7 +2863,10 @@ async fn handle_list_compat(
Ok(())
}
_ => {
eprintln!("{}", style(format!("Unknown entity: {entity}")).red());
eprintln!(
"{}",
Theme::error().render(&format!("Unknown entity: {entity}"))
);
std::process::exit(1);
}
}
@@ -2799,7 +2903,10 @@ async fn handle_show_compat(
Ok(())
}
_ => {
eprintln!("{}", style(format!("Unknown entity: {entity}")).red());
eprintln!(
"{}",
Theme::error().render(&format!("Unknown entity: {entity}"))
);
std::process::exit(1);
}
}

View File

@@ -257,7 +257,8 @@ mod tests {
#[test]
fn test_raw_mode_leading_wildcard_falls_back_to_safe() {
let result = to_fts_query("* OR auth", FtsQueryMode::Raw);
assert_eq!(result, "\"*\" \"OR\" \"auth\"");
// Falls back to Safe mode; OR is an FTS5 operator so it passes through unquoted
assert_eq!(result, "\"*\" OR \"auth\"");
let result = to_fts_query("*", FtsQueryMode::Raw);
assert_eq!(result, "\"*\"");