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" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" 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]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.43" version = "0.4.43"
@@ -239,14 +256,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]] [[package]]
name = "comfy-table" name = "colored"
version = "7.2.2" version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47" checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
dependencies = [ dependencies = [
"crossterm", "lazy_static",
"unicode-segmentation", "windows-sys 0.52.0",
"unicode-width",
] ]
[[package]] [[package]]
@@ -258,10 +274,19 @@ dependencies = [
"encode_unicode", "encode_unicode",
"libc", "libc",
"once_cell", "once_cell",
"unicode-width", "unicode-width 0.2.2",
"windows-sys 0.61.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]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.9.4" version = "0.9.4"
@@ -319,9 +344,13 @@ checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"crossterm_winapi", "crossterm_winapi",
"derive_more",
"document-features", "document-features",
"mio",
"parking_lot", "parking_lot",
"rustix", "rustix",
"signal-hook",
"signal-hook-mio",
"winapi", "winapi",
] ]
@@ -371,6 +400,28 @@ dependencies = [
"powerfmt", "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]] [[package]]
name = "dialoguer" name = "dialoguer"
version = "0.12.0" version = "0.12.0"
@@ -976,7 +1027,7 @@ checksum = "9375e112e4b463ec1b1c6c011953545c65a30164fbab5b581df32b3abf0dcb88"
dependencies = [ dependencies = [
"console", "console",
"portable-atomic", "portable-atomic",
"unicode-width", "unicode-width 0.2.2",
"unit-prefix", "unit-prefix",
"web-time", "web-time",
] ]
@@ -1109,10 +1160,10 @@ name = "lore"
version = "0.8.2" version = "0.8.2"
dependencies = [ dependencies = [
"async-stream", "async-stream",
"charmed-lipgloss",
"chrono", "chrono",
"clap", "clap",
"clap_complete", "clap_complete",
"comfy-table",
"console", "console",
"dialoguer", "dialoguer",
"dirs", "dirs",
@@ -1181,6 +1232,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
dependencies = [ dependencies = [
"libc", "libc",
"log",
"wasi", "wasi",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@@ -1574,6 +1626,15 @@ dependencies = [
"sqlite-wasm-rs", "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]] [[package]]
name = "rustix" name = "rustix"
version = "1.1.3" version = "1.1.3"
@@ -1670,6 +1731,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "semver"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.228" version = "1.0.228"
@@ -1713,6 +1780,15 @@ dependencies = [
"zmij", "zmij",
] ]
[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "serde_urlencoded" name = "serde_urlencoded"
version = "0.7.1" version = "0.7.1"
@@ -1757,6 +1833,27 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 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]] [[package]]
name = "signal-hook-registry" name = "signal-hook-registry"
version = "1.4.5" version = "1.4.5"
@@ -2028,6 +2125,47 @@ dependencies = [
"tokio", "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]] [[package]]
name = "tower" name = "tower"
version = "0.5.3" version = "0.5.3"
@@ -2183,6 +2321,12 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]] [[package]]
name = "unicode-width" name = "unicode-width"
version = "0.2.2" version = "0.2.2"
@@ -2611,6 +2755,15 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "winnow"
version = "0.7.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "wiremock" name = "wiremock"
version = "0.6.5" version = "0.6.5"

View File

@@ -25,7 +25,7 @@ clap_complete = "4"
dialoguer = "0.12" dialoguer = "0.12"
console = "0.16" console = "0.16"
indicatif = "0.18" indicatif = "0.18"
comfy-table = "7" lipgloss = { package = "charmed-lipgloss", version = "0.1", default-features = false, features = ["native"] }
open = "5" open = "5"
# HTTP # 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" -p group/repo # Scoped to a project
lore timeline "auth" --since 30d # Only recent events lore timeline "auth" --since 30d # Only recent events
lore timeline "migration" --depth 2 # Deeper cross-reference expansion 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 "deploy" -n 50 # Limit event count
lore timeline "auth" --max-seeds 5 # Fewer seed entities 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) | | `-p` / `--project` | all | Scope to a specific project (fuzzy match) |
| `--since` | none | Only events after this date (7d, 2w, 6m, YYYY-MM-DD) | | `--since` | none | Only events after this date (7d, 2w, 6m, YYYY-MM-DD) |
| `--depth` | `1` | Cross-reference expansion depth (0 = seeds only) | | `--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 | | `-n` / `--limit` | `100` | Maximum events to display |
| `--max-seeds` | `10` | Maximum seed entities from search | | `--max-seeds` | `10` | Maximum seed entities from search |
| `--max-entities` | `50` | Maximum entities discovered via cross-references | | `--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. 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. 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. 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). 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", "--project",
"--since", "--since",
"--depth", "--depth",
"--expand-mentions", "--no-mentions",
"--limit", "--limit",
"--fields", "--fields",
"--max-seeds", "--max-seeds",

View File

@@ -1,4 +1,4 @@
use console::style; use crate::cli::render::{self, Theme};
use rusqlite::Connection; use rusqlite::Connection;
use serde::Serialize; 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)] #[derive(Serialize)]
struct CountJsonOutput { struct CountJsonOutput {
ok: bool, ok: bool,
@@ -284,10 +263,10 @@ pub fn print_event_count_json(counts: &EventCounts, elapsed_ms: u64) {
pub fn print_event_count(counts: &EventCounts) { pub fn print_event_count(counts: &EventCounts) {
println!( println!(
"{:<20} {:>8} {:>8} {:>8}", "{:<20} {:>8} {:>8} {:>8}",
style("Event Type").cyan().bold(), Theme::info().bold().render("Event Type"),
style("Issues").bold(), Theme::bold().render("Issues"),
style("MRs").bold(), Theme::bold().render("MRs"),
style("Total").bold() Theme::bold().render("Total")
); );
let state_total = counts.state_issue + counts.state_mr; let state_total = counts.state_issue + counts.state_mr;
@@ -297,33 +276,33 @@ pub fn print_event_count(counts: &EventCounts) {
println!( println!(
"{:<20} {:>8} {:>8} {:>8}", "{:<20} {:>8} {:>8} {:>8}",
"State events", "State events",
format_number(counts.state_issue as i64), render::format_number(counts.state_issue as i64),
format_number(counts.state_mr as i64), render::format_number(counts.state_mr as i64),
format_number(state_total as i64) render::format_number(state_total as i64)
); );
println!( println!(
"{:<20} {:>8} {:>8} {:>8}", "{:<20} {:>8} {:>8} {:>8}",
"Label events", "Label events",
format_number(counts.label_issue as i64), render::format_number(counts.label_issue as i64),
format_number(counts.label_mr as i64), render::format_number(counts.label_mr as i64),
format_number(label_total as i64) render::format_number(label_total as i64)
); );
println!( println!(
"{:<20} {:>8} {:>8} {:>8}", "{:<20} {:>8} {:>8} {:>8}",
"Milestone events", "Milestone events",
format_number(counts.milestone_issue as i64), render::format_number(counts.milestone_issue as i64),
format_number(counts.milestone_mr as i64), render::format_number(counts.milestone_mr as i64),
format_number(milestone_total as i64) render::format_number(milestone_total as i64)
); );
let total_issues = counts.state_issue + counts.label_issue + counts.milestone_issue; let total_issues = counts.state_issue + counts.label_issue + counts.milestone_issue;
let total_mrs = counts.state_mr + counts.label_mr + counts.milestone_mr; let total_mrs = counts.state_mr + counts.label_mr + counts.milestone_mr;
println!( println!(
"{:<20} {:>8} {:>8} {:>8}", "{:<20} {:>8} {:>8} {:>8}",
style("Total").bold(), Theme::bold().render("Total"),
format_number(total_issues as i64), render::format_number(total_issues as i64),
format_number(total_mrs as i64), render::format_number(total_mrs as i64),
style(format_number(counts.total() as i64)).bold() 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) { 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 { if let Some(system_count) = result.system_count {
println!( println!(
"{}: {} {}", "{}: {} {}",
style(&result.entity).cyan(), Theme::info().render(&result.entity),
style(&count_str).bold(), Theme::bold().render(&count_str),
style(format!( Theme::dim().render(&format!(
"(excluding {} system)", "(excluding {} system)",
format_number(system_count) render::format_number(system_count)
)) ))
.dim()
); );
} else { } else {
println!( println!(
"{}: {}", "{}: {}",
style(&result.entity).cyan(), Theme::info().render(&result.entity),
style(&count_str).bold() Theme::bold().render(&count_str)
); );
} }
if let Some(breakdown) = &result.state_breakdown { 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 { 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 if let Some(locked) = breakdown.locked
&& locked > 0 && locked > 0
{ {
println!(" locked: {}", format_number(locked)); println!(" locked: {}", render::format_number(locked));
} }
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use crate::cli::render;
#[test] #[test]
fn format_number_handles_small_numbers() { fn format_number_handles_small_numbers() {
assert_eq!(format_number(0), "0"); assert_eq!(render::format_number(0), "0");
assert_eq!(format_number(1), "1"); assert_eq!(render::format_number(1), "1");
assert_eq!(format_number(100), "100"); assert_eq!(render::format_number(100), "100");
assert_eq!(format_number(999), "999"); assert_eq!(render::format_number(999), "999");
} }
#[test] #[test]
fn format_number_adds_thousands_separators() { fn format_number_adds_thousands_separators() {
assert_eq!(format_number(1000), "1,000"); assert_eq!(render::format_number(1000), "1,000");
assert_eq!(format_number(12345), "12,345"); assert_eq!(render::format_number(12345), "12,345");
assert_eq!(format_number(1234567), "1,234,567"); 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 serde::Serialize;
use crate::core::config::Config; use crate::core::config::Config;
@@ -544,32 +544,33 @@ pub fn print_doctor_results(result: &DoctorResult) {
if result.success { if result.success {
let ollama_ok = result.checks.ollama.result.status == CheckStatus::Ok; let ollama_ok = result.checks.ollama.result.status == CheckStatus::Ok;
if ollama_ok { if ollama_ok {
println!("{}", style("Status: Ready").green()); println!("{}", Theme::success().render("Status: Ready"));
} else { } else {
println!( println!(
"{} {}", "{} {}",
style("Status: Ready").green(), Theme::success().render("Status: Ready"),
style("(lexical search available, semantic search requires Ollama)").yellow() Theme::warning()
.render("(lexical search available, semantic search requires Ollama)")
); );
} }
} else { } else {
println!("{}", style("Status: Not ready").red()); println!("{}", Theme::error().render("Status: Not ready"));
} }
println!(); println!();
} }
fn print_check(name: &str, result: &CheckResult) { fn print_check(name: &str, result: &CheckResult) {
let symbol = match result.status { let symbol = match result.status {
CheckStatus::Ok => style("").green(), CheckStatus::Ok => Theme::success().render("\u{2713}"),
CheckStatus::Warning => style("").yellow(), CheckStatus::Warning => Theme::warning().render("\u{26a0}"),
CheckStatus::Error => style("").red(), CheckStatus::Error => Theme::error().render("\u{2717}"),
}; };
let message = result.message.as_deref().unwrap_or(""); let message = result.message.as_deref().unwrap_or("");
let message_styled = match result.status { let message_styled = match result.status {
CheckStatus::Ok => message.to_string(), CheckStatus::Ok => message.to_string(),
CheckStatus::Warning => style(message).yellow().to_string(), CheckStatus::Warning => Theme::warning().render(message),
CheckStatus::Error => style(message).red().to_string(), CheckStatus::Error => Theme::error().render(message),
}; };
println!(" {symbol} {:<10} {message_styled}", name); println!(" {symbol} {:<10} {message_styled}", name);

View File

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

View File

@@ -1,4 +1,4 @@
use console::style; use crate::cli::render::Theme;
use serde::Serialize; use serde::Serialize;
use crate::Config; use crate::Config;
@@ -96,16 +96,31 @@ pub async fn run_embed(
} }
pub fn print_embed(result: &EmbedCommandResult) { 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!( println!(
" Embedded: {} documents ({} chunks)", "\n {} {} documents ({} chunks)",
result.docs_embedded, result.chunks_embedded Theme::success().bold().render("Embedded"),
Theme::bold().render(&result.docs_embedded.to_string()),
result.chunks_embedded
); );
if result.failed > 0 { if result.failed > 0 {
println!(" Failed: {}", style(result.failed).red()); println!(
" {}",
Theme::error().render(&format!("{} failed", result.failed))
);
} }
if result.skipped > 0 { 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 rusqlite::Connection;
use serde::Serialize; use serde::Serialize;
use tracing::info; use tracing::info;
@@ -185,19 +185,40 @@ pub fn print_generate_docs(result: &GenerateDocsResult) {
} else { } else {
"incremental" "incremental"
}; };
if result.regenerated == 0 && result.errored == 0 {
println!(
"\n {} no documents to update ({})",
Theme::success().bold().render("Docs"),
mode
);
return;
}
// Headline
println!( println!(
"{} Document generation complete ({})", "\n {} {} documents ({})",
style("done").green().bold(), Theme::success().bold().render("Generated"),
Theme::bold().render(&result.regenerated.to_string()),
mode mode
); );
if result.full_mode { // Detail line: compact middle-dot format, zero-suppressed
println!(" Seeded: {}", result.seeded); 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 { 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::Arc;
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::atomic::{AtomicUsize, Ordering};
use console::style; use crate::cli::render::Theme;
use indicatif::{ProgressBar, ProgressStyle}; use indicatif::{ProgressBar, ProgressStyle};
use rusqlite::Connection; use rusqlite::Connection;
use serde::Serialize; use serde::Serialize;
@@ -293,7 +293,7 @@ async fn run_ingest_inner(
if display.show_text { if display.show_text {
println!( 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 { for (local_project_id, _, path) in &projects {
@@ -341,7 +341,10 @@ async fn run_ingest_inner(
"merge requests" "merge requests"
}; };
if display.show_text { if display.show_text {
println!("{}", style(format!("Ingesting {type_label}...")).blue()); println!(
"{}",
Theme::info().render(&format!("Ingesting {type_label}..."))
);
println!(); println!();
} }
@@ -746,7 +749,7 @@ fn print_issue_project_summary(path: &str, result: &IngestProjectResult) {
println!( println!(
" {}: {} issues fetched{}", " {}: {} issues fetched{}",
style(path).cyan(), Theme::info().render(path),
result.issues_upserted, result.issues_upserted,
labels_str labels_str
); );
@@ -761,7 +764,7 @@ fn print_issue_project_summary(path: &str, result: &IngestProjectResult) {
if result.issues_skipped_discussion_sync > 0 { if result.issues_skipped_discussion_sync > 0 {
println!( println!(
" {} unchanged issues (discussion sync skipped)", " {} 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!( println!(
" {}: {} MRs fetched{}{}", " {}: {} MRs fetched{}{}",
style(path).cyan(), Theme::info().render(path),
result.mrs_upserted, result.mrs_upserted,
labels_str, labels_str,
assignees_str assignees_str
@@ -808,7 +811,7 @@ fn print_mr_project_summary(path: &str, result: &IngestMrProjectResult) {
if result.mrs_skipped_discussion_sync > 0 { if result.mrs_skipped_discussion_sync > 0 {
println!( println!(
" {} unchanged MRs (discussion sync skipped)", " {} 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" { if result.resource_type == "issues" {
println!( println!(
"{}", "{}",
style(format!( Theme::success().render(&format!(
"Total: {} issues, {} discussions, {} notes", "Total: {} issues, {} discussions, {} notes",
result.issues_upserted, result.discussions_fetched, result.notes_upserted result.issues_upserted, result.discussions_fetched, result.notes_upserted
)) ))
.green()
); );
if result.issues_skipped_discussion_sync > 0 { if result.issues_skipped_discussion_sync > 0 {
println!( println!(
"{}", "{}",
style(format!( Theme::dim().render(&format!(
"Skipped discussion sync for {} unchanged issues.", "Skipped discussion sync for {} unchanged issues.",
result.issues_skipped_discussion_sync result.issues_skipped_discussion_sync
)) ))
.dim()
); );
} }
} else { } else {
@@ -968,24 +969,22 @@ pub fn print_ingest_summary(result: &IngestResult) {
println!( println!(
"{}", "{}",
style(format!( Theme::success().render(&format!(
"Total: {} MRs, {} discussions, {} notes{}", "Total: {} MRs, {} discussions, {} notes{}",
result.mrs_upserted, result.mrs_upserted,
result.discussions_fetched, result.discussions_fetched,
result.notes_upserted, result.notes_upserted,
diffnotes_str diffnotes_str
)) ))
.green()
); );
if result.mrs_skipped_discussion_sync > 0 { if result.mrs_skipped_discussion_sync > 0 {
println!( println!(
"{}", "{}",
style(format!( Theme::dim().render(&format!(
"Skipped discussion sync for {} unchanged MRs.", "Skipped discussion sync for {} unchanged MRs.",
result.mrs_skipped_discussion_sync 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) { pub fn print_dry_run_preview(preview: &DryRunPreview) {
println!( println!(
"{} {}", "{} {}",
style("Dry Run Preview").cyan().bold(), Theme::info().bold().render("Dry Run Preview"),
style("(no changes will be made)").yellow() Theme::warning().render("(no changes will be made)")
); );
println!(); println!();
@@ -1017,27 +1016,31 @@ pub fn print_dry_run_preview(preview: &DryRunPreview) {
"merge requests" "merge requests"
}; };
println!(" Resource type: {}", style(type_label).white().bold()); println!(" Resource type: {}", Theme::bold().render(type_label));
println!( println!(
" Sync mode: {}", " Sync mode: {}",
if preview.sync_mode == "full" { 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 { } else {
style("incremental (only changes since last sync)").green() Theme::success().render("incremental (only changes since last sync)")
} }
); );
println!(" Projects: {}", preview.projects.len()); println!(" Projects: {}", preview.projects.len());
println!(); println!();
println!("{}", style("Projects to sync:").cyan().bold()); println!("{}", Theme::info().bold().render("Projects to sync:"));
for project in &preview.projects { for project in &preview.projects {
let sync_status = if !project.has_cursor { let sync_status = if !project.has_cursor {
style("initial sync").yellow() Theme::warning().render("initial sync")
} else { } 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); println!(" Existing {}: {}", type_label, project.existing_count);
if let Some(ref last_synced) = project.last_synced { 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 rusqlite::Connection;
use serde::Serialize; 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::path_resolver::escape_like as note_escape_like;
use crate::core::paths::get_db_path; use crate::core::paths::get_db_path;
use crate::core::project::resolve_project; use crate::core::project::resolve_project;
use crate::core::time::{ms_to_iso, now_ms, parse_since}; use crate::core::time::{ms_to_iso, 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 })
}
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct IssueListRow { pub struct IssueListRow {
@@ -669,60 +637,6 @@ fn query_mrs(conn: &Connection, filters: &MrListFilters) -> Result<MrListResult>
Ok(MrListResult { mrs, total_count }) 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 { fn format_assignees(assignees: &[String]) -> String {
if assignees.is_empty() { if assignees.is_empty() {
return "-".to_string(); return "-".to_string();
@@ -732,7 +646,7 @@ fn format_assignees(assignees: &[String]) -> String {
let shown: Vec<String> = assignees let shown: Vec<String> = assignees
.iter() .iter()
.take(max_shown) .take(max_shown)
.map(|s| format!("@{}", truncate_with_ellipsis(s, 10))) .map(|s| format!("@{}", render::truncate(s, 10)))
.collect(); .collect();
let overflow = assignees.len().saturating_sub(max_shown); 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 { fn format_branches(target: &str, source: &str, max_width: usize) -> String {
let full = format!("{} <- {}", target, source); let full = format!("{} <- {}", target, source);
truncate_with_ellipsis(&full, max_width) render::truncate(&full, max_width)
} }
pub fn print_list_issues(result: &ListResult) { 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 has_any_status = result.issues.iter().any(|i| i.status_name.is_some());
let mut header = vec![ let mut headers = vec!["IID", "Title", "State"];
Cell::new("IID").add_attribute(Attribute::Bold),
Cell::new("Title").add_attribute(Attribute::Bold),
Cell::new("State").add_attribute(Attribute::Bold),
];
if has_any_status { if has_any_status {
header.push(Cell::new("Status").add_attribute(Attribute::Bold)); headers.push("Status");
} }
header.extend([ headers.extend(["Assignee", "Labels", "Disc", "Updated"]);
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),
]);
let mut table = Table::new(); let mut table = LoreTable::new().headers(&headers).align(0, Align::Right);
table
.set_content_arrangement(ContentArrangement::Dynamic)
.set_header(header);
for issue in &result.issues { for issue in &result.issues {
let title = truncate_with_ellipsis(&issue.title, 45); let title = render::truncate(&issue.title, 45);
let relative_time = format_relative_time(issue.updated_at); let relative_time = render::format_relative_time(issue.updated_at);
let labels = format_labels(&issue.labels, 2); let labels = render::format_labels(&issue.labels, 2);
let assignee = format_assignees(&issue.assignees); let assignee = format_assignees(&issue.assignees);
let discussions = format_discussions(issue.discussion_count, issue.unresolved_count); let discussions = format_discussions(issue.discussion_count, issue.unresolved_count);
let state_cell = if issue.state == "opened" { let state_cell = if issue.state == "opened" {
colored_cell(&issue.state, Color::Green) StyledCell::styled(&issue.state, Theme::success())
} else { } else {
colored_cell(&issue.state, Color::DarkGrey) StyledCell::styled(&issue.state, Theme::dim())
}; };
let mut row = vec![ let mut row = vec![
colored_cell(format!("#{}", issue.iid), Color::Cyan), StyledCell::styled(format!("#{}", issue.iid), Theme::info()),
Cell::new(title), StyledCell::plain(title),
state_cell, state_cell,
]; ];
if has_any_status { if has_any_status {
match &issue.status_name { match &issue.status_name {
Some(status) => { 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 => { None => {
row.push(Cell::new("")); row.push(StyledCell::plain(""));
} }
} }
} }
row.extend([ row.extend([
colored_cell(assignee, Color::Magenta), StyledCell::styled(assignee, Theme::accent()),
colored_cell(labels, Color::Yellow), StyledCell::styled(labels, Theme::warning()),
Cell::new(discussions), StyledCell::plain(discussions),
colored_cell(relative_time, Color::DarkGrey), StyledCell::styled(relative_time, Theme::dim()),
]); ]);
table.add_row(row); table.add_row(row);
} }
println!("{table}"); println!("{}", table.render());
} }
pub fn print_list_issues_json(result: &ListResult, elapsed_ms: u64, fields: Option<&[String]>) { 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 result.total_count
); );
let mut table = Table::new(); let mut table = LoreTable::new()
table .headers(&[
.set_content_arrangement(ContentArrangement::Dynamic) "IID", "Title", "State", "Author", "Branches", "Disc", "Updated",
.set_header(vec![ ])
Cell::new("IID").add_attribute(Attribute::Bold), .align(0, Align::Right);
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),
]);
for mr in &result.mrs { for mr in &result.mrs {
let title = if mr.draft { let title = if mr.draft {
format!("[DRAFT] {}", truncate_with_ellipsis(&mr.title, 38)) format!("[DRAFT] {}", render::truncate(&mr.title, 38))
} else { } 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 branches = format_branches(&mr.target_branch, &mr.source_branch, 25);
let discussions = format_discussions(mr.discussion_count, mr.unresolved_count); let discussions = format_discussions(mr.discussion_count, mr.unresolved_count);
let state_cell = match mr.state.as_str() { let state_cell = match mr.state.as_str() {
"opened" => colored_cell(&mr.state, Color::Green), "opened" => StyledCell::styled(&mr.state, Theme::success()),
"merged" => colored_cell(&mr.state, Color::Magenta), "merged" => StyledCell::styled(&mr.state, Theme::accent()),
"closed" => colored_cell(&mr.state, Color::Red), "closed" => StyledCell::styled(&mr.state, Theme::error()),
"locked" => colored_cell(&mr.state, Color::Yellow), "locked" => StyledCell::styled(&mr.state, Theme::warning()),
_ => colored_cell(&mr.state, Color::DarkGrey), _ => StyledCell::styled(&mr.state, Theme::dim()),
}; };
table.add_row(vec![ table.add_row(vec![
colored_cell(format!("!{}", mr.iid), Color::Cyan), StyledCell::styled(format!("!{}", mr.iid), Theme::info()),
Cell::new(title), StyledCell::plain(title),
state_cell, state_cell,
colored_cell( StyledCell::styled(
format!("@{}", truncate_with_ellipsis(&mr.author_username, 12)), format!("@{}", render::truncate(&mr.author_username, 12)),
Color::Magenta, Theme::accent(),
), ),
colored_cell(branches, Color::Blue), StyledCell::styled(branches, Theme::info()),
Cell::new(discussions), StyledCell::plain(discussions),
colored_cell(relative_time, Color::DarkGrey), 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]>) { 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 result.total_count
); );
let mut table = Table::new(); let mut table = LoreTable::new()
table .headers(&[
.set_content_arrangement(ContentArrangement::Dynamic) "ID",
.set_header(vec![ "Author",
Cell::new("ID").add_attribute(Attribute::Bold), "Type",
Cell::new("Author").add_attribute(Attribute::Bold), "Body",
Cell::new("Type").add_attribute(Attribute::Bold), "Path:Line",
Cell::new("Body").add_attribute(Attribute::Bold), "Parent",
Cell::new("Path:Line").add_attribute(Attribute::Bold), "Created",
Cell::new("Parent").add_attribute(Attribute::Bold), ])
Cell::new("Created").add_attribute(Attribute::Bold), .align(0, Align::Right);
]);
for note in &result.notes { for note in &result.notes {
let body = note let body = note
@@ -1037,24 +934,24 @@ pub fn print_list_notes(result: &NoteListResult) {
.unwrap_or_default(); .unwrap_or_default();
let path = format_note_path(note.position_new_path.as_deref(), note.position_new_line); 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 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()); let note_type = format_note_type(note.note_type.as_deref());
table.add_row(vec![ table.add_row(vec![
colored_cell(note.gitlab_id, Color::Cyan), StyledCell::styled(note.gitlab_id.to_string(), Theme::info()),
colored_cell( StyledCell::styled(
format!("@{}", truncate_with_ellipsis(&note.author_username, 12)), format!("@{}", render::truncate(&note.author_username, 12)),
Color::Magenta, Theme::accent(),
), ),
Cell::new(note_type), StyledCell::plain(note_type),
Cell::new(body), StyledCell::plain(body),
Cell::new(path), StyledCell::plain(path),
Cell::new(parent), StyledCell::plain(parent),
colored_cell(relative_time, Color::DarkGrey), 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]>) { pub fn print_list_notes_json(result: &NoteListResult, elapsed_ms: u64, fields: Option<&[String]>) {

View File

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

View File

@@ -1,6 +1,6 @@
use std::collections::HashMap; use std::collections::HashMap;
use console::style; use crate::cli::render::Theme;
use serde::Serialize; use serde::Serialize;
use crate::Config; use crate::Config;
@@ -309,68 +309,94 @@ fn parse_json_array(json: &str) -> Vec<String> {
.collect() .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) { pub fn print_search_results(response: &SearchResponse) {
if !response.warnings.is_empty() { if !response.warnings.is_empty() {
for w in &response.warnings { for w in &response.warnings {
eprintln!("{} {}", style("Warning:").yellow(), w); eprintln!("{} {}", Theme::warning().render("Warning:"), w);
} }
} }
if response.results.is_empty() { 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; return;
} }
println!( println!(
"{} results for '{}' ({})", "\n {} results for '{}' {}",
response.total_results, Theme::bold().render(&response.total_results.to_string()),
style(&response.query).bold(), Theme::bold().render(&response.query),
response.mode Theme::dim().render(&format!("({})", response.mode))
); );
println!(); println!();
for (i, result) in response.results.iter().enumerate() { for (i, result) in response.results.iter().enumerate() {
let type_prefix = match result.source_type.as_str() { let type_badge = match result.source_type.as_str() {
"issue" => "Issue", "issue" => Theme::info().render("issue"),
"merge_request" => "MR", "merge_request" => Theme::accent().render("mr"),
"discussion" => "Discussion", "discussion" => Theme::info().render("disc"),
"note" => "Note", "note" => Theme::info().render("note"),
_ => &result.source_type, _ => Theme::dim().render(&result.source_type),
}; };
// Title line: rank, type badge, title
println!( println!(
"[{}] {} - {} (score: {:.2})", " {} {} {}",
i + 1, Theme::dim().render(&format!("{:>2}.", i + 1)),
style(type_prefix).cyan(), type_badge,
result.title, Theme::bold().render(&result.title)
result.score
); );
if let Some(ref url) = result.url { // Metadata: project, author, labels — compact middle-dot line
println!(" {}", style(url).dim()); 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() { 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>", ""); // Snippet with proper highlighting
println!(" {}", style(clean_snippet).dim()); let rendered = render_snippet(&result.snippet);
println!(" {}", Theme::dim().render(&rendered));
if let Some(ref explain) = result.explain { if let Some(ref explain) = result.explain {
println!( println!(
" {} vector_rank={} fts_rank={} rrf_score={:.6}", " {} vec={} fts={} rrf={:.4}",
style("[explain]").magenta(), Theme::accent().render("explain"),
explain explain
.vector_rank .vector_rank
.map(|r| r.to_string()) .map(|r| r.to_string())

View File

@@ -1,4 +1,4 @@
use console::style; use crate::cli::render::{self, Theme};
use rusqlite::Connection; use rusqlite::Connection;
use serde::Serialize; 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 { fn format_date(ms: i64) -> String {
let iso = ms_to_iso(ms); render::format_date(ms)
iso.split('T').next().unwrap_or(&iso).to_string()
} }
fn wrap_text(text: &str, width: usize, indent: &str) -> String { fn wrap_text(text: &str, width: usize, indent: &str) -> String {
let mut result = String::new(); render::wrap_indent(text, width, indent)
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
} }
pub fn print_show_issue(issue: &IssueDetail) { pub fn print_show_issue(issue: &IssueDetail) {
let header = format!("Issue #{}: {}", issue.iid, issue.title); let header = format!("Issue #{}: {}", issue.iid, issue.title);
println!("{}", style(&header).bold()); println!("{}", Theme::bold().render(&header));
println!("{}", "".repeat(header.len().min(80))); println!("{}", "\u{2501}".repeat(header.len().min(80)));
println!(); println!();
println!("Ref: {}", style(&issue.references_full).dim()); println!("Ref: {}", Theme::dim().render(&issue.references_full));
println!("Project: {}", style(&issue.project_path).cyan()); println!("Project: {}", Theme::info().render(&issue.project_path));
let state_styled = if issue.state == "opened" { let state_styled = if issue.state == "opened" {
style(&issue.state).green() Theme::success().render(&issue.state)
} else { } else {
style(&issue.state).dim() Theme::dim().render(&issue.state)
}; };
println!("State: {}", state_styled); println!("State: {}", state_styled);
if issue.confidential { if issue.confidential {
println!(" {}", style("CONFIDENTIAL").red().bold()); println!(" {}", Theme::error().bold().render("CONFIDENTIAL"));
} }
if let Some(status) = &issue.status_name { if let Some(status) = &issue.status_name {
println!( println!(
"Status: {}", "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() { if issue.labels.is_empty() {
println!("Labels: {}", style("(none)").dim()); println!("Labels: {}", Theme::dim().render("(none)"));
} else { } else {
println!("Labels: {}", issue.labels.join(", ")); println!("Labels: {}", issue.labels.join(", "));
} }
if !issue.closing_merge_requests.is_empty() { if !issue.closing_merge_requests.is_empty() {
println!(); println!();
println!("{}", style("Development:").bold()); println!("{}", Theme::bold().render("Development:"));
for mr in &issue.closing_merge_requests { for mr in &issue.closing_merge_requests {
let state_indicator = match mr.state.as_str() { let state_indicator = match mr.state.as_str() {
"merged" => style(&mr.state).green(), "merged" => Theme::success().render(&mr.state),
"opened" => style(&mr.state).cyan(), "opened" => Theme::info().render(&mr.state),
"closed" => style(&mr.state).red(), "closed" => Theme::error().render(&mr.state),
_ => style(&mr.state).dim(), _ => Theme::dim().render(&mr.state),
}; };
println!(" !{} {} ({})", mr.iid, mr.title, state_indicator); println!(" !{} {} ({})", mr.iid, mr.title, state_indicator);
} }
} }
if let Some(url) = &issue.web_url { if let Some(url) = &issue.web_url {
println!("URL: {}", style(url).dim()); println!("URL: {}", Theme::dim().render(url));
} }
println!(); println!();
println!("{}", style("Description:").bold()); println!("{}", Theme::bold().render("Description:"));
if let Some(desc) = &issue.description { if let Some(desc) = &issue.description {
let wrapped = wrap_text(desc, 76, " "); let wrapped = wrap_text(desc, 76, " ");
println!(" {}", wrapped); println!(" {}", wrapped);
} else { } else {
println!(" {}", style("(no description)").dim()); println!(" {}", Theme::dim().render("(no description)"));
} }
println!(); println!();
@@ -747,11 +719,11 @@ pub fn print_show_issue(issue: &IssueDetail) {
.collect(); .collect();
if user_discussions.is_empty() { if user_discussions.is_empty() {
println!("{}", style("Discussions: (none)").dim()); println!("{}", Theme::dim().render("Discussions: (none)"));
} else { } else {
println!( println!(
"{}", "{}",
style(format!("Discussions ({}):", user_discussions.len())).bold() Theme::bold().render(&format!("Discussions ({}):", user_discussions.len()))
); );
println!(); println!();
@@ -762,7 +734,7 @@ pub fn print_show_issue(issue: &IssueDetail) {
if let Some(first_note) = user_notes.first() { if let Some(first_note) = user_notes.first() {
println!( println!(
" {} ({}):", " {} ({}):",
style(format!("@{}", first_note.author_username)).cyan(), Theme::info().render(&format!("@{}", first_note.author_username)),
format_date(first_note.created_at) format_date(first_note.created_at)
); );
let wrapped = wrap_text(&first_note.body, 72, " "); 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) { for reply in user_notes.iter().skip(1) {
println!( println!(
" {} ({}):", " {} ({}):",
style(format!("@{}", reply.author_username)).cyan(), Theme::info().render(&format!("@{}", reply.author_username)),
format_date(reply.created_at) format_date(reply.created_at)
); );
let wrapped = wrap_text(&reply.body, 68, " "); 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) { pub fn print_show_mr(mr: &MrDetail) {
let draft_prefix = if mr.draft { "[Draft] " } else { "" }; let draft_prefix = if mr.draft { "[Draft] " } else { "" };
let header = format!("MR !{}: {}{}", mr.iid, draft_prefix, mr.title); let header = format!("MR !{}: {}{}", mr.iid, draft_prefix, mr.title);
println!("{}", style(&header).bold()); println!("{}", Theme::bold().render(&header));
println!("{}", "".repeat(header.len().min(80))); println!("{}", "\u{2501}".repeat(header.len().min(80)));
println!(); println!();
println!("Project: {}", style(&mr.project_path).cyan()); println!("Project: {}", Theme::info().render(&mr.project_path));
let state_styled = match mr.state.as_str() { let state_styled = match mr.state.as_str() {
"opened" => style(&mr.state).green(), "opened" => Theme::success().render(&mr.state),
"merged" => style(&mr.state).magenta(), "merged" => Theme::accent().render(&mr.state),
"closed" => style(&mr.state).red(), "closed" => Theme::error().render(&mr.state),
_ => style(&mr.state).dim(), _ => Theme::dim().render(&mr.state),
}; };
println!("State: {}", state_styled); println!("State: {}", state_styled);
println!( println!(
"Branches: {} -> {}", "Branches: {} -> {}",
style(&mr.source_branch).cyan(), Theme::info().render(&mr.source_branch),
style(&mr.target_branch).yellow() Theme::warning().render(&mr.target_branch)
); );
println!("Author: @{}", mr.author_username); println!("Author: @{}", mr.author_username);
@@ -843,23 +815,23 @@ pub fn print_show_mr(mr: &MrDetail) {
} }
if mr.labels.is_empty() { if mr.labels.is_empty() {
println!("Labels: {}", style("(none)").dim()); println!("Labels: {}", Theme::dim().render("(none)"));
} else { } else {
println!("Labels: {}", mr.labels.join(", ")); println!("Labels: {}", mr.labels.join(", "));
} }
if let Some(url) = &mr.web_url { if let Some(url) = &mr.web_url {
println!("URL: {}", style(url).dim()); println!("URL: {}", Theme::dim().render(url));
} }
println!(); println!();
println!("{}", style("Description:").bold()); println!("{}", Theme::bold().render("Description:"));
if let Some(desc) = &mr.description { if let Some(desc) = &mr.description {
let wrapped = wrap_text(desc, 76, " "); let wrapped = wrap_text(desc, 76, " ");
println!(" {}", wrapped); println!(" {}", wrapped);
} else { } else {
println!(" {}", style("(no description)").dim()); println!(" {}", Theme::dim().render("(no description)"));
} }
println!(); println!();
@@ -871,11 +843,11 @@ pub fn print_show_mr(mr: &MrDetail) {
.collect(); .collect();
if user_discussions.is_empty() { if user_discussions.is_empty() {
println!("{}", style("Discussions: (none)").dim()); println!("{}", Theme::dim().render("Discussions: (none)"));
} else { } else {
println!( println!(
"{}", "{}",
style(format!("Discussions ({}):", user_discussions.len())).bold() Theme::bold().render(&format!("Discussions ({}):", user_discussions.len()))
); );
println!(); println!();
@@ -890,7 +862,7 @@ pub fn print_show_mr(mr: &MrDetail) {
println!( println!(
" {} ({}):", " {} ({}):",
style(format!("@{}", first_note.author_username)).cyan(), Theme::info().render(&format!("@{}", first_note.author_username)),
format_date(first_note.created_at) format_date(first_note.created_at)
); );
let wrapped = wrap_text(&first_note.body, 72, " "); 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) { for reply in user_notes.iter().skip(1) {
println!( println!(
" {} ({}):", " {} ({}):",
style(format!("@{}", reply.author_username)).cyan(), Theme::info().render(&format!("@{}", reply.author_username)),
format_date(reply.created_at) format_date(reply.created_at)
); );
let wrapped = wrap_text(&reply.body, 68, " "); let wrapped = wrap_text(&reply.body, 68, " ");
@@ -926,39 +898,13 @@ fn print_diff_position(pos: &DiffNotePosition) {
println!( println!(
" {} {}{}", " {} {}{}",
style("📍").dim(), Theme::dim().render("\u{1f4cd}"),
style(file_path).yellow(), Theme::warning().render(file_path),
style(line_str).dim() 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)] #[derive(Serialize)]
pub struct IssueDetailJson { pub struct IssueDetailJson {
pub id: i64, pub id: i64,
@@ -1387,8 +1333,9 @@ mod tests {
#[test] #[test]
fn test_ansi256_from_rgb() { fn test_ansi256_from_rgb() {
assert_eq!(ansi256_from_rgb(0, 0, 0), 16); // Moved to render.rs — keeping basic hex sanity check
assert_eq!(ansi256_from_rgb(255, 255, 255), 231); let result = render::style_with_hex("test", Some("#ff0000"));
assert!(!result.is_empty());
} }
#[test] #[test]

View File

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

View File

@@ -1,4 +1,4 @@
use console::style; use crate::cli::render::{self, Theme};
use indicatif::{ProgressBar, ProgressStyle}; use indicatif::{ProgressBar, ProgressStyle};
use serde::Serialize; use serde::Serialize;
use std::sync::Arc; use std::sync::Arc;
@@ -240,7 +240,7 @@ pub async fn run_sync(
embed_bar.finish_and_clear(); embed_bar.finish_and_clear();
spinner.finish_and_clear(); spinner.finish_and_clear();
if !options.robot_mode { 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"); warn!(error = %e, "Embedding stage failed (Ollama may be unavailable), continuing");
} }
@@ -273,37 +273,58 @@ pub fn print_sync(
elapsed: std::time::Duration, elapsed: std::time::Duration,
metrics: Option<&MetricsLayer>, metrics: Option<&MetricsLayer>,
) { ) {
println!("{} Sync complete:", style("done").green().bold(),); // Headline: what happened, how long
println!(" Issues updated: {}", result.issues_updated);
println!(" MRs updated: {}", result.mrs_updated);
println!( println!(
" Discussions fetched: {}", "\n {} {} issues and {} MRs in {:.1}s",
result.discussions_fetched 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); // Detail: supporting counts, compact middle-dot format, zero-suppressed
if result.mr_diffs_failed > 0 { let mut details: Vec<String> = Vec::new();
println!(" MR diffs failed: {}", result.mr_diffs_failed); if result.discussions_fetched > 0 {
} details.push(format!("{} discussions", result.discussions_fetched));
} }
if result.resource_events_fetched > 0 || result.resource_events_failed > 0 { if result.resource_events_fetched > 0 {
println!( details.push(format!("{} events", result.resource_events_fetched));
" Resource events fetched: {}",
result.resource_events_fetched
);
if result.resource_events_failed > 0 {
println!(
" Resource events failed: {}",
result.resource_events_failed
);
}
} }
println!( if result.mr_diffs_fetched > 0 {
" Documents regenerated: {}", details.push(format!("{} diffs", result.mr_diffs_fetched));
result.documents_regenerated }
); if !details.is_empty() {
println!(" Documents embedded: {}", result.documents_embedded); println!(" {}", Theme::dim().render(&details.join(" \u{b7} ")));
println!(" Elapsed: {:.1}s", elapsed.as_secs_f64()); }
// 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 { if let Some(metrics) = metrics {
let stages = metrics.extract_timings(); 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]) { fn print_timing_summary(stages: &[StageTiming]) {
println!(); section("Timing");
println!("{}", style("Stage timing:").dim());
for stage in stages { for stage in stages {
for sub in &stage.sub_stages { for sub in &stage.sub_stages {
print_stage_line(sub, 1); print_stage_line(sub, 1);
@@ -331,29 +355,25 @@ fn print_stage_line(stage: &StageTiming, depth: usize) {
stage.name.clone() stage.name.clone()
}; };
let pad_width = 30_usize.saturating_sub(indent.len() + name.len()); 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 { 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 stage.errors > 0 {
if !suffix.is_empty() { parts.push(Theme::error().render(&format!("{} errors", stage.errors)));
suffix.push_str(", ");
}
suffix.push_str(&format!("{} errors", stage.errors));
} }
if stage.rate_limit_hits > 0 { if stage.rate_limit_hits > 0 {
if !suffix.is_empty() { parts.push(Theme::warning().render(&format!("{} rate limits", stage.rate_limit_hits)));
suffix.push_str(", ");
}
suffix.push_str(&format!("{} rate limits", stage.rate_limit_hits));
} }
let time_str = format!("{:.1}s", stage.elapsed_ms as f64 / 1000.0); if parts.is_empty() {
if suffix.is_empty() {
println!("{indent}{name} {dots} {time_str}"); println!("{indent}{name} {dots} {time_str}");
} else { } else {
let suffix = parts.join(" \u{b7} ");
println!("{indent}{name} {dots} {time_str} ({suffix})"); 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) { pub fn print_sync_dry_run(result: &SyncDryRunResult) {
println!( println!(
"{} {}", "\n {} {}",
style("Sync Dry Run Preview").cyan().bold(), Theme::info().bold().render("Dry run"),
style("(no changes will be made)").yellow() Theme::dim().render("(no changes will be made)")
); );
println!();
println!("{}", style("Stage 1: Issues Ingestion").white().bold()); print_dry_run_entity("Issues", &result.issues_preview);
println!( print_dry_run_entity("Merge Requests", &result.mrs_preview);
" 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!();
// Pipeline stages
section("Pipeline");
let mut stages: Vec<String> = Vec::new();
if result.would_generate_docs { if result.would_generate_docs {
println!( stages.push("generate-docs".to_string());
"{} {}",
style("Stage 3: Document Generation").white().bold(),
style("(would run)").green()
);
} else { } else {
println!( stages.push(Theme::dim().render("generate-docs (skip)"));
"{} {}",
style("Stage 3: Document Generation").white().bold(),
style("(skipped)").dim()
);
} }
if result.would_embed { if result.would_embed {
println!( stages.push("embed".to_string());
"{} {}",
style("Stage 4: Embedding").white().bold(),
style("(would run)").green()
);
} else { } else {
println!( stages.push(Theme::dim().render("embed (skip)"));
"{} {}", }
style("Stage 4: Embedding").white().bold(), println!(" {}", stages.join(" \u{b7} "));
style("(skipped)").dim() }
);
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 rusqlite::Connection;
use serde::Serialize; 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)] #[derive(Serialize)]
struct SyncStatusJsonOutput { struct SyncStatusJsonOutput {
ok: bool, ok: bool,
@@ -293,14 +272,14 @@ pub fn print_sync_status_json(result: &SyncStatusResult, elapsed_ms: u64) {
} }
pub fn print_sync_status(result: &SyncStatusResult) { pub fn print_sync_status(result: &SyncStatusResult) {
println!("{}", style("Recent Sync Runs").bold().underlined()); println!("{}", Theme::bold().underline().render("Recent Sync Runs"));
println!(); println!();
if result.runs.is_empty() { if result.runs.is_empty() {
println!(" {}", style("No sync runs recorded yet.").dim()); println!(" {}", Theme::dim().render("No sync runs recorded yet."));
println!( println!(
" {}", " {}",
style("Run 'lore sync' or 'lore ingest' to start.").dim() Theme::dim().render("Run 'lore sync' or 'lore ingest' to start.")
); );
} else { } else {
for run in &result.runs { for run in &result.runs {
@@ -310,16 +289,16 @@ pub fn print_sync_status(result: &SyncStatusResult) {
println!(); println!();
println!("{}", style("Cursor Positions").bold().underlined()); println!("{}", Theme::bold().underline().render("Cursor Positions"));
println!(); println!();
if result.cursors.is_empty() { if result.cursors.is_empty() {
println!(" {}", style("No cursors recorded yet.").dim()); println!(" {}", Theme::dim().render("No cursors recorded yet."));
} else { } else {
for cursor in &result.cursors { for cursor in &result.cursors {
println!( println!(
" {} ({}):", " {} ({}):",
style(&cursor.project_path).cyan(), Theme::info().render(&cursor.project_path),
cursor.resource_type 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: {}", 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!();
println!("{}", style("Data Summary").bold().underlined()); println!("{}", Theme::bold().underline().render("Data Summary"));
println!(); println!();
println!( println!(
" Issues: {}", " Issues: {}",
style(format_number(result.summary.issue_count)).bold() Theme::bold().render(&render::format_number(result.summary.issue_count))
); );
println!( println!(
" MRs: {}", " MRs: {}",
style(format_number(result.summary.mr_count)).bold() Theme::bold().render(&render::format_number(result.summary.mr_count))
); );
println!( println!(
" Discussions: {}", " 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; let user_notes = result.summary.note_count - result.summary.system_note_count;
println!( println!(
" Notes: {} {}", " Notes: {} {}",
style(format_number(user_notes)).bold(), Theme::bold().render(&render::format_number(user_notes)),
style(format!( Theme::dim().render(&format!(
"(excluding {} system)", "(excluding {} system)",
format_number(result.summary.system_note_count) render::format_number(result.summary.system_note_count)
)) ))
.dim()
); );
} }
fn print_run_line(run: &SyncRunInfo) { fn print_run_line(run: &SyncRunInfo) {
let status_styled = match run.status.as_str() { let status_styled = match run.status.as_str() {
"succeeded" => style(&run.status).green(), "succeeded" => Theme::success().render(&run.status),
"failed" => style(&run.status).red(), "failed" => Theme::error().render(&run.status),
"running" => style(&run.status).yellow(), "running" => Theme::warning().render(&run.status),
_ => style(&run.status).dim(), _ => Theme::dim().render(&run.status),
}; };
let run_label = run let run_label = run
@@ -386,9 +367,9 @@ fn print_run_line(run: &SyncRunInfo) {
let time = format_full_datetime(run.started_at); let time = format_full_datetime(run.started_at);
let mut parts = vec![ let mut parts = vec![
format!("{}", style(run_label).bold()), Theme::bold().render(&run_label),
format!("{status_styled}"), status_styled,
format!("{}", style(&run.command).dim()), Theme::dim().render(&run.command),
time, time,
]; ];
@@ -403,16 +384,13 @@ fn print_run_line(run: &SyncRunInfo) {
} }
if run.total_errors > 0 { if run.total_errors > 0 {
parts.push(format!( parts.push(Theme::error().render(&format!("{} errors", run.total_errors)));
"{}",
style(format!("{} errors", run.total_errors)).red()
));
} }
println!(" {}", parts.join(" | ")); println!(" {}", parts.join(" | "));
if let Some(error) = &run.error { if let Some(error) = &run.error {
println!(" {}", style(error).red()); println!(" {}", Theme::error().render(error));
} }
} }
@@ -448,7 +426,7 @@ mod tests {
#[test] #[test]
fn format_number_adds_thousands_separators() { fn format_number_adds_thousands_separators() {
assert_eq!(format_number(1000), "1,000"); assert_eq!(render::format_number(1000), "1,000");
assert_eq!(format_number(1234567), "1,234,567"); 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 serde::Serialize;
use crate::Config; use crate::Config;
@@ -22,7 +22,7 @@ pub struct TimelineParams {
pub project: Option<String>, pub project: Option<String>,
pub since: Option<String>, pub since: Option<String>,
pub depth: u32, pub depth: u32,
pub expand_mentions: bool, pub no_mentions: bool,
pub limit: usize, pub limit: usize,
pub max_seeds: usize, pub max_seeds: usize,
pub max_entities: usize, pub max_entities: usize,
@@ -133,7 +133,7 @@ pub async fn run_timeline(config: &Config, params: &TimelineParams) -> Result<Ti
&conn, &conn,
&seed_result.seed_entities, &seed_result.seed_entities,
params.depth, params.depth,
params.expand_mentions, !params.no_mentions,
params.max_entities, params.max_entities,
)?; )?;
spinner.finish_and_clear(); spinner.finish_and_clear();
@@ -171,19 +171,21 @@ pub fn print_timeline(result: &TimelineResult) {
println!(); println!();
println!( println!(
"{}", "{}",
style(format!( Theme::bold().render(&format!(
"Timeline: \"{}\" ({} events across {} entities)", "Timeline: \"{}\" ({} events across {} entities)",
result.query, result.query,
result.events.len(), result.events.len(),
entity_count, entity_count,
)) ))
.bold()
); );
println!("{}", "".repeat(60)); println!("{}", "\u{2500}".repeat(60));
println!(); println!();
if result.events.is_empty() { 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!(); println!();
return; return;
} }
@@ -193,12 +195,12 @@ pub fn print_timeline(result: &TimelineResult) {
} }
println!(); println!();
println!("{}", "".repeat(60)); println!("{}", "\u{2500}".repeat(60));
print_timeline_footer(result); print_timeline_footer(result);
} }
fn print_timeline_event(event: &TimelineEvent) { 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 tag = format_event_tag(&event.event_type);
let entity_ref = format_entity_ref(&event.entity_type, event.entity_iid); let entity_ref = format_entity_ref(&event.entity_type, event.entity_iid);
let actor = event let actor = event
@@ -208,18 +210,20 @@ fn print_timeline_event(event: &TimelineEvent) {
.unwrap_or_default(); .unwrap_or_default();
let expanded_marker = if event.is_seed { "" } else { " [expanded]" }; let expanded_marker = if event.is_seed { "" } else { " [expanded]" };
let summary = truncate_summary(&event.summary, 50); let summary = render::truncate(&event.summary, 50);
let tag_padded = pad_str(&tag, 12, Alignment::Left, None); let tag_padded = format!("{:<12}", tag);
println!("{date} {tag_padded} {entity_ref:7} {summary:50} {actor}{expanded_marker}"); println!("{date} {tag_padded} {entity_ref:7} {summary:50} {actor}{expanded_marker}");
// Show snippet for evidence notes // Show snippet for evidence notes
if let TimelineEventType::NoteEvidence { snippet, .. } = &event.event_type if let TimelineEventType::NoteEvidence { snippet, .. } = &event.event_type
&& !snippet.is_empty() && !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!( println!(
" \"{}\"", " \"{}\"",
style(line).dim() Theme::dim().render(&line)
); );
} }
} }
@@ -229,14 +233,14 @@ fn print_timeline_event(event: &TimelineEvent) {
let bar = "\u{2500}".repeat(44); let bar = "\u{2500}".repeat(44);
println!(" \u{2500}\u{2500} Discussion {bar}"); println!(" \u{2500}\u{2500} Discussion {bar}");
for note in notes { for note in notes {
let note_date = format_date(note.created_at); let note_date = render::format_date(note.created_at);
let author = note let author = note
.author .author
.as_deref() .as_deref()
.map(|a| format!("@{a}")) .map(|a| format!("@{a}"))
.unwrap_or_else(|| "unknown".to_owned()); .unwrap_or_else(|| "unknown".to_owned());
println!(" {} ({note_date}):", style(author).bold()); println!(" {} ({note_date}):", Theme::bold().render(&author));
for line in wrap_text(&note.body, 60) { for line in render::wrap_lines(&note.body, 60) {
println!(" {line}"); println!(" {line}");
} }
} }
@@ -274,20 +278,20 @@ fn print_timeline_footer(result: &TimelineResult) {
fn format_event_tag(event_type: &TimelineEventType) -> String { fn format_event_tag(event_type: &TimelineEventType) -> String {
match event_type { match event_type {
TimelineEventType::Created => style("CREATED").green().to_string(), TimelineEventType::Created => Theme::success().render("CREATED"),
TimelineEventType::StateChanged { state } => match state.as_str() { TimelineEventType::StateChanged { state } => match state.as_str() {
"closed" => style("CLOSED").red().to_string(), "closed" => Theme::error().render("CLOSED"),
"reopened" => style("REOPENED").yellow().to_string(), "reopened" => Theme::warning().render("REOPENED"),
_ => style(state.to_uppercase()).dim().to_string(), _ => Theme::dim().render(&state.to_uppercase()),
}, },
TimelineEventType::LabelAdded { .. } => style("LABEL+").blue().to_string(), TimelineEventType::LabelAdded { .. } => Theme::info().render("LABEL+"),
TimelineEventType::LabelRemoved { .. } => style("LABEL-").blue().to_string(), TimelineEventType::LabelRemoved { .. } => Theme::info().render("LABEL-"),
TimelineEventType::MilestoneSet { .. } => style("MILESTONE+").magenta().to_string(), TimelineEventType::MilestoneSet { .. } => Theme::accent().render("MILESTONE+"),
TimelineEventType::MilestoneRemoved { .. } => style("MILESTONE-").magenta().to_string(), TimelineEventType::MilestoneRemoved { .. } => Theme::accent().render("MILESTONE-"),
TimelineEventType::Merged => style("MERGED").cyan().to_string(), TimelineEventType::Merged => Theme::info().render("MERGED"),
TimelineEventType::NoteEvidence { .. } => style("NOTE").dim().to_string(), TimelineEventType::NoteEvidence { .. } => Theme::dim().render("NOTE"),
TimelineEventType::DiscussionThread { .. } => style("THREAD").yellow().to_string(), TimelineEventType::DiscussionThread { .. } => Theme::warning().render("THREAD"),
TimelineEventType::CrossReferenced { .. } => style("REF").dim().to_string(), 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 ─────────────────────────────────────────────────────── // ─── Robot JSON output ───────────────────────────────────────────────────────
/// Render timeline as robot-mode JSON in {ok, data, meta} envelope. /// Render timeline as robot-mode JSON in {ok, data, meta} envelope.
@@ -348,7 +310,7 @@ pub fn print_timeline_json_with_meta(
result: &TimelineResult, result: &TimelineResult,
total_events_before_limit: usize, total_events_before_limit: usize,
depth: u32, depth: u32,
expand_mentions: bool, include_mentions: bool,
fields: Option<&[String]>, fields: Option<&[String]>,
) { ) {
let output = TimelineJsonEnvelope { let output = TimelineJsonEnvelope {
@@ -357,7 +319,7 @@ pub fn print_timeline_json_with_meta(
meta: TimelineMetaJson { meta: TimelineMetaJson {
search_mode: result.search_mode.clone(), search_mode: result.search_mode.clone(),
expansion_depth: depth, expansion_depth: depth,
expand_mentions, include_mentions,
total_entities: result.seed_entities.len() + result.expanded_entities.len(), total_entities: result.seed_entities.len() + result.expanded_entities.len(),
total_events: total_events_before_limit, total_events: total_events_before_limit,
evidence_notes_included: count_evidence_notes(&result.events), 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 { struct TimelineMetaJson {
search_mode: String, search_mode: String,
expansion_depth: u32, expansion_depth: u32,
expand_mentions: bool, include_mentions: bool,
total_entities: usize, total_entities: usize,
total_events: usize, total_events: usize,
evidence_notes_included: usize, evidence_notes_included: usize,

View File

@@ -1,4 +1,4 @@
use console::style; use crate::cli::render::{self, Theme};
use rusqlite::Connection; use rusqlite::Connection;
use serde::Serialize; use serde::Serialize;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
@@ -1874,18 +1874,21 @@ fn print_scope_hint(project_path: Option<&str>) {
if project_path.is_none() { if project_path.is_none() {
println!( 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>) { fn print_expert_human(r: &ExpertResult, project_path: Option<&str>) {
println!(); 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!("{}", "\u{2500}".repeat(60));
println!( println!(
" {}", " {}",
style(format!( Theme::dim().render(&format!(
"(matching {} {})", "(matching {} {})",
r.path_match, r.path_match,
if r.path_match == "exact" { if r.path_match == "exact" {
@@ -1894,26 +1897,28 @@ fn print_expert_human(r: &ExpertResult, project_path: Option<&str>) {
"directory prefix" "directory prefix"
} }
)) ))
.dim()
); );
print_scope_hint(project_path); print_scope_hint(project_path);
println!(); println!();
if r.experts.is_empty() { 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!(); println!();
return; return;
} }
println!( println!(
" {:<16} {:>6} {:>12} {:>6} {:>12} {} {}", " {:<16} {:>6} {:>12} {:>6} {:>12} {} {}",
style("Username").bold(), Theme::bold().render("Username"),
style("Score").bold(), Theme::bold().render("Score"),
style("Reviewed(MRs)").bold(), Theme::bold().render("Reviewed(MRs)"),
style("Notes").bold(), Theme::bold().render("Notes"),
style("Authored(MRs)").bold(), Theme::bold().render("Authored(MRs)"),
style("Last Seen").bold(), Theme::bold().render("Last Seen"),
style("MR Refs").bold(), Theme::bold().render("MR Refs"),
); );
for expert in &r.experts { for expert in &r.experts {
@@ -1946,12 +1951,12 @@ fn print_expert_human(r: &ExpertResult, project_path: Option<&str>) {
}; };
println!( println!(
" {:<16} {:>6} {:>12} {:>6} {:>12} {:<12}{}{}", " {:<16} {:>6} {:>12} {:>6} {:>12} {:<12}{}{}",
style(format!("@{}", expert.username)).cyan(), Theme::info().render(&format!("@{}", expert.username)),
expert.score, expert.score,
reviews, reviews,
notes, notes,
authored, authored,
format_relative_time(expert.last_seen_ms), render::format_relative_time(expert.last_seen_ms),
if mr_str.is_empty() { if mr_str.is_empty() {
String::new() String::new()
} else { } else {
@@ -1971,17 +1976,17 @@ fn print_expert_human(r: &ExpertResult, project_path: Option<&str>) {
}; };
println!( println!(
" {:<3} {:<30} {:>30} {:>10} {}", " {:<3} {:<30} {:>30} {:>10} {}",
style(&d.role).dim(), Theme::dim().render(&d.role),
d.mr_ref, d.mr_ref,
truncate_str(&format!("\"{}\"", d.title), 30), render::truncate(&format!("\"{}\"", d.title), 30),
notes_str, 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 { if details.len() > MAX_DETAIL_DISPLAY {
println!( 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 { if r.truncated {
println!( println!(
" {}", " {}",
style("(showing first -n; rerun with a higher --limit)").dim() Theme::dim().render("(showing first -n; rerun with a higher --limit)")
); );
} }
println!(); println!();
@@ -1999,7 +2004,7 @@ fn print_workload_human(r: &WorkloadResult) {
println!(); println!();
println!( println!(
"{}", "{}",
style(format!("@{} -- Workload Summary", r.username)).bold() Theme::bold().render(&format!("@{} -- Workload Summary", r.username))
); );
println!("{}", "\u{2500}".repeat(60)); println!("{}", "\u{2500}".repeat(60));
@@ -2007,21 +2012,21 @@ fn print_workload_human(r: &WorkloadResult) {
println!(); println!();
println!( println!(
" {} ({})", " {} ({})",
style("Assigned Issues").bold(), Theme::bold().render("Assigned Issues"),
r.assigned_issues.len() r.assigned_issues.len()
); );
for item in &r.assigned_issues { for item in &r.assigned_issues {
println!( println!(
" {} {} {}", " {} {} {}",
style(&item.ref_).cyan(), Theme::info().render(&item.ref_),
truncate_str(&item.title, 40), render::truncate(&item.title, 40),
style(format_relative_time(item.updated_at)).dim(), Theme::dim().render(&render::format_relative_time(item.updated_at)),
); );
} }
if r.assigned_issues_truncated { if r.assigned_issues_truncated {
println!( 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!();
println!( println!(
" {} ({})", " {} ({})",
style("Authored MRs").bold(), Theme::bold().render("Authored MRs"),
r.authored_mrs.len() r.authored_mrs.len()
); );
for mr in &r.authored_mrs { for mr in &r.authored_mrs {
let draft = if mr.draft { " [draft]" } else { "" }; let draft = if mr.draft { " [draft]" } else { "" };
println!( println!(
" {} {}{} {}", " {} {}{} {}",
style(&mr.ref_).cyan(), Theme::info().render(&mr.ref_),
truncate_str(&mr.title, 35), render::truncate(&mr.title, 35),
style(draft).dim(), Theme::dim().render(draft),
style(format_relative_time(mr.updated_at)).dim(), Theme::dim().render(&render::format_relative_time(mr.updated_at)),
); );
} }
if r.authored_mrs_truncated { if r.authored_mrs_truncated {
println!( 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!();
println!( println!(
" {} ({})", " {} ({})",
style("Reviewing MRs").bold(), Theme::bold().render("Reviewing MRs"),
r.reviewing_mrs.len() r.reviewing_mrs.len()
); );
for mr in &r.reviewing_mrs { for mr in &r.reviewing_mrs {
@@ -2066,16 +2071,16 @@ fn print_workload_human(r: &WorkloadResult) {
.unwrap_or_default(); .unwrap_or_default();
println!( println!(
" {} {}{} {}", " {} {}{} {}",
style(&mr.ref_).cyan(), Theme::info().render(&mr.ref_),
truncate_str(&mr.title, 30), render::truncate(&mr.title, 30),
style(author).dim(), Theme::dim().render(&author),
style(format_relative_time(mr.updated_at)).dim(), Theme::dim().render(&render::format_relative_time(mr.updated_at)),
); );
} }
if r.reviewing_mrs_truncated { if r.reviewing_mrs_truncated {
println!( 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!();
println!( println!(
" {} ({})", " {} ({})",
style("Unresolved Discussions").bold(), Theme::bold().render("Unresolved Discussions"),
r.unresolved_discussions.len() r.unresolved_discussions.len()
); );
for disc in &r.unresolved_discussions { for disc in &r.unresolved_discussions {
println!( println!(
" {} {} {} {}", " {} {} {} {}",
style(&disc.entity_type).dim(), Theme::dim().render(&disc.entity_type),
style(&disc.ref_).cyan(), Theme::info().render(&disc.ref_),
truncate_str(&disc.entity_title, 35), render::truncate(&disc.entity_title, 35),
style(format_relative_time(disc.last_note_at)).dim(), Theme::dim().render(&render::format_relative_time(disc.last_note_at)),
); );
} }
if r.unresolved_discussions_truncated { if r.unresolved_discussions_truncated {
println!( 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!();
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!();
println!( println!(
"{}", "{}",
style(format!("@{} -- Review Patterns", r.username)).bold() Theme::bold().render(&format!("@{} -- Review Patterns", r.username))
); );
println!("{}", "\u{2500}".repeat(60)); println!("{}", "\u{2500}".repeat(60));
println!(); println!();
@@ -2131,7 +2136,7 @@ fn print_reviews_human(r: &ReviewsResult) {
if r.total_diffnotes == 0 { if r.total_diffnotes == 0 {
println!( println!(
" {}", " {}",
style("No review comments found for this user.").dim() Theme::dim().render("No review comments found for this user.")
); );
println!(); println!();
return; return;
@@ -2139,24 +2144,24 @@ fn print_reviews_human(r: &ReviewsResult) {
println!( println!(
" {} DiffNotes across {} MRs ({} categorized)", " {} DiffNotes across {} MRs ({} categorized)",
style(r.total_diffnotes).bold(), Theme::bold().render(&r.total_diffnotes.to_string()),
style(r.mrs_reviewed).bold(), Theme::bold().render(&r.mrs_reviewed.to_string()),
style(r.categorized_count).bold(), Theme::bold().render(&r.categorized_count.to_string()),
); );
println!(); println!();
if !r.categories.is_empty() { if !r.categories.is_empty() {
println!( println!(
" {:<16} {:>6} {:>6}", " {:<16} {:>6} {:>6}",
style("Category").bold(), Theme::bold().render("Category"),
style("Count").bold(), Theme::bold().render("Count"),
style("%").bold(), Theme::bold().render("%"),
); );
for cat in &r.categories { for cat in &r.categories {
println!( println!(
" {:<16} {:>6} {:>5.1}%", " {:<16} {:>6} {:>5.1}%",
style(&cat.name).cyan(), Theme::info().render(&cat.name),
cat.count, cat.count,
cat.percentage, cat.percentage,
); );
@@ -2168,7 +2173,7 @@ fn print_reviews_human(r: &ReviewsResult) {
println!(); println!();
println!( println!(
" {} {} uncategorized (no **prefix** convention)", " {} {} uncategorized (no **prefix** convention)",
style("Note:").dim(), Theme::dim().render("Note:"),
uncategorized, uncategorized,
); );
} }
@@ -2180,11 +2185,10 @@ fn print_active_human(r: &ActiveResult, project_path: Option<&str>) {
println!(); println!();
println!( println!(
"{}", "{}",
style(format!( Theme::bold().render(&format!(
"Active Discussions ({} unresolved in window)", "Active Discussions ({} unresolved in window)",
r.total_unresolved_in_window r.total_unresolved_in_window
)) ))
.bold()
); );
println!("{}", "\u{2500}".repeat(60)); println!("{}", "\u{2500}".repeat(60));
print_scope_hint(project_path); print_scope_hint(project_path);
@@ -2193,7 +2197,7 @@ fn print_active_human(r: &ActiveResult, project_path: Option<&str>) {
if r.discussions.is_empty() { if r.discussions.is_empty() {
println!( println!(
" {}", " {}",
style("No active unresolved discussions in this time window.").dim() Theme::dim().render("No active unresolved discussions in this time window.")
); );
println!(); println!();
return; return;
@@ -2210,20 +2214,20 @@ fn print_active_human(r: &ActiveResult, project_path: Option<&str>) {
println!( println!(
" {} {} {} {} notes {}", " {} {} {} {} notes {}",
style(format!("{prefix}{}", disc.entity_iid)).cyan(), Theme::info().render(&format!("{prefix}{}", disc.entity_iid)),
truncate_str(&disc.entity_title, 40), render::truncate(&disc.entity_title, 40),
style(format_relative_time(disc.last_note_at)).dim(), Theme::dim().render(&render::format_relative_time(disc.last_note_at)),
disc.note_count, disc.note_count,
style(&disc.project_path).dim(), Theme::dim().render(&disc.project_path),
); );
if !participants_str.is_empty() { if !participants_str.is_empty() {
println!(" {}", style(participants_str).dim()); println!(" {}", Theme::dim().render(&participants_str));
} }
} }
if r.truncated { if r.truncated {
println!( println!(
" {}", " {}",
style("(showing first -n; rerun with a higher --limit)").dim() Theme::dim().render("(showing first -n; rerun with a higher --limit)")
); );
} }
println!(); 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>) { fn print_overlap_human(r: &OverlapResult, project_path: Option<&str>) {
println!(); 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!("{}", "\u{2500}".repeat(60));
println!( println!(
" {}", " {}",
style(format!( Theme::dim().render(&format!(
"(matching {} {})", "(matching {} {})",
r.path_match, r.path_match,
if r.path_match == "exact" { if r.path_match == "exact" {
@@ -2244,7 +2251,6 @@ fn print_overlap_human(r: &OverlapResult, project_path: Option<&str>) {
"directory prefix" "directory prefix"
} }
)) ))
.dim()
); );
print_scope_hint(project_path); print_scope_hint(project_path);
println!(); println!();
@@ -2252,7 +2258,7 @@ fn print_overlap_human(r: &OverlapResult, project_path: Option<&str>) {
if r.users.is_empty() { if r.users.is_empty() {
println!( println!(
" {}", " {}",
style("No overlapping users found for this path.").dim() Theme::dim().render("No overlapping users found for this path.")
); );
println!(); println!();
return; return;
@@ -2260,11 +2266,11 @@ fn print_overlap_human(r: &OverlapResult, project_path: Option<&str>) {
println!( println!(
" {:<16} {:<6} {:>7} {:<12} {}", " {:<16} {:<6} {:>7} {:<12} {}",
style("Username").bold(), Theme::bold().render("Username"),
style("Role").bold(), Theme::bold().render("Role"),
style("MRs").bold(), Theme::bold().render("MRs"),
style("Last Seen").bold(), Theme::bold().render("Last Seen"),
style("MR Refs").bold(), Theme::bold().render("MR Refs"),
); );
for user in &r.users { for user in &r.users {
@@ -2283,10 +2289,10 @@ fn print_overlap_human(r: &OverlapResult, project_path: Option<&str>) {
println!( println!(
" {:<16} {:<6} {:>7} {:<12} {}{}", " {:<16} {:<6} {:>7} {:<12} {}{}",
style(format!("@{}", user.username)).cyan(), Theme::info().render(&format!("@{}", user.username)),
format_overlap_role(user), format_overlap_role(user),
user.touch_count, user.touch_count,
format_relative_time(user.last_seen_at), render::format_relative_time(user.last_seen_at),
mr_str, mr_str,
overflow, overflow,
); );
@@ -2294,7 +2300,7 @@ fn print_overlap_human(r: &OverlapResult, project_path: Option<&str>) {
if r.truncated { if r.truncated {
println!( println!(
" {}", " {}",
style("(showing first -n; rerun with a higher --limit)").dim() Theme::dim().render("(showing first -n; rerun with a higher --limit)")
); );
} }
println!(); 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 ─────────────────────────────────────────────────────────────────── // ─── Tests ───────────────────────────────────────────────────────────────────
#[cfg(test)] #[cfg(test)]

View File

@@ -1,6 +1,7 @@
pub mod autocorrect; pub mod autocorrect;
pub mod commands; pub mod commands;
pub mod progress; pub mod progress;
pub mod render;
pub mod robot; pub mod robot;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
@@ -810,7 +811,8 @@ pub struct EmbedArgs {
lore timeline i:42 # Shorthand for issue:42 lore timeline i:42 # Shorthand for issue:42
lore timeline mr:99 # Direct: MR !99 and related entities 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 '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 { pub struct TimelineArgs {
/// Search text or entity reference (issue:N, i:N, mr:N, m:N) /// Search text or entity reference (issue:N, i:N, mr:N, m:N)
pub query: String, pub query: String,
@@ -827,9 +829,9 @@ pub struct TimelineArgs {
#[arg(long, default_value = "1", help_heading = "Expansion")] #[arg(long, default_value = "1", help_heading = "Expansion")]
pub depth: u32, pub depth: u32,
/// Also follow 'mentioned' edges during expansion (high fan-out) /// Skip 'mentioned' edges during expansion (only follow 'closes' and 'related')
#[arg(long = "expand-mentions", help_heading = "Expansion")] #[arg(long = "no-mentions", help_heading = "Expansion")]
pub expand_mentions: bool, pub no_mentions: bool,
/// Maximum number of events to display /// Maximum number of events to display
#[arg( #[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::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::thread; use std::thread;
use std::time::Duration; use std::time::Duration;
use tracing::{debug, error, info, warn}; use tracing::{debug, error, warn};
use uuid::Uuid; use uuid::Uuid;
use super::db::create_connection; use super::db::create_connection;
@@ -75,7 +75,7 @@ impl AppLock {
"INSERT INTO app_locks (name, owner, acquired_at, heartbeat_at) VALUES (?, ?, ?, ?)", "INSERT INTO app_locks (name, owner, acquired_at, heartbeat_at) VALUES (?, ?, ?, ?)",
(&self.name, &self.owner, now, now), (&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)) => { Some((existing_owner, acquired_at, heartbeat_at)) => {
let is_stale = now - heartbeat_at > self.stale_lock_ms; 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 = ?", "UPDATE app_locks SET owner = ?, acquired_at = ?, heartbeat_at = ? WHERE name = ?",
(&self.owner, now, now, &self.name), (&self.owner, now, now, &self.name),
)?; )?;
info!( debug!(
owner = %self.owner, owner = %self.owner,
previous_owner = %existing_owner, previous_owner = %existing_owner,
was_stale = is_stale, was_stale = is_stale,
@@ -125,7 +125,7 @@ impl AppLock {
"DELETE FROM app_locks WHERE name = ? AND owner = ?", "DELETE FROM app_locks WHERE name = ? AND owner = ?",
(&self.name, &self.owner), (&self.name, &self.owner),
) { ) {
Ok(_) => info!(owner = %self.owner, "Lock released"), Ok(_) => debug!(owner = %self.owner, "Lock released"),
Err(e) => error!( Err(e) => error!(
owner = %self.owner, owner = %self.owner,
error = %e, error = %e,

View File

@@ -1,7 +1,45 @@
use std::fmt;
use std::fs; use std::fs;
use std::path::Path; use std::path::Path;
use tracing_subscriber::EnvFilter; 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 { pub fn build_stderr_filter(verbose: u8, quiet: bool) -> EnvFilter {
if std::env::var("RUST_LOG").is_ok() { 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)?; result.issues_needing_discussion_sync = get_issues_needing_discussion_sync(conn, project_id)?;
info!( info!(
fetched = result.fetched, summary = crate::ingestion::nonzero_summary(&[
upserted = result.upserted, ("fetched", result.fetched),
labels_created = result.labels_created, ("upserted", result.upserted),
needing_sync = result.issues_needing_discussion_sync.len(), ("labels", result.labels_created),
"Issue ingestion complete" ("needing sync", result.issues_needing_discussion_sync.len()),
]),
"Issue ingestion"
); );
Ok(result) Ok(result)

View File

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

View File

@@ -14,6 +14,22 @@ pub use merge_requests::{
ingest_merge_requests, ingest_merge_requests,
}; };
pub use mr_discussions::{IngestMrDiscussionsResult, ingest_mr_discussions}; 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::{ pub use orchestrator::{
DrainResult, IngestMrProjectResult, IngestProjectResult, ProgressCallback, ProgressEvent, DrainResult, IngestMrProjectResult, IngestProjectResult, ProgressCallback, ProgressEvent,
ingest_project_issues, ingest_project_issues_with_progress, ingest_project_merge_requests, 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!( info!(
mrs_processed = mrs.len(), summary = crate::ingestion::nonzero_summary(&[
discussions_fetched = total_result.discussions_fetched, ("MRs", mrs.len()),
discussions_upserted = total_result.discussions_upserted, ("discussions", total_result.discussions_fetched),
notes_upserted = total_result.notes_upserted, ("notes", total_result.notes_upserted),
notes_skipped = total_result.notes_skipped_bad_timestamp, ("skipped", total_result.notes_skipped_bad_timestamp),
diffnotes = total_result.diffnotes_count, ("diffnotes", total_result.diffnotes_count),
pagination_succeeded = total_result.pagination_succeeded, ]),
"MR discussion ingestion complete" "MR discussion ingestion"
); );
Ok(total_result) Ok(total_result)

View File

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

View File

@@ -1,5 +1,4 @@
use clap::Parser; use clap::Parser;
use console::style;
use dialoguer::{Confirm, Input}; use dialoguer::{Confirm, Input};
use serde::Serialize; use serde::Serialize;
use strsim::jaro_winkler; 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_list_issues, run_list_mrs, run_search, run_show_issue, run_show_mr, run_stats, run_sync,
run_sync_status, run_timeline, run_who, run_sync_status, run_timeline, run_who,
}; };
use lore::cli::render::{ColorMode, LoreRenderer, Theme};
use lore::cli::robot::{RobotMeta, strip_schemas}; use lore::cli::robot::{RobotMeta, strip_schemas};
use lore::cli::{ use lore::cli::{
Cli, Commands, CountArgs, EmbedArgs, GenerateDocsArgs, IngestArgs, IssuesArgs, MrsArgs, Cli, Commands, CountArgs, EmbedArgs, GenerateDocsArgs, IngestArgs, IssuesArgs, MrsArgs,
@@ -116,7 +116,7 @@ async fn main() {
} }
} else { } else {
let stderr_layer = tracing_subscriber::fmt::layer() let stderr_layer = tracing_subscriber::fmt::layer()
.with_target(false) .event_format(logging::CompactHumanFormat)
.with_writer(lore::cli::progress::SuspendingWriter) .with_writer(lore::cli::progress::SuspendingWriter)
.with_filter(stderr_filter); .with_filter(stderr_filter);
@@ -146,13 +146,23 @@ async fn main() {
// I1: Respect NO_COLOR convention (https://no-color.org/) // I1: Respect NO_COLOR convention (https://no-color.org/)
if std::env::var("NO_COLOR").is_ok_and(|v| !v.is_empty()) { if std::env::var("NO_COLOR").is_ok_and(|v| !v.is_empty()) {
LoreRenderer::init(ColorMode::Never);
console::set_colors_enabled(false); console::set_colors_enabled(false);
} else { } else {
match cli.color.as_str() { match cli.color.as_str() {
"never" => console::set_colors_enabled(false), "never" => {
"always" => console::set_colors_enabled(true), LoreRenderer::init(ColorMode::Never);
"auto" => {} console::set_colors_enabled(false);
}
"always" => {
LoreRenderer::init(ColorMode::Always);
console::set_colors_enabled(true);
}
"auto" => {
LoreRenderer::init(ColorMode::Auto);
}
other => { other => {
LoreRenderer::init(ColorMode::Auto);
eprintln!("Warning: unknown color mode '{}', using auto", other); eprintln!("Warning: unknown color mode '{}', using auto", other);
} }
} }
@@ -277,8 +287,9 @@ async fn main() {
} else { } else {
eprintln!( eprintln!(
"{}", "{}",
style("warning: 'lore list' is deprecated, use 'lore issues' or 'lore mrs'") Theme::warning().render(
.yellow() "warning: 'lore list' is deprecated, use 'lore issues' or 'lore mrs'"
)
); );
} }
handle_list_compat( handle_list_compat(
@@ -318,11 +329,10 @@ async fn main() {
} else { } else {
eprintln!( eprintln!(
"{}", "{}",
style(format!( Theme::warning().render(&format!(
"warning: 'lore show' is deprecated, use 'lore {}s {}'", "warning: 'lore show' is deprecated, use 'lore {}s {}'",
entity, iid entity, iid
)) ))
.yellow()
); );
} }
handle_show_compat( handle_show_compat(
@@ -342,7 +352,8 @@ async fn main() {
} else { } else {
eprintln!( 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 handle_auth_test(cli.config.as_deref(), robot_mode).await
@@ -355,7 +366,8 @@ async fn main() {
} else { } else {
eprintln!( 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 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()); std::process::exit(gi_error.exit_code());
} else { } else {
eprintln!("{} {}", style("Error:").red(), gi_error); eprintln!("{} {}", Theme::error().render("Error:"), gi_error);
if let Some(suggestion) = gi_error.suggestion() { 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()); std::process::exit(gi_error.exit_code());
} }
@@ -420,7 +443,7 @@ fn handle_error(e: Box<dyn std::error::Error>, robot_mode: bool) -> ! {
}) })
); );
} else { } else {
eprintln!("{} {}", style("Error:").red(), e); eprintln!("{} {}", Theme::error().render("Error:"), e);
} }
std::process::exit(1); std::process::exit(1);
} }
@@ -459,7 +482,7 @@ fn emit_correction_warnings(result: &CorrectionResult, robot_mode: bool) {
for c in &result.corrections { for c in &result.corrections {
eprintln!( eprintln!(
"{} {}", "{} {}",
style("Auto-corrected:").yellow(), Theme::warning().render("Auto-corrected:"),
autocorrect::format_teaching_note(c) autocorrect::format_teaching_note(c)
); );
} }
@@ -984,7 +1007,7 @@ async fn handle_ingest(
if !robot_mode && !quiet { if !robot_mode && !quiet {
println!( println!(
"{}", "{}",
style("Ingesting all content (issues + merge requests)...").blue() Theme::info().render("Ingesting all content (issues + merge requests)...")
); );
println!(); println!();
} }
@@ -1027,7 +1050,7 @@ async fn handle_ingest(
if !robot_mode { if !robot_mode {
eprintln!( 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(()) Ok(())
@@ -1037,6 +1060,12 @@ async fn handle_ingest(
let total_items: usize = stages.iter().map(|s| s.items_processed).sum(); let total_items: usize = stages.iter().map(|s| s.items_processed).sum();
let total_errors: usize = stages.iter().map(|s| s.errors).sum(); let total_errors: usize = stages.iter().map(|s| s.errors).sum();
let _ = recorder.succeed(&recorder_conn, &stages, total_items, total_errors); 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(()) Ok(())
} }
Err(e) => { Err(e) => {
@@ -1311,11 +1340,10 @@ async fn handle_init(
if non_interactive { if non_interactive {
eprintln!( eprintln!(
"{}", "{}",
style(format!( Theme::error().render(&format!(
"Config file exists at {}. Use --force to overwrite.", "Config file exists at {}. Use --force to overwrite.",
config_path.display() config_path.display()
)) ))
.red()
); );
std::process::exit(2); std::process::exit(2);
} }
@@ -1329,7 +1357,7 @@ async fn handle_init(
.interact()?; .interact()?;
if !confirm { if !confirm {
println!("{}", style("Cancelled.").yellow()); println!("{}", Theme::warning().render("Cancelled."));
std::process::exit(2); std::process::exit(2);
} }
confirmed_overwrite = true; confirmed_overwrite = true;
@@ -1408,7 +1436,7 @@ async fn handle_init(
None None
}; };
println!("{}", style("\nValidating configuration...").blue()); println!("{}", Theme::info().render("Validating configuration..."));
let result = run_init( let result = run_init(
InitInputs { InitInputs {
@@ -1427,35 +1455,43 @@ async fn handle_init(
println!( println!(
"{}", "{}",
style(format!( Theme::success().render(&format!(
"\n Authenticated as @{} ({})", "\n\u{2713} Authenticated as @{} ({})",
result.user.username, result.user.name result.user.username, result.user.name
)) ))
.green()
); );
for project in &result.projects { for project in &result.projects {
println!( 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 { 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!( 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!( println!(
"{}", "{}",
style(format!("✓ Database initialized at {}", result.data_dir)).green() Theme::success().render(&format!(
"\u{2713} Database initialized at {}",
result.data_dir
))
); );
println!( println!(
"{}", "{}",
style("\nSetup complete! Run 'lore doctor' to verify.").blue() Theme::info().render("\nSetup complete! Run 'lore doctor' to verify.")
); );
Ok(()) Ok(())
@@ -1518,9 +1554,9 @@ async fn handle_auth_test(
}) })
); );
} else { } else {
eprintln!("{} {}", style("Error:").red(), e); eprintln!("{} {}", Theme::error().render("Error:"), e);
if let Some(suggestion) = e.suggestion() { if let Some(suggestion) = e.suggestion() {
eprintln!("{} {}", style("Hint:").yellow(), suggestion); eprintln!("{} {}", Theme::warning().render("Hint:"), suggestion);
} }
} }
std::process::exit(e.exit_code()); std::process::exit(e.exit_code());
@@ -1647,7 +1683,7 @@ fn handle_backup(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>> {
} else { } else {
eprintln!( eprintln!(
"{} The 'backup' command is not yet implemented.", "{} The 'backup' command is not yet implemented.",
style("Error:").red() Theme::error().render("Error:")
); );
} }
std::process::exit(1); std::process::exit(1);
@@ -1669,7 +1705,7 @@ fn handle_reset(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>> {
} else { } else {
eprintln!( eprintln!(
"{} The 'reset' command is not yet implemented.", "{} The 'reset' command is not yet implemented.",
style("Error:").red() Theme::error().render("Error:")
); );
} }
std::process::exit(1); std::process::exit(1);
@@ -1728,11 +1764,11 @@ async fn handle_migrate(
} else { } else {
eprintln!( eprintln!(
"{}", "{}",
style(format!("Database not found at {}", db_path.display())).red() Theme::error().render(&format!("Database not found at {}", db_path.display()))
); );
eprintln!( 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); std::process::exit(10);
@@ -1744,7 +1780,7 @@ async fn handle_migrate(
if !robot_mode { if !robot_mode {
println!( 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 { } else if after_version > before_version {
println!( println!(
"{}", "{}",
style(format!( Theme::success().render(&format!(
"Migrations applied: {} -> {}", "Migrations applied: {} -> {}",
before_version, after_version before_version, after_version
)) ))
.green()
); );
} else { } else {
println!("{}", style("Database is already up to date.").green()); println!(
"{}",
Theme::success().render("Database is already up to date.")
);
} }
Ok(()) Ok(())
@@ -1813,7 +1851,7 @@ async fn handle_timeline(
.map(String::from), .map(String::from),
since: args.since, since: args.since,
depth: args.depth, depth: args.depth,
expand_mentions: args.expand_mentions, no_mentions: args.no_mentions,
limit: args.limit, limit: args.limit,
max_seeds: args.max_seeds, max_seeds: args.max_seeds,
max_entities: args.max_entities, max_entities: args.max_entities,
@@ -1828,7 +1866,7 @@ async fn handle_timeline(
&result, &result,
result.total_events_before_limit, result.total_events_before_limit,
params.depth, params.depth,
params.expand_mentions, !params.no_mentions,
args.fields.as_deref(), args.fields.as_deref(),
); );
} else { } else {
@@ -1900,10 +1938,25 @@ async fn handle_generate_docs(
let project = config.effective_project(args.project.as_deref()); let project = config.effective_project(args.project.as_deref());
let result = run_generate_docs(&config, args.full, project, None)?; let result = run_generate_docs(&config, args.full, project, None)?;
let elapsed = start.elapsed();
if robot_mode { 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 { } else {
print_generate_docs(&result); 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(()) Ok(())
} }
@@ -1913,6 +1966,10 @@ async fn handle_embed(
args: EmbedArgs, args: EmbedArgs,
robot_mode: bool, robot_mode: bool,
) -> Result<(), Box<dyn std::error::Error>> { ) -> 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 start = std::time::Instant::now();
let config = Config::load(config_override)?; let config = Config::load(config_override)?;
let full = args.full && !args.no_full; let full = args.full && !args.no_full;
@@ -1928,11 +1985,45 @@ async fn handle_embed(
std::process::exit(130); 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 { if robot_mode {
print_embed_json(&result, start.elapsed().as_millis() as u64); print_embed_json(&result, elapsed.as_millis() as u64);
} else { } else {
print_embed(&result); print_embed(&result);
if elapsed.as_secs() >= 1 {
eprintln!(
"{}",
Theme::dim().render(&format!(" Done in {:.1}s", elapsed.as_secs_f64()))
);
}
} }
Ok(()) Ok(())
} }
@@ -1962,7 +2053,7 @@ async fn handle_sync_cmd(
dry_run, 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 { if dry_run {
let signal = ShutdownSignal::new(); let signal = ShutdownSignal::new();
run_sync(&config, options, None, &signal).await?; run_sync(&config, options, None, &signal).await?;
@@ -2003,13 +2094,13 @@ async fn handle_sync_cmd(
eprintln!(); eprintln!();
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)); print_sync(&result, elapsed, Some(metrics));
if released > 0 { if released > 0 {
eprintln!( 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 { } else {
let status = |ok: bool| { let status = |ok: bool| {
if ok { if ok {
style("pass").green() Theme::success().render("pass")
} else { } else {
style("FAIL").red() Theme::error().render("FAIL")
} }
}; };
println!( println!(
@@ -2135,13 +2226,13 @@ async fn handle_health(
println!("Schema: {} (v{})", status(schema_current), schema_version); println!("Schema: {} (v{})", status(schema_current), schema_version);
println!(); println!();
if healthy { if healthy {
println!("{}", style("Healthy").green().bold()); println!("{}", Theme::success().bold().render("Healthy"));
} else { } else {
println!( println!(
"{}", "{}",
style("Unhealthy - run 'lore doctor' for details") Theme::error()
.red()
.bold() .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": { "sync": {
"description": "Full sync pipeline: ingest -> generate-docs -> embed", "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", "example": "lore --robot sync",
"response_schema": { "response_schema": {
"ok": "bool", "ok": "bool",
@@ -2382,7 +2473,7 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
}, },
"timeline": { "timeline": {
"description": "Chronological timeline of events matching a keyword query or entity reference", "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": { "query_syntax": {
"search": "Any text -> hybrid search seeding (FTS + vector)", "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)" "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": { "who": {
"description": "People intelligence: experts, workload, active discussions, overlap, review patterns", "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": { "modes": {
"expert": "lore who <file-path> -- Who knows about this area? (also: --path for root files)", "expert": "lore who <file-path> -- Who knows about this area? (also: --path for root files)",
"workload": "lore who <username> -- What is someone working on?", "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"] "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": { "notes": {
"description": "List notes from discussions with rich filtering", "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"], "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": [ "temporal_intelligence": [
"lore --robot sync", "lore --robot sync",
"lore --robot timeline '<keyword>' --since 30d", "lore --robot timeline '<keyword>' --since 30d",
"lore --robot timeline '<keyword>' --depth 2 --expand-mentions" "lore --robot timeline '<keyword>' --depth 2"
], ],
"people_intelligence": [ "people_intelligence": [
"lore --robot who src/path/to/feature/", "lore --robot who src/path/to/feature/",
@@ -2762,7 +2863,10 @@ async fn handle_list_compat(
Ok(()) Ok(())
} }
_ => { _ => {
eprintln!("{}", style(format!("Unknown entity: {entity}")).red()); eprintln!(
"{}",
Theme::error().render(&format!("Unknown entity: {entity}"))
);
std::process::exit(1); std::process::exit(1);
} }
} }
@@ -2799,7 +2903,10 @@ async fn handle_show_compat(
Ok(()) Ok(())
} }
_ => { _ => {
eprintln!("{}", style(format!("Unknown entity: {entity}")).red()); eprintln!(
"{}",
Theme::error().render(&format!("Unknown entity: {entity}"))
);
std::process::exit(1); std::process::exit(1);
} }
} }

View File

@@ -257,7 +257,8 @@ mod tests {
#[test] #[test]
fn test_raw_mode_leading_wildcard_falls_back_to_safe() { fn test_raw_mode_leading_wildcard_falls_back_to_safe() {
let result = to_fts_query("* OR auth", FtsQueryMode::Raw); 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); let result = to_fts_query("*", FtsQueryMode::Raw);
assert_eq!(result, "\"*\""); assert_eq!(result, "\"*\"");