Compare commits
8 Commits
159c490ad7
...
ebf64816c9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebf64816c9 | ||
|
|
450951dee1 | ||
|
|
81f049a7fa | ||
|
|
dd00a2b840 | ||
|
|
c6a5461d41 | ||
|
|
a7f86b26e4 | ||
|
|
5ee8b0841c | ||
|
|
7062a3f1fd |
171
Cargo.lock
generated
171
Cargo.lock
generated
@@ -169,6 +169,23 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "charmed-lipgloss"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "45e10db01f5eaea11d98ca5c5cffd8cc4add7ac56d0128d91ba1f2a3757b6c5a"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"colored",
|
||||
"crossterm",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"toml",
|
||||
"tracing",
|
||||
"unicode-width 0.1.14",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.43"
|
||||
@@ -239,14 +256,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
||||
|
||||
[[package]]
|
||||
name = "comfy-table"
|
||||
version = "7.2.2"
|
||||
name = "colored"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47"
|
||||
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
|
||||
dependencies = [
|
||||
"crossterm",
|
||||
"unicode-segmentation",
|
||||
"unicode-width",
|
||||
"lazy_static",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -258,10 +274,19 @@ dependencies = [
|
||||
"encode_unicode",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"unicode-width",
|
||||
"unicode-width 0.2.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
|
||||
dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
@@ -319,9 +344,13 @@ checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"crossterm_winapi",
|
||||
"derive_more",
|
||||
"document-features",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"rustix",
|
||||
"signal-hook",
|
||||
"signal-hook-mio",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
@@ -371,6 +400,28 @@ dependencies = [
|
||||
"powerfmt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
|
||||
dependencies = [
|
||||
"derive_more-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more-impl"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dialoguer"
|
||||
version = "0.12.0"
|
||||
@@ -976,7 +1027,7 @@ checksum = "9375e112e4b463ec1b1c6c011953545c65a30164fbab5b581df32b3abf0dcb88"
|
||||
dependencies = [
|
||||
"console",
|
||||
"portable-atomic",
|
||||
"unicode-width",
|
||||
"unicode-width 0.2.2",
|
||||
"unit-prefix",
|
||||
"web-time",
|
||||
]
|
||||
@@ -1109,10 +1160,10 @@ name = "lore"
|
||||
version = "0.8.2"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"charmed-lipgloss",
|
||||
"chrono",
|
||||
"clap",
|
||||
"clap_complete",
|
||||
"comfy-table",
|
||||
"console",
|
||||
"dialoguer",
|
||||
"dirs",
|
||||
@@ -1181,6 +1232,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"wasi",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
@@ -1574,6 +1626,15 @@ dependencies = [
|
||||
"sqlite-wasm-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
|
||||
dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.3"
|
||||
@@ -1670,6 +1731,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
@@ -1713,6 +1780,15 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
@@ -1757,6 +1833,27 @@ version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook"
|
||||
version = "0.3.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"signal-hook-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-mio"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"mio",
|
||||
"signal-hook",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.5"
|
||||
@@ -2028,6 +2125,47 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.8.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_edit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.22.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_write",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_write"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.5.3"
|
||||
@@ -2183,6 +2321,12 @@ version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.2.2"
|
||||
@@ -2611,6 +2755,15 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.7.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wiremock"
|
||||
version = "0.6.5"
|
||||
|
||||
@@ -25,7 +25,7 @@ clap_complete = "4"
|
||||
dialoguer = "0.12"
|
||||
console = "0.16"
|
||||
indicatif = "0.18"
|
||||
comfy-table = "7"
|
||||
lipgloss = { package = "charmed-lipgloss", version = "0.1", default-features = false, features = ["native"] }
|
||||
open = "5"
|
||||
|
||||
# HTTP
|
||||
|
||||
@@ -400,7 +400,7 @@ lore timeline m:99 # Shorthand for mr:99
|
||||
lore timeline "auth" -p group/repo # Scoped to a project
|
||||
lore timeline "auth" --since 30d # Only recent events
|
||||
lore timeline "migration" --depth 2 # Deeper cross-reference expansion
|
||||
lore timeline "migration" --expand-mentions # Follow 'mentioned' edges (high fan-out)
|
||||
lore timeline "migration" --no-mentions # Skip 'mentioned' edges (reduces fan-out)
|
||||
lore timeline "deploy" -n 50 # Limit event count
|
||||
lore timeline "auth" --max-seeds 5 # Fewer seed entities
|
||||
```
|
||||
@@ -414,7 +414,7 @@ The query can be either a search string (hybrid search finds matching entities)
|
||||
| `-p` / `--project` | all | Scope to a specific project (fuzzy match) |
|
||||
| `--since` | none | Only events after this date (7d, 2w, 6m, YYYY-MM-DD) |
|
||||
| `--depth` | `1` | Cross-reference expansion depth (0 = seeds only) |
|
||||
| `--expand-mentions` | off | Also follow "mentioned" edges during expansion |
|
||||
| `--no-mentions` | off | Skip "mentioned" edges during expansion (reduces fan-out) |
|
||||
| `-n` / `--limit` | `100` | Maximum events to display |
|
||||
| `--max-seeds` | `10` | Maximum seed entities from search |
|
||||
| `--max-entities` | `50` | Maximum entities discovered via cross-references |
|
||||
@@ -427,7 +427,7 @@ Each stage displays a numbered progress spinner (e.g., `[1/3] Seeding timeline..
|
||||
|
||||
1. **SEED** -- Hybrid search (FTS5 lexical + Ollama vector similarity via Reciprocal Rank Fusion) identifies the most relevant issues and MRs. Falls back to lexical-only if Ollama is unavailable. Discussion notes matching the query are also discovered and attached to their parent entities.
|
||||
2. **HYDRATE** -- Evidence notes are extracted: the top search-matched discussion notes with 200-character snippets explaining *why* each entity was surfaced. Matched discussions are collected as full thread candidates.
|
||||
3. **EXPAND** -- Breadth-first traversal over the `entity_references` graph discovers related entities via "closes", "related", and optionally "mentioned" references up to the configured depth.
|
||||
3. **EXPAND** -- Breadth-first traversal over the `entity_references` graph discovers related entities via "closes", "related", and "mentioned" references up to the configured depth. Use `--no-mentions` to exclude "mentioned" edges and reduce fan-out.
|
||||
4. **COLLECT** -- Events are gathered for all discovered entities. Event types include: creation, state changes, label adds/removes, milestone assignments, merge events, evidence notes, and full discussion threads. Events are sorted chronologically with stable tiebreaking.
|
||||
5. **RENDER** -- Events are formatted as human-readable text or structured JSON (robot mode).
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@ const COMMAND_FLAGS: &[(&str, &[&str])] = &[
|
||||
"--project",
|
||||
"--since",
|
||||
"--depth",
|
||||
"--expand-mentions",
|
||||
"--no-mentions",
|
||||
"--limit",
|
||||
"--fields",
|
||||
"--max-seeds",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use console::style;
|
||||
use crate::cli::render::{self, Theme};
|
||||
use rusqlite::Connection;
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -178,27 +178,6 @@ fn count_notes(conn: &Connection, type_filter: Option<&str>) -> Result<CountResu
|
||||
})
|
||||
}
|
||||
|
||||
fn format_number(n: i64) -> String {
|
||||
let (prefix, abs) = if n < 0 {
|
||||
("-", n.unsigned_abs())
|
||||
} else {
|
||||
("", n.unsigned_abs())
|
||||
};
|
||||
|
||||
let s = abs.to_string();
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
let mut result = String::from(prefix);
|
||||
|
||||
for (i, c) in chars.iter().enumerate() {
|
||||
if i > 0 && (chars.len() - i).is_multiple_of(3) {
|
||||
result.push(',');
|
||||
}
|
||||
result.push(*c);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CountJsonOutput {
|
||||
ok: bool,
|
||||
@@ -284,10 +263,10 @@ pub fn print_event_count_json(counts: &EventCounts, elapsed_ms: u64) {
|
||||
pub fn print_event_count(counts: &EventCounts) {
|
||||
println!(
|
||||
"{:<20} {:>8} {:>8} {:>8}",
|
||||
style("Event Type").cyan().bold(),
|
||||
style("Issues").bold(),
|
||||
style("MRs").bold(),
|
||||
style("Total").bold()
|
||||
Theme::info().bold().render("Event Type"),
|
||||
Theme::bold().render("Issues"),
|
||||
Theme::bold().render("MRs"),
|
||||
Theme::bold().render("Total")
|
||||
);
|
||||
|
||||
let state_total = counts.state_issue + counts.state_mr;
|
||||
@@ -297,33 +276,33 @@ pub fn print_event_count(counts: &EventCounts) {
|
||||
println!(
|
||||
"{:<20} {:>8} {:>8} {:>8}",
|
||||
"State events",
|
||||
format_number(counts.state_issue as i64),
|
||||
format_number(counts.state_mr as i64),
|
||||
format_number(state_total as i64)
|
||||
render::format_number(counts.state_issue as i64),
|
||||
render::format_number(counts.state_mr as i64),
|
||||
render::format_number(state_total as i64)
|
||||
);
|
||||
println!(
|
||||
"{:<20} {:>8} {:>8} {:>8}",
|
||||
"Label events",
|
||||
format_number(counts.label_issue as i64),
|
||||
format_number(counts.label_mr as i64),
|
||||
format_number(label_total as i64)
|
||||
render::format_number(counts.label_issue as i64),
|
||||
render::format_number(counts.label_mr as i64),
|
||||
render::format_number(label_total as i64)
|
||||
);
|
||||
println!(
|
||||
"{:<20} {:>8} {:>8} {:>8}",
|
||||
"Milestone events",
|
||||
format_number(counts.milestone_issue as i64),
|
||||
format_number(counts.milestone_mr as i64),
|
||||
format_number(milestone_total as i64)
|
||||
render::format_number(counts.milestone_issue as i64),
|
||||
render::format_number(counts.milestone_mr as i64),
|
||||
render::format_number(milestone_total as i64)
|
||||
);
|
||||
|
||||
let total_issues = counts.state_issue + counts.label_issue + counts.milestone_issue;
|
||||
let total_mrs = counts.state_mr + counts.label_mr + counts.milestone_mr;
|
||||
println!(
|
||||
"{:<20} {:>8} {:>8} {:>8}",
|
||||
style("Total").bold(),
|
||||
format_number(total_issues as i64),
|
||||
format_number(total_mrs as i64),
|
||||
style(format_number(counts.total() as i64)).bold()
|
||||
Theme::bold().render("Total"),
|
||||
render::format_number(total_issues as i64),
|
||||
render::format_number(total_mrs as i64),
|
||||
Theme::bold().render(&render::format_number(counts.total() as i64))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -350,57 +329,56 @@ pub fn print_count_json(result: &CountResult, elapsed_ms: u64) {
|
||||
}
|
||||
|
||||
pub fn print_count(result: &CountResult) {
|
||||
let count_str = format_number(result.count);
|
||||
let count_str = render::format_number(result.count);
|
||||
|
||||
if let Some(system_count) = result.system_count {
|
||||
println!(
|
||||
"{}: {} {}",
|
||||
style(&result.entity).cyan(),
|
||||
style(&count_str).bold(),
|
||||
style(format!(
|
||||
Theme::info().render(&result.entity),
|
||||
Theme::bold().render(&count_str),
|
||||
Theme::dim().render(&format!(
|
||||
"(excluding {} system)",
|
||||
format_number(system_count)
|
||||
render::format_number(system_count)
|
||||
))
|
||||
.dim()
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"{}: {}",
|
||||
style(&result.entity).cyan(),
|
||||
style(&count_str).bold()
|
||||
Theme::info().render(&result.entity),
|
||||
Theme::bold().render(&count_str)
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(breakdown) = &result.state_breakdown {
|
||||
println!(" opened: {}", format_number(breakdown.opened));
|
||||
println!(" opened: {}", render::format_number(breakdown.opened));
|
||||
if let Some(merged) = breakdown.merged {
|
||||
println!(" merged: {}", format_number(merged));
|
||||
println!(" merged: {}", render::format_number(merged));
|
||||
}
|
||||
println!(" closed: {}", format_number(breakdown.closed));
|
||||
println!(" closed: {}", render::format_number(breakdown.closed));
|
||||
if let Some(locked) = breakdown.locked
|
||||
&& locked > 0
|
||||
{
|
||||
println!(" locked: {}", format_number(locked));
|
||||
println!(" locked: {}", render::format_number(locked));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cli::render;
|
||||
|
||||
#[test]
|
||||
fn format_number_handles_small_numbers() {
|
||||
assert_eq!(format_number(0), "0");
|
||||
assert_eq!(format_number(1), "1");
|
||||
assert_eq!(format_number(100), "100");
|
||||
assert_eq!(format_number(999), "999");
|
||||
assert_eq!(render::format_number(0), "0");
|
||||
assert_eq!(render::format_number(1), "1");
|
||||
assert_eq!(render::format_number(100), "100");
|
||||
assert_eq!(render::format_number(999), "999");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_number_adds_thousands_separators() {
|
||||
assert_eq!(format_number(1000), "1,000");
|
||||
assert_eq!(format_number(12345), "12,345");
|
||||
assert_eq!(format_number(1234567), "1,234,567");
|
||||
assert_eq!(render::format_number(1000), "1,000");
|
||||
assert_eq!(render::format_number(12345), "12,345");
|
||||
assert_eq!(render::format_number(1234567), "1,234,567");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use console::style;
|
||||
use crate::cli::render::Theme;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::core::config::Config;
|
||||
@@ -544,32 +544,33 @@ pub fn print_doctor_results(result: &DoctorResult) {
|
||||
if result.success {
|
||||
let ollama_ok = result.checks.ollama.result.status == CheckStatus::Ok;
|
||||
if ollama_ok {
|
||||
println!("{}", style("Status: Ready").green());
|
||||
println!("{}", Theme::success().render("Status: Ready"));
|
||||
} else {
|
||||
println!(
|
||||
"{} {}",
|
||||
style("Status: Ready").green(),
|
||||
style("(lexical search available, semantic search requires Ollama)").yellow()
|
||||
Theme::success().render("Status: Ready"),
|
||||
Theme::warning()
|
||||
.render("(lexical search available, semantic search requires Ollama)")
|
||||
);
|
||||
}
|
||||
} else {
|
||||
println!("{}", style("Status: Not ready").red());
|
||||
println!("{}", Theme::error().render("Status: Not ready"));
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
fn print_check(name: &str, result: &CheckResult) {
|
||||
let symbol = match result.status {
|
||||
CheckStatus::Ok => style("✓").green(),
|
||||
CheckStatus::Warning => style("⚠").yellow(),
|
||||
CheckStatus::Error => style("✗").red(),
|
||||
CheckStatus::Ok => Theme::success().render("\u{2713}"),
|
||||
CheckStatus::Warning => Theme::warning().render("\u{26a0}"),
|
||||
CheckStatus::Error => Theme::error().render("\u{2717}"),
|
||||
};
|
||||
|
||||
let message = result.message.as_deref().unwrap_or("");
|
||||
let message_styled = match result.status {
|
||||
CheckStatus::Ok => message.to_string(),
|
||||
CheckStatus::Warning => style(message).yellow().to_string(),
|
||||
CheckStatus::Error => style(message).red().to_string(),
|
||||
CheckStatus::Warning => Theme::warning().render(message),
|
||||
CheckStatus::Error => Theme::error().render(message),
|
||||
};
|
||||
|
||||
println!(" {symbol} {:<10} {message_styled}", name);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use console::style;
|
||||
use regex::Regex;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::cli::render::Theme;
|
||||
use crate::cli::robot::RobotMeta;
|
||||
use crate::core::config::Config;
|
||||
use crate::core::db::create_connection;
|
||||
@@ -420,7 +420,7 @@ pub fn print_drift_human(response: &DriftResponse) {
|
||||
"Drift Analysis: {} #{}",
|
||||
response.entity.entity_type, response.entity.iid
|
||||
);
|
||||
println!("{}", style(&header).bold());
|
||||
println!("{}", Theme::bold().render(&header));
|
||||
println!("{}", "-".repeat(header.len().min(60)));
|
||||
println!("Title: {}", response.entity.title);
|
||||
println!("Threshold: {:.2}", response.threshold);
|
||||
@@ -428,7 +428,7 @@ pub fn print_drift_human(response: &DriftResponse) {
|
||||
println!();
|
||||
|
||||
if response.drift_detected {
|
||||
println!("{}", style("DRIFT DETECTED").red().bold());
|
||||
println!("{}", Theme::error().bold().render("DRIFT DETECTED"));
|
||||
if let Some(dp) = &response.drift_point {
|
||||
println!(
|
||||
" At note #{} by @{} ({}) - similarity {:.2}",
|
||||
@@ -439,7 +439,7 @@ pub fn print_drift_human(response: &DriftResponse) {
|
||||
println!(" Topics: {}", response.drift_topics.join(", "));
|
||||
}
|
||||
} else {
|
||||
println!("{}", style("No drift detected").green());
|
||||
println!("{}", Theme::success().render("No drift detected"));
|
||||
}
|
||||
|
||||
println!();
|
||||
@@ -447,7 +447,7 @@ pub fn print_drift_human(response: &DriftResponse) {
|
||||
|
||||
if !response.similarity_curve.is_empty() {
|
||||
println!();
|
||||
println!("{}", style("Similarity Curve:").bold());
|
||||
println!("{}", Theme::bold().render("Similarity Curve:"));
|
||||
for pt in &response.similarity_curve {
|
||||
let bar_len = ((pt.similarity.max(0.0)) * 30.0) as usize;
|
||||
let bar: String = "#".repeat(bar_len);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use console::style;
|
||||
use crate::cli::render::Theme;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::Config;
|
||||
@@ -96,16 +96,31 @@ pub async fn run_embed(
|
||||
}
|
||||
|
||||
pub fn print_embed(result: &EmbedCommandResult) {
|
||||
println!("{} Embedding complete", style("done").green().bold(),);
|
||||
if result.docs_embedded == 0 && result.failed == 0 && result.skipped == 0 {
|
||||
println!(
|
||||
" Embedded: {} documents ({} chunks)",
|
||||
result.docs_embedded, result.chunks_embedded
|
||||
"\n {} nothing to embed",
|
||||
Theme::success().bold().render("Embedding")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
println!(
|
||||
"\n {} {} documents ({} chunks)",
|
||||
Theme::success().bold().render("Embedded"),
|
||||
Theme::bold().render(&result.docs_embedded.to_string()),
|
||||
result.chunks_embedded
|
||||
);
|
||||
if result.failed > 0 {
|
||||
println!(" Failed: {}", style(result.failed).red());
|
||||
println!(
|
||||
" {}",
|
||||
Theme::error().render(&format!("{} failed", result.failed))
|
||||
);
|
||||
}
|
||||
if result.skipped > 0 {
|
||||
println!(" Skipped: {}", result.skipped);
|
||||
println!(
|
||||
" {}",
|
||||
Theme::dim().render(&format!("{} skipped", result.skipped))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use console::style;
|
||||
use crate::cli::render::Theme;
|
||||
use rusqlite::Connection;
|
||||
use serde::Serialize;
|
||||
use tracing::info;
|
||||
@@ -185,19 +185,40 @@ pub fn print_generate_docs(result: &GenerateDocsResult) {
|
||||
} else {
|
||||
"incremental"
|
||||
};
|
||||
|
||||
if result.regenerated == 0 && result.errored == 0 {
|
||||
println!(
|
||||
"{} Document generation complete ({})",
|
||||
style("done").green().bold(),
|
||||
"\n {} no documents to update ({})",
|
||||
Theme::success().bold().render("Docs"),
|
||||
mode
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Headline
|
||||
println!(
|
||||
"\n {} {} documents ({})",
|
||||
Theme::success().bold().render("Generated"),
|
||||
Theme::bold().render(&result.regenerated.to_string()),
|
||||
mode
|
||||
);
|
||||
|
||||
if result.full_mode {
|
||||
println!(" Seeded: {}", result.seeded);
|
||||
// Detail line: compact middle-dot format, zero-suppressed
|
||||
let mut details: Vec<String> = Vec::new();
|
||||
if result.full_mode && result.seeded > 0 {
|
||||
details.push(format!("{} seeded", result.seeded));
|
||||
}
|
||||
if result.unchanged > 0 {
|
||||
details.push(format!("{} unchanged", result.unchanged));
|
||||
}
|
||||
if !details.is_empty() {
|
||||
println!(" {}", Theme::dim().render(&details.join(" \u{b7} ")));
|
||||
}
|
||||
println!(" Regenerated: {}", result.regenerated);
|
||||
println!(" Unchanged: {}", result.unchanged);
|
||||
if result.errored > 0 {
|
||||
println!(" Errored: {}", style(result.errored).red());
|
||||
println!(
|
||||
" {}",
|
||||
Theme::error().render(&format!("{} errored", result.errored))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use console::style;
|
||||
use crate::cli::render::Theme;
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
use rusqlite::Connection;
|
||||
use serde::Serialize;
|
||||
@@ -293,7 +293,7 @@ async fn run_ingest_inner(
|
||||
if display.show_text {
|
||||
println!(
|
||||
"{}",
|
||||
style("Full sync: resetting cursors to fetch all data...").yellow()
|
||||
Theme::warning().render("Full sync: resetting cursors to fetch all data...")
|
||||
);
|
||||
}
|
||||
for (local_project_id, _, path) in &projects {
|
||||
@@ -341,7 +341,10 @@ async fn run_ingest_inner(
|
||||
"merge requests"
|
||||
};
|
||||
if display.show_text {
|
||||
println!("{}", style(format!("Ingesting {type_label}...")).blue());
|
||||
println!(
|
||||
"{}",
|
||||
Theme::info().render(&format!("Ingesting {type_label}..."))
|
||||
);
|
||||
println!();
|
||||
}
|
||||
|
||||
@@ -746,7 +749,7 @@ fn print_issue_project_summary(path: &str, result: &IngestProjectResult) {
|
||||
|
||||
println!(
|
||||
" {}: {} issues fetched{}",
|
||||
style(path).cyan(),
|
||||
Theme::info().render(path),
|
||||
result.issues_upserted,
|
||||
labels_str
|
||||
);
|
||||
@@ -761,7 +764,7 @@ fn print_issue_project_summary(path: &str, result: &IngestProjectResult) {
|
||||
if result.issues_skipped_discussion_sync > 0 {
|
||||
println!(
|
||||
" {} unchanged issues (discussion sync skipped)",
|
||||
style(result.issues_skipped_discussion_sync).dim()
|
||||
Theme::dim().render(&result.issues_skipped_discussion_sync.to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -784,7 +787,7 @@ fn print_mr_project_summary(path: &str, result: &IngestMrProjectResult) {
|
||||
|
||||
println!(
|
||||
" {}: {} MRs fetched{}{}",
|
||||
style(path).cyan(),
|
||||
Theme::info().render(path),
|
||||
result.mrs_upserted,
|
||||
labels_str,
|
||||
assignees_str
|
||||
@@ -808,7 +811,7 @@ fn print_mr_project_summary(path: &str, result: &IngestMrProjectResult) {
|
||||
if result.mrs_skipped_discussion_sync > 0 {
|
||||
println!(
|
||||
" {} unchanged MRs (discussion sync skipped)",
|
||||
style(result.mrs_skipped_discussion_sync).dim()
|
||||
Theme::dim().render(&result.mrs_skipped_discussion_sync.to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -942,21 +945,19 @@ pub fn print_ingest_summary(result: &IngestResult) {
|
||||
if result.resource_type == "issues" {
|
||||
println!(
|
||||
"{}",
|
||||
style(format!(
|
||||
Theme::success().render(&format!(
|
||||
"Total: {} issues, {} discussions, {} notes",
|
||||
result.issues_upserted, result.discussions_fetched, result.notes_upserted
|
||||
))
|
||||
.green()
|
||||
);
|
||||
|
||||
if result.issues_skipped_discussion_sync > 0 {
|
||||
println!(
|
||||
"{}",
|
||||
style(format!(
|
||||
Theme::dim().render(&format!(
|
||||
"Skipped discussion sync for {} unchanged issues.",
|
||||
result.issues_skipped_discussion_sync
|
||||
))
|
||||
.dim()
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -968,24 +969,22 @@ pub fn print_ingest_summary(result: &IngestResult) {
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
style(format!(
|
||||
Theme::success().render(&format!(
|
||||
"Total: {} MRs, {} discussions, {} notes{}",
|
||||
result.mrs_upserted,
|
||||
result.discussions_fetched,
|
||||
result.notes_upserted,
|
||||
diffnotes_str
|
||||
))
|
||||
.green()
|
||||
);
|
||||
|
||||
if result.mrs_skipped_discussion_sync > 0 {
|
||||
println!(
|
||||
"{}",
|
||||
style(format!(
|
||||
Theme::dim().render(&format!(
|
||||
"Skipped discussion sync for {} unchanged MRs.",
|
||||
result.mrs_skipped_discussion_sync
|
||||
))
|
||||
.dim()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1006,8 +1005,8 @@ pub fn print_ingest_summary(result: &IngestResult) {
|
||||
pub fn print_dry_run_preview(preview: &DryRunPreview) {
|
||||
println!(
|
||||
"{} {}",
|
||||
style("Dry Run Preview").cyan().bold(),
|
||||
style("(no changes will be made)").yellow()
|
||||
Theme::info().bold().render("Dry Run Preview"),
|
||||
Theme::warning().render("(no changes will be made)")
|
||||
);
|
||||
println!();
|
||||
|
||||
@@ -1017,27 +1016,31 @@ pub fn print_dry_run_preview(preview: &DryRunPreview) {
|
||||
"merge requests"
|
||||
};
|
||||
|
||||
println!(" Resource type: {}", style(type_label).white().bold());
|
||||
println!(" Resource type: {}", Theme::bold().render(type_label));
|
||||
println!(
|
||||
" Sync mode: {}",
|
||||
if preview.sync_mode == "full" {
|
||||
style("full (all data will be re-fetched)").yellow()
|
||||
Theme::warning().render("full (all data will be re-fetched)")
|
||||
} else {
|
||||
style("incremental (only changes since last sync)").green()
|
||||
Theme::success().render("incremental (only changes since last sync)")
|
||||
}
|
||||
);
|
||||
println!(" Projects: {}", preview.projects.len());
|
||||
println!();
|
||||
|
||||
println!("{}", style("Projects to sync:").cyan().bold());
|
||||
println!("{}", Theme::info().bold().render("Projects to sync:"));
|
||||
for project in &preview.projects {
|
||||
let sync_status = if !project.has_cursor {
|
||||
style("initial sync").yellow()
|
||||
Theme::warning().render("initial sync")
|
||||
} else {
|
||||
style("incremental").green()
|
||||
Theme::success().render("incremental")
|
||||
};
|
||||
|
||||
println!(" {} ({})", style(&project.path).white(), sync_status);
|
||||
println!(
|
||||
" {} ({})",
|
||||
Theme::bold().render(&project.path),
|
||||
sync_status
|
||||
);
|
||||
println!(" Existing {}: {}", type_label, project.existing_count);
|
||||
|
||||
if let Some(ref last_synced) = project.last_synced {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use comfy_table::{Attribute, Cell, Color, ContentArrangement, Table};
|
||||
use crate::cli::render::{self, Align, StyledCell, Table as LoreTable, Theme};
|
||||
use rusqlite::Connection;
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -9,39 +9,7 @@ use crate::core::error::{LoreError, Result};
|
||||
use crate::core::path_resolver::escape_like as note_escape_like;
|
||||
use crate::core::paths::get_db_path;
|
||||
use crate::core::project::resolve_project;
|
||||
use crate::core::time::{ms_to_iso, now_ms, parse_since};
|
||||
|
||||
fn colored_cell(content: impl std::fmt::Display, color: Color) -> Cell {
|
||||
let cell = Cell::new(content);
|
||||
if console::colors_enabled() {
|
||||
cell.fg(color)
|
||||
} else {
|
||||
cell
|
||||
}
|
||||
}
|
||||
|
||||
fn colored_cell_hex(content: &str, hex: Option<&str>) -> Cell {
|
||||
if !console::colors_enabled() {
|
||||
return Cell::new(content);
|
||||
}
|
||||
let Some(hex) = hex else {
|
||||
return Cell::new(content);
|
||||
};
|
||||
let hex = hex.trim_start_matches('#');
|
||||
if hex.len() != 6 {
|
||||
return Cell::new(content);
|
||||
}
|
||||
let Ok(r) = u8::from_str_radix(&hex[0..2], 16) else {
|
||||
return Cell::new(content);
|
||||
};
|
||||
let Ok(g) = u8::from_str_radix(&hex[2..4], 16) else {
|
||||
return Cell::new(content);
|
||||
};
|
||||
let Ok(b) = u8::from_str_radix(&hex[4..6], 16) else {
|
||||
return Cell::new(content);
|
||||
};
|
||||
Cell::new(content).fg(Color::Rgb { r, g, b })
|
||||
}
|
||||
use crate::core::time::{ms_to_iso, parse_since};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IssueListRow {
|
||||
@@ -669,60 +637,6 @@ fn query_mrs(conn: &Connection, filters: &MrListFilters) -> Result<MrListResult>
|
||||
Ok(MrListResult { mrs, total_count })
|
||||
}
|
||||
|
||||
fn format_relative_time(ms_epoch: i64) -> String {
|
||||
let now = now_ms();
|
||||
let diff = now - ms_epoch;
|
||||
|
||||
if diff < 0 {
|
||||
return "in the future".to_string();
|
||||
}
|
||||
|
||||
match diff {
|
||||
d if d < 60_000 => "just now".to_string(),
|
||||
d if d < 3_600_000 => format!("{} min ago", d / 60_000),
|
||||
d if d < 86_400_000 => {
|
||||
let n = d / 3_600_000;
|
||||
format!("{n} {} ago", if n == 1 { "hour" } else { "hours" })
|
||||
}
|
||||
d if d < 604_800_000 => {
|
||||
let n = d / 86_400_000;
|
||||
format!("{n} {} ago", if n == 1 { "day" } else { "days" })
|
||||
}
|
||||
d if d < 2_592_000_000 => {
|
||||
let n = d / 604_800_000;
|
||||
format!("{n} {} ago", if n == 1 { "week" } else { "weeks" })
|
||||
}
|
||||
_ => {
|
||||
let n = diff / 2_592_000_000;
|
||||
format!("{n} {} ago", if n == 1 { "month" } else { "months" })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_with_ellipsis(s: &str, max_width: usize) -> String {
|
||||
if s.chars().count() <= max_width {
|
||||
s.to_string()
|
||||
} else {
|
||||
let truncated: String = s.chars().take(max_width.saturating_sub(3)).collect();
|
||||
format!("{truncated}...")
|
||||
}
|
||||
}
|
||||
|
||||
fn format_labels(labels: &[String], max_shown: usize) -> String {
|
||||
if labels.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let shown: Vec<&str> = labels.iter().take(max_shown).map(|s| s.as_str()).collect();
|
||||
let overflow = labels.len().saturating_sub(max_shown);
|
||||
|
||||
if overflow > 0 {
|
||||
format!("[{} +{}]", shown.join(", "), overflow)
|
||||
} else {
|
||||
format!("[{}]", shown.join(", "))
|
||||
}
|
||||
}
|
||||
|
||||
fn format_assignees(assignees: &[String]) -> String {
|
||||
if assignees.is_empty() {
|
||||
return "-".to_string();
|
||||
@@ -732,7 +646,7 @@ fn format_assignees(assignees: &[String]) -> String {
|
||||
let shown: Vec<String> = assignees
|
||||
.iter()
|
||||
.take(max_shown)
|
||||
.map(|s| format!("@{}", truncate_with_ellipsis(s, 10)))
|
||||
.map(|s| format!("@{}", render::truncate(s, 10)))
|
||||
.collect();
|
||||
let overflow = assignees.len().saturating_sub(max_shown);
|
||||
|
||||
@@ -757,7 +671,7 @@ fn format_discussions(total: i64, unresolved: i64) -> String {
|
||||
|
||||
fn format_branches(target: &str, source: &str, max_width: usize) -> String {
|
||||
let full = format!("{} <- {}", target, source);
|
||||
truncate_with_ellipsis(&full, max_width)
|
||||
render::truncate(&full, max_width)
|
||||
}
|
||||
|
||||
pub fn print_list_issues(result: &ListResult) {
|
||||
@@ -774,64 +688,55 @@ pub fn print_list_issues(result: &ListResult) {
|
||||
|
||||
let has_any_status = result.issues.iter().any(|i| i.status_name.is_some());
|
||||
|
||||
let mut header = vec![
|
||||
Cell::new("IID").add_attribute(Attribute::Bold),
|
||||
Cell::new("Title").add_attribute(Attribute::Bold),
|
||||
Cell::new("State").add_attribute(Attribute::Bold),
|
||||
];
|
||||
let mut headers = vec!["IID", "Title", "State"];
|
||||
if has_any_status {
|
||||
header.push(Cell::new("Status").add_attribute(Attribute::Bold));
|
||||
headers.push("Status");
|
||||
}
|
||||
header.extend([
|
||||
Cell::new("Assignee").add_attribute(Attribute::Bold),
|
||||
Cell::new("Labels").add_attribute(Attribute::Bold),
|
||||
Cell::new("Disc").add_attribute(Attribute::Bold),
|
||||
Cell::new("Updated").add_attribute(Attribute::Bold),
|
||||
]);
|
||||
headers.extend(["Assignee", "Labels", "Disc", "Updated"]);
|
||||
|
||||
let mut table = Table::new();
|
||||
table
|
||||
.set_content_arrangement(ContentArrangement::Dynamic)
|
||||
.set_header(header);
|
||||
let mut table = LoreTable::new().headers(&headers).align(0, Align::Right);
|
||||
|
||||
for issue in &result.issues {
|
||||
let title = truncate_with_ellipsis(&issue.title, 45);
|
||||
let relative_time = format_relative_time(issue.updated_at);
|
||||
let labels = format_labels(&issue.labels, 2);
|
||||
let title = render::truncate(&issue.title, 45);
|
||||
let relative_time = render::format_relative_time(issue.updated_at);
|
||||
let labels = render::format_labels(&issue.labels, 2);
|
||||
let assignee = format_assignees(&issue.assignees);
|
||||
let discussions = format_discussions(issue.discussion_count, issue.unresolved_count);
|
||||
|
||||
let state_cell = if issue.state == "opened" {
|
||||
colored_cell(&issue.state, Color::Green)
|
||||
StyledCell::styled(&issue.state, Theme::success())
|
||||
} else {
|
||||
colored_cell(&issue.state, Color::DarkGrey)
|
||||
StyledCell::styled(&issue.state, Theme::dim())
|
||||
};
|
||||
|
||||
let mut row = vec![
|
||||
colored_cell(format!("#{}", issue.iid), Color::Cyan),
|
||||
Cell::new(title),
|
||||
StyledCell::styled(format!("#{}", issue.iid), Theme::info()),
|
||||
StyledCell::plain(title),
|
||||
state_cell,
|
||||
];
|
||||
if has_any_status {
|
||||
match &issue.status_name {
|
||||
Some(status) => {
|
||||
row.push(colored_cell_hex(status, issue.status_color.as_deref()));
|
||||
row.push(StyledCell::plain(render::style_with_hex(
|
||||
status,
|
||||
issue.status_color.as_deref(),
|
||||
)));
|
||||
}
|
||||
None => {
|
||||
row.push(Cell::new(""));
|
||||
row.push(StyledCell::plain(""));
|
||||
}
|
||||
}
|
||||
}
|
||||
row.extend([
|
||||
colored_cell(assignee, Color::Magenta),
|
||||
colored_cell(labels, Color::Yellow),
|
||||
Cell::new(discussions),
|
||||
colored_cell(relative_time, Color::DarkGrey),
|
||||
StyledCell::styled(assignee, Theme::accent()),
|
||||
StyledCell::styled(labels, Theme::warning()),
|
||||
StyledCell::plain(discussions),
|
||||
StyledCell::styled(relative_time, Theme::dim()),
|
||||
]);
|
||||
table.add_row(row);
|
||||
}
|
||||
|
||||
println!("{table}");
|
||||
println!("{}", table.render());
|
||||
}
|
||||
|
||||
pub fn print_list_issues_json(result: &ListResult, elapsed_ms: u64, fields: Option<&[String]>) {
|
||||
@@ -883,53 +788,46 @@ pub fn print_list_mrs(result: &MrListResult) {
|
||||
result.total_count
|
||||
);
|
||||
|
||||
let mut table = Table::new();
|
||||
table
|
||||
.set_content_arrangement(ContentArrangement::Dynamic)
|
||||
.set_header(vec![
|
||||
Cell::new("IID").add_attribute(Attribute::Bold),
|
||||
Cell::new("Title").add_attribute(Attribute::Bold),
|
||||
Cell::new("State").add_attribute(Attribute::Bold),
|
||||
Cell::new("Author").add_attribute(Attribute::Bold),
|
||||
Cell::new("Branches").add_attribute(Attribute::Bold),
|
||||
Cell::new("Disc").add_attribute(Attribute::Bold),
|
||||
Cell::new("Updated").add_attribute(Attribute::Bold),
|
||||
]);
|
||||
let mut table = LoreTable::new()
|
||||
.headers(&[
|
||||
"IID", "Title", "State", "Author", "Branches", "Disc", "Updated",
|
||||
])
|
||||
.align(0, Align::Right);
|
||||
|
||||
for mr in &result.mrs {
|
||||
let title = if mr.draft {
|
||||
format!("[DRAFT] {}", truncate_with_ellipsis(&mr.title, 38))
|
||||
format!("[DRAFT] {}", render::truncate(&mr.title, 38))
|
||||
} else {
|
||||
truncate_with_ellipsis(&mr.title, 45)
|
||||
render::truncate(&mr.title, 45)
|
||||
};
|
||||
|
||||
let relative_time = format_relative_time(mr.updated_at);
|
||||
let relative_time = render::format_relative_time(mr.updated_at);
|
||||
let branches = format_branches(&mr.target_branch, &mr.source_branch, 25);
|
||||
let discussions = format_discussions(mr.discussion_count, mr.unresolved_count);
|
||||
|
||||
let state_cell = match mr.state.as_str() {
|
||||
"opened" => colored_cell(&mr.state, Color::Green),
|
||||
"merged" => colored_cell(&mr.state, Color::Magenta),
|
||||
"closed" => colored_cell(&mr.state, Color::Red),
|
||||
"locked" => colored_cell(&mr.state, Color::Yellow),
|
||||
_ => colored_cell(&mr.state, Color::DarkGrey),
|
||||
"opened" => StyledCell::styled(&mr.state, Theme::success()),
|
||||
"merged" => StyledCell::styled(&mr.state, Theme::accent()),
|
||||
"closed" => StyledCell::styled(&mr.state, Theme::error()),
|
||||
"locked" => StyledCell::styled(&mr.state, Theme::warning()),
|
||||
_ => StyledCell::styled(&mr.state, Theme::dim()),
|
||||
};
|
||||
|
||||
table.add_row(vec![
|
||||
colored_cell(format!("!{}", mr.iid), Color::Cyan),
|
||||
Cell::new(title),
|
||||
StyledCell::styled(format!("!{}", mr.iid), Theme::info()),
|
||||
StyledCell::plain(title),
|
||||
state_cell,
|
||||
colored_cell(
|
||||
format!("@{}", truncate_with_ellipsis(&mr.author_username, 12)),
|
||||
Color::Magenta,
|
||||
StyledCell::styled(
|
||||
format!("@{}", render::truncate(&mr.author_username, 12)),
|
||||
Theme::accent(),
|
||||
),
|
||||
colored_cell(branches, Color::Blue),
|
||||
Cell::new(discussions),
|
||||
colored_cell(relative_time, Color::DarkGrey),
|
||||
StyledCell::styled(branches, Theme::info()),
|
||||
StyledCell::plain(discussions),
|
||||
StyledCell::styled(relative_time, Theme::dim()),
|
||||
]);
|
||||
}
|
||||
|
||||
println!("{table}");
|
||||
println!("{}", table.render());
|
||||
}
|
||||
|
||||
pub fn print_list_mrs_json(result: &MrListResult, elapsed_ms: u64, fields: Option<&[String]>) {
|
||||
@@ -1016,18 +914,17 @@ pub fn print_list_notes(result: &NoteListResult) {
|
||||
result.total_count
|
||||
);
|
||||
|
||||
let mut table = Table::new();
|
||||
table
|
||||
.set_content_arrangement(ContentArrangement::Dynamic)
|
||||
.set_header(vec![
|
||||
Cell::new("ID").add_attribute(Attribute::Bold),
|
||||
Cell::new("Author").add_attribute(Attribute::Bold),
|
||||
Cell::new("Type").add_attribute(Attribute::Bold),
|
||||
Cell::new("Body").add_attribute(Attribute::Bold),
|
||||
Cell::new("Path:Line").add_attribute(Attribute::Bold),
|
||||
Cell::new("Parent").add_attribute(Attribute::Bold),
|
||||
Cell::new("Created").add_attribute(Attribute::Bold),
|
||||
]);
|
||||
let mut table = LoreTable::new()
|
||||
.headers(&[
|
||||
"ID",
|
||||
"Author",
|
||||
"Type",
|
||||
"Body",
|
||||
"Path:Line",
|
||||
"Parent",
|
||||
"Created",
|
||||
])
|
||||
.align(0, Align::Right);
|
||||
|
||||
for note in &result.notes {
|
||||
let body = note
|
||||
@@ -1037,24 +934,24 @@ pub fn print_list_notes(result: &NoteListResult) {
|
||||
.unwrap_or_default();
|
||||
let path = format_note_path(note.position_new_path.as_deref(), note.position_new_line);
|
||||
let parent = format_note_parent(note.noteable_type.as_deref(), note.parent_iid);
|
||||
let relative_time = format_relative_time(note.created_at);
|
||||
let relative_time = render::format_relative_time(note.created_at);
|
||||
let note_type = format_note_type(note.note_type.as_deref());
|
||||
|
||||
table.add_row(vec![
|
||||
colored_cell(note.gitlab_id, Color::Cyan),
|
||||
colored_cell(
|
||||
format!("@{}", truncate_with_ellipsis(¬e.author_username, 12)),
|
||||
Color::Magenta,
|
||||
StyledCell::styled(note.gitlab_id.to_string(), Theme::info()),
|
||||
StyledCell::styled(
|
||||
format!("@{}", render::truncate(¬e.author_username, 12)),
|
||||
Theme::accent(),
|
||||
),
|
||||
Cell::new(note_type),
|
||||
Cell::new(body),
|
||||
Cell::new(path),
|
||||
Cell::new(parent),
|
||||
colored_cell(relative_time, Color::DarkGrey),
|
||||
StyledCell::plain(note_type),
|
||||
StyledCell::plain(body),
|
||||
StyledCell::plain(path),
|
||||
StyledCell::plain(parent),
|
||||
StyledCell::styled(relative_time, Theme::dim()),
|
||||
]);
|
||||
}
|
||||
|
||||
println!("{table}");
|
||||
println!("{}", table.render());
|
||||
}
|
||||
|
||||
pub fn print_list_notes_json(result: &NoteListResult, elapsed_ms: u64, fields: Option<&[String]>) {
|
||||
|
||||
@@ -1,47 +1,52 @@
|
||||
use super::*;
|
||||
use crate::cli::render;
|
||||
use crate::core::time::now_ms;
|
||||
|
||||
#[test]
|
||||
fn truncate_leaves_short_strings_alone() {
|
||||
assert_eq!(truncate_with_ellipsis("short", 10), "short");
|
||||
assert_eq!(render::truncate("short", 10), "short");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_adds_ellipsis_to_long_strings() {
|
||||
assert_eq!(
|
||||
truncate_with_ellipsis("this is a very long title", 15),
|
||||
render::truncate("this is a very long title", 15),
|
||||
"this is a ve..."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_handles_exact_length() {
|
||||
assert_eq!(truncate_with_ellipsis("exactly10!", 10), "exactly10!");
|
||||
assert_eq!(render::truncate("exactly10!", 10), "exactly10!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_time_formats_correctly() {
|
||||
let now = now_ms();
|
||||
|
||||
assert_eq!(format_relative_time(now - 30_000), "just now");
|
||||
assert_eq!(format_relative_time(now - 120_000), "2 min ago");
|
||||
assert_eq!(format_relative_time(now - 7_200_000), "2 hours ago");
|
||||
assert_eq!(format_relative_time(now - 172_800_000), "2 days ago");
|
||||
assert_eq!(render::format_relative_time(now - 30_000), "just now");
|
||||
assert_eq!(render::format_relative_time(now - 120_000), "2 min ago");
|
||||
assert_eq!(render::format_relative_time(now - 7_200_000), "2 hours ago");
|
||||
assert_eq!(
|
||||
render::format_relative_time(now - 172_800_000),
|
||||
"2 days ago"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_labels_empty() {
|
||||
assert_eq!(format_labels(&[], 2), "");
|
||||
assert_eq!(render::format_labels(&[], 2), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_labels_single() {
|
||||
assert_eq!(format_labels(&["bug".to_string()], 2), "[bug]");
|
||||
assert_eq!(render::format_labels(&["bug".to_string()], 2), "[bug]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_labels_multiple() {
|
||||
let labels = vec!["bug".to_string(), "urgent".to_string()];
|
||||
assert_eq!(format_labels(&labels, 2), "[bug, urgent]");
|
||||
assert_eq!(render::format_labels(&labels, 2), "[bug, urgent]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -52,7 +57,7 @@ fn format_labels_overflow() {
|
||||
"wip".to_string(),
|
||||
"blocked".to_string(),
|
||||
];
|
||||
assert_eq!(format_labels(&labels, 2), "[bug, urgent +2]");
|
||||
assert_eq!(render::format_labels(&labels, 2), "[bug, urgent +2]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use console::style;
|
||||
use crate::cli::render::Theme;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::Config;
|
||||
@@ -309,68 +309,94 @@ fn parse_json_array(json: &str) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Render FTS snippet with `<mark>` tags as terminal bold+underline.
|
||||
fn render_snippet(snippet: &str) -> String {
|
||||
let mut result = String::new();
|
||||
let mut remaining = snippet;
|
||||
while let Some(start) = remaining.find("<mark>") {
|
||||
result.push_str(&remaining[..start]);
|
||||
remaining = &remaining[start + 6..];
|
||||
if let Some(end) = remaining.find("</mark>") {
|
||||
let highlighted = &remaining[..end];
|
||||
result.push_str(&Theme::bold().underline().render(highlighted));
|
||||
remaining = &remaining[end + 7..];
|
||||
}
|
||||
}
|
||||
result.push_str(remaining);
|
||||
result
|
||||
}
|
||||
|
||||
pub fn print_search_results(response: &SearchResponse) {
|
||||
if !response.warnings.is_empty() {
|
||||
for w in &response.warnings {
|
||||
eprintln!("{} {}", style("Warning:").yellow(), w);
|
||||
eprintln!("{} {}", Theme::warning().render("Warning:"), w);
|
||||
}
|
||||
}
|
||||
|
||||
if response.results.is_empty() {
|
||||
println!("No results found for '{}'", style(&response.query).bold());
|
||||
println!(
|
||||
"No results found for '{}'",
|
||||
Theme::bold().render(&response.query)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
println!(
|
||||
"{} results for '{}' ({})",
|
||||
response.total_results,
|
||||
style(&response.query).bold(),
|
||||
response.mode
|
||||
"\n {} results for '{}' {}",
|
||||
Theme::bold().render(&response.total_results.to_string()),
|
||||
Theme::bold().render(&response.query),
|
||||
Theme::dim().render(&format!("({})", response.mode))
|
||||
);
|
||||
println!();
|
||||
|
||||
for (i, result) in response.results.iter().enumerate() {
|
||||
let type_prefix = match result.source_type.as_str() {
|
||||
"issue" => "Issue",
|
||||
"merge_request" => "MR",
|
||||
"discussion" => "Discussion",
|
||||
"note" => "Note",
|
||||
_ => &result.source_type,
|
||||
let type_badge = match result.source_type.as_str() {
|
||||
"issue" => Theme::info().render("issue"),
|
||||
"merge_request" => Theme::accent().render("mr"),
|
||||
"discussion" => Theme::info().render("disc"),
|
||||
"note" => Theme::info().render("note"),
|
||||
_ => Theme::dim().render(&result.source_type),
|
||||
};
|
||||
|
||||
// Title line: rank, type badge, title
|
||||
println!(
|
||||
"[{}] {} - {} (score: {:.2})",
|
||||
i + 1,
|
||||
style(type_prefix).cyan(),
|
||||
result.title,
|
||||
result.score
|
||||
" {} {} {}",
|
||||
Theme::dim().render(&format!("{:>2}.", i + 1)),
|
||||
type_badge,
|
||||
Theme::bold().render(&result.title)
|
||||
);
|
||||
|
||||
if let Some(ref url) = result.url {
|
||||
println!(" {}", style(url).dim());
|
||||
// Metadata: project, author, labels — compact middle-dot line
|
||||
let mut meta_parts: Vec<String> = Vec::new();
|
||||
meta_parts.push(result.project_path.clone());
|
||||
if let Some(ref author) = result.author {
|
||||
meta_parts.push(format!("@{author}"));
|
||||
}
|
||||
|
||||
println!(
|
||||
" {} | {}",
|
||||
style(&result.project_path).dim(),
|
||||
result
|
||||
.author
|
||||
.as_deref()
|
||||
.map(|a| format!("@{}", a))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
|
||||
if !result.labels.is_empty() {
|
||||
println!(" Labels: {}", result.labels.join(", "));
|
||||
let label_str = if result.labels.len() <= 3 {
|
||||
result.labels.join(", ")
|
||||
} else {
|
||||
format!(
|
||||
"{} +{}",
|
||||
result.labels[..2].join(", "),
|
||||
result.labels.len() - 2
|
||||
)
|
||||
};
|
||||
meta_parts.push(label_str);
|
||||
}
|
||||
println!(
|
||||
" {}",
|
||||
Theme::dim().render(&meta_parts.join(" \u{b7} "))
|
||||
);
|
||||
|
||||
let clean_snippet = result.snippet.replace("<mark>", "").replace("</mark>", "");
|
||||
println!(" {}", style(clean_snippet).dim());
|
||||
// Snippet with proper highlighting
|
||||
let rendered = render_snippet(&result.snippet);
|
||||
println!(" {}", Theme::dim().render(&rendered));
|
||||
|
||||
if let Some(ref explain) = result.explain {
|
||||
println!(
|
||||
" {} vector_rank={} fts_rank={} rrf_score={:.6}",
|
||||
style("[explain]").magenta(),
|
||||
" {} vec={} fts={} rrf={:.4}",
|
||||
Theme::accent().render("explain"),
|
||||
explain
|
||||
.vector_rank
|
||||
.map(|r| r.to_string())
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use console::style;
|
||||
use crate::cli::render::{self, Theme};
|
||||
use rusqlite::Connection;
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -606,65 +606,37 @@ fn get_mr_discussions(conn: &Connection, mr_id: i64) -> Result<Vec<MrDiscussionD
|
||||
}
|
||||
|
||||
fn format_date(ms: i64) -> String {
|
||||
let iso = ms_to_iso(ms);
|
||||
iso.split('T').next().unwrap_or(&iso).to_string()
|
||||
render::format_date(ms)
|
||||
}
|
||||
|
||||
fn wrap_text(text: &str, width: usize, indent: &str) -> String {
|
||||
let mut result = String::new();
|
||||
let mut current_line = String::new();
|
||||
|
||||
for word in text.split_whitespace() {
|
||||
if current_line.is_empty() {
|
||||
current_line = word.to_string();
|
||||
} else if current_line.len() + 1 + word.len() <= width {
|
||||
current_line.push(' ');
|
||||
current_line.push_str(word);
|
||||
} else {
|
||||
if !result.is_empty() {
|
||||
result.push('\n');
|
||||
result.push_str(indent);
|
||||
}
|
||||
result.push_str(¤t_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(¤t_line);
|
||||
}
|
||||
|
||||
result
|
||||
render::wrap_indent(text, width, indent)
|
||||
}
|
||||
|
||||
pub fn print_show_issue(issue: &IssueDetail) {
|
||||
let header = format!("Issue #{}: {}", issue.iid, issue.title);
|
||||
println!("{}", style(&header).bold());
|
||||
println!("{}", "━".repeat(header.len().min(80)));
|
||||
println!("{}", Theme::bold().render(&header));
|
||||
println!("{}", "\u{2501}".repeat(header.len().min(80)));
|
||||
println!();
|
||||
|
||||
println!("Ref: {}", style(&issue.references_full).dim());
|
||||
println!("Project: {}", style(&issue.project_path).cyan());
|
||||
println!("Ref: {}", Theme::dim().render(&issue.references_full));
|
||||
println!("Project: {}", Theme::info().render(&issue.project_path));
|
||||
|
||||
let state_styled = if issue.state == "opened" {
|
||||
style(&issue.state).green()
|
||||
Theme::success().render(&issue.state)
|
||||
} else {
|
||||
style(&issue.state).dim()
|
||||
Theme::dim().render(&issue.state)
|
||||
};
|
||||
println!("State: {}", state_styled);
|
||||
|
||||
if issue.confidential {
|
||||
println!(" {}", style("CONFIDENTIAL").red().bold());
|
||||
println!(" {}", Theme::error().bold().render("CONFIDENTIAL"));
|
||||
}
|
||||
|
||||
if let Some(status) = &issue.status_name {
|
||||
println!(
|
||||
"Status: {}",
|
||||
style_with_hex(status, issue.status_color.as_deref())
|
||||
render::style_with_hex(status, issue.status_color.as_deref())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -705,37 +677,37 @@ pub fn print_show_issue(issue: &IssueDetail) {
|
||||
}
|
||||
|
||||
if issue.labels.is_empty() {
|
||||
println!("Labels: {}", style("(none)").dim());
|
||||
println!("Labels: {}", Theme::dim().render("(none)"));
|
||||
} else {
|
||||
println!("Labels: {}", issue.labels.join(", "));
|
||||
}
|
||||
|
||||
if !issue.closing_merge_requests.is_empty() {
|
||||
println!();
|
||||
println!("{}", style("Development:").bold());
|
||||
println!("{}", Theme::bold().render("Development:"));
|
||||
for mr in &issue.closing_merge_requests {
|
||||
let state_indicator = match mr.state.as_str() {
|
||||
"merged" => style(&mr.state).green(),
|
||||
"opened" => style(&mr.state).cyan(),
|
||||
"closed" => style(&mr.state).red(),
|
||||
_ => style(&mr.state).dim(),
|
||||
"merged" => Theme::success().render(&mr.state),
|
||||
"opened" => Theme::info().render(&mr.state),
|
||||
"closed" => Theme::error().render(&mr.state),
|
||||
_ => Theme::dim().render(&mr.state),
|
||||
};
|
||||
println!(" !{} {} ({})", mr.iid, mr.title, state_indicator);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(url) = &issue.web_url {
|
||||
println!("URL: {}", style(url).dim());
|
||||
println!("URL: {}", Theme::dim().render(url));
|
||||
}
|
||||
|
||||
println!();
|
||||
|
||||
println!("{}", style("Description:").bold());
|
||||
println!("{}", Theme::bold().render("Description:"));
|
||||
if let Some(desc) = &issue.description {
|
||||
let wrapped = wrap_text(desc, 76, " ");
|
||||
println!(" {}", wrapped);
|
||||
} else {
|
||||
println!(" {}", style("(no description)").dim());
|
||||
println!(" {}", Theme::dim().render("(no description)"));
|
||||
}
|
||||
|
||||
println!();
|
||||
@@ -747,11 +719,11 @@ pub fn print_show_issue(issue: &IssueDetail) {
|
||||
.collect();
|
||||
|
||||
if user_discussions.is_empty() {
|
||||
println!("{}", style("Discussions: (none)").dim());
|
||||
println!("{}", Theme::dim().render("Discussions: (none)"));
|
||||
} else {
|
||||
println!(
|
||||
"{}",
|
||||
style(format!("Discussions ({}):", user_discussions.len())).bold()
|
||||
Theme::bold().render(&format!("Discussions ({}):", user_discussions.len()))
|
||||
);
|
||||
println!();
|
||||
|
||||
@@ -762,7 +734,7 @@ pub fn print_show_issue(issue: &IssueDetail) {
|
||||
if let Some(first_note) = user_notes.first() {
|
||||
println!(
|
||||
" {} ({}):",
|
||||
style(format!("@{}", first_note.author_username)).cyan(),
|
||||
Theme::info().render(&format!("@{}", first_note.author_username)),
|
||||
format_date(first_note.created_at)
|
||||
);
|
||||
let wrapped = wrap_text(&first_note.body, 72, " ");
|
||||
@@ -772,7 +744,7 @@ pub fn print_show_issue(issue: &IssueDetail) {
|
||||
for reply in user_notes.iter().skip(1) {
|
||||
println!(
|
||||
" {} ({}):",
|
||||
style(format!("@{}", reply.author_username)).cyan(),
|
||||
Theme::info().render(&format!("@{}", reply.author_username)),
|
||||
format_date(reply.created_at)
|
||||
);
|
||||
let wrapped = wrap_text(&reply.body, 68, " ");
|
||||
@@ -787,24 +759,24 @@ pub fn print_show_issue(issue: &IssueDetail) {
|
||||
pub fn print_show_mr(mr: &MrDetail) {
|
||||
let draft_prefix = if mr.draft { "[Draft] " } else { "" };
|
||||
let header = format!("MR !{}: {}{}", mr.iid, draft_prefix, mr.title);
|
||||
println!("{}", style(&header).bold());
|
||||
println!("{}", "━".repeat(header.len().min(80)));
|
||||
println!("{}", Theme::bold().render(&header));
|
||||
println!("{}", "\u{2501}".repeat(header.len().min(80)));
|
||||
println!();
|
||||
|
||||
println!("Project: {}", style(&mr.project_path).cyan());
|
||||
println!("Project: {}", Theme::info().render(&mr.project_path));
|
||||
|
||||
let state_styled = match mr.state.as_str() {
|
||||
"opened" => style(&mr.state).green(),
|
||||
"merged" => style(&mr.state).magenta(),
|
||||
"closed" => style(&mr.state).red(),
|
||||
_ => style(&mr.state).dim(),
|
||||
"opened" => Theme::success().render(&mr.state),
|
||||
"merged" => Theme::accent().render(&mr.state),
|
||||
"closed" => Theme::error().render(&mr.state),
|
||||
_ => Theme::dim().render(&mr.state),
|
||||
};
|
||||
println!("State: {}", state_styled);
|
||||
|
||||
println!(
|
||||
"Branches: {} -> {}",
|
||||
style(&mr.source_branch).cyan(),
|
||||
style(&mr.target_branch).yellow()
|
||||
Theme::info().render(&mr.source_branch),
|
||||
Theme::warning().render(&mr.target_branch)
|
||||
);
|
||||
|
||||
println!("Author: @{}", mr.author_username);
|
||||
@@ -843,23 +815,23 @@ pub fn print_show_mr(mr: &MrDetail) {
|
||||
}
|
||||
|
||||
if mr.labels.is_empty() {
|
||||
println!("Labels: {}", style("(none)").dim());
|
||||
println!("Labels: {}", Theme::dim().render("(none)"));
|
||||
} else {
|
||||
println!("Labels: {}", mr.labels.join(", "));
|
||||
}
|
||||
|
||||
if let Some(url) = &mr.web_url {
|
||||
println!("URL: {}", style(url).dim());
|
||||
println!("URL: {}", Theme::dim().render(url));
|
||||
}
|
||||
|
||||
println!();
|
||||
|
||||
println!("{}", style("Description:").bold());
|
||||
println!("{}", Theme::bold().render("Description:"));
|
||||
if let Some(desc) = &mr.description {
|
||||
let wrapped = wrap_text(desc, 76, " ");
|
||||
println!(" {}", wrapped);
|
||||
} else {
|
||||
println!(" {}", style("(no description)").dim());
|
||||
println!(" {}", Theme::dim().render("(no description)"));
|
||||
}
|
||||
|
||||
println!();
|
||||
@@ -871,11 +843,11 @@ pub fn print_show_mr(mr: &MrDetail) {
|
||||
.collect();
|
||||
|
||||
if user_discussions.is_empty() {
|
||||
println!("{}", style("Discussions: (none)").dim());
|
||||
println!("{}", Theme::dim().render("Discussions: (none)"));
|
||||
} else {
|
||||
println!(
|
||||
"{}",
|
||||
style(format!("Discussions ({}):", user_discussions.len())).bold()
|
||||
Theme::bold().render(&format!("Discussions ({}):", user_discussions.len()))
|
||||
);
|
||||
println!();
|
||||
|
||||
@@ -890,7 +862,7 @@ pub fn print_show_mr(mr: &MrDetail) {
|
||||
|
||||
println!(
|
||||
" {} ({}):",
|
||||
style(format!("@{}", first_note.author_username)).cyan(),
|
||||
Theme::info().render(&format!("@{}", first_note.author_username)),
|
||||
format_date(first_note.created_at)
|
||||
);
|
||||
let wrapped = wrap_text(&first_note.body, 72, " ");
|
||||
@@ -900,7 +872,7 @@ pub fn print_show_mr(mr: &MrDetail) {
|
||||
for reply in user_notes.iter().skip(1) {
|
||||
println!(
|
||||
" {} ({}):",
|
||||
style(format!("@{}", reply.author_username)).cyan(),
|
||||
Theme::info().render(&format!("@{}", reply.author_username)),
|
||||
format_date(reply.created_at)
|
||||
);
|
||||
let wrapped = wrap_text(&reply.body, 68, " ");
|
||||
@@ -926,39 +898,13 @@ fn print_diff_position(pos: &DiffNotePosition) {
|
||||
|
||||
println!(
|
||||
" {} {}{}",
|
||||
style("📍").dim(),
|
||||
style(file_path).yellow(),
|
||||
style(line_str).dim()
|
||||
Theme::dim().render("\u{1f4cd}"),
|
||||
Theme::warning().render(file_path),
|
||||
Theme::dim().render(&line_str)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn style_with_hex<'a>(text: &'a str, hex: Option<&str>) -> console::StyledObject<&'a str> {
|
||||
let styled = console::style(text);
|
||||
let Some(hex) = hex else { return styled };
|
||||
let hex = hex.trim_start_matches('#');
|
||||
if hex.len() != 6 {
|
||||
return styled;
|
||||
}
|
||||
let Ok(r) = u8::from_str_radix(&hex[0..2], 16) else {
|
||||
return styled;
|
||||
};
|
||||
let Ok(g) = u8::from_str_radix(&hex[2..4], 16) else {
|
||||
return styled;
|
||||
};
|
||||
let Ok(b) = u8::from_str_radix(&hex[4..6], 16) else {
|
||||
return styled;
|
||||
};
|
||||
styled.color256(ansi256_from_rgb(r, g, b))
|
||||
}
|
||||
|
||||
fn ansi256_from_rgb(r: u8, g: u8, b: u8) -> u8 {
|
||||
let ri = (u16::from(r) * 5 + 127) / 255;
|
||||
let gi = (u16::from(g) * 5 + 127) / 255;
|
||||
let bi = (u16::from(b) * 5 + 127) / 255;
|
||||
(16 + 36 * ri + 6 * gi + bi) as u8
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct IssueDetailJson {
|
||||
pub id: i64,
|
||||
@@ -1387,8 +1333,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_ansi256_from_rgb() {
|
||||
assert_eq!(ansi256_from_rgb(0, 0, 0), 16);
|
||||
assert_eq!(ansi256_from_rgb(255, 255, 255), 231);
|
||||
// Moved to render.rs — keeping basic hex sanity check
|
||||
let result = render::style_with_hex("test", Some("#ff0000"));
|
||||
assert!(!result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use console::style;
|
||||
use crate::cli::render::{self, Theme};
|
||||
use rusqlite::Connection;
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -322,77 +322,145 @@ fn table_exists(conn: &Connection, table: &str) -> bool {
|
||||
> 0
|
||||
}
|
||||
|
||||
fn section(title: &str) {
|
||||
println!("{}", render::section_divider(title));
|
||||
}
|
||||
|
||||
pub fn print_stats(result: &StatsResult) {
|
||||
println!("{}", style("Documents").cyan().bold());
|
||||
println!(" Total: {}", result.documents.total);
|
||||
println!(" Issues: {}", result.documents.issues);
|
||||
println!(" Merge Requests: {}", result.documents.merge_requests);
|
||||
println!(" Discussions: {}", result.documents.discussions);
|
||||
section("Documents");
|
||||
let mut parts = vec![format!("{} total", result.documents.total)];
|
||||
if result.documents.issues > 0 {
|
||||
parts.push(format!("{} issues", result.documents.issues));
|
||||
}
|
||||
if result.documents.merge_requests > 0 {
|
||||
parts.push(format!("{} MRs", result.documents.merge_requests));
|
||||
}
|
||||
if result.documents.discussions > 0 {
|
||||
parts.push(format!("{} discussions", result.documents.discussions));
|
||||
}
|
||||
println!(" {}", parts.join(" \u{b7} "));
|
||||
if result.documents.truncated > 0 {
|
||||
println!(
|
||||
" Truncated: {}",
|
||||
style(result.documents.truncated).yellow()
|
||||
" {}",
|
||||
Theme::warning().render(&format!("{} truncated", result.documents.truncated))
|
||||
);
|
||||
}
|
||||
println!();
|
||||
|
||||
println!("{}", style("Search Index").cyan().bold());
|
||||
println!(" FTS indexed: {}", result.fts.indexed);
|
||||
section("Search Index");
|
||||
println!(" {} FTS indexed", result.fts.indexed);
|
||||
let coverage_color = if result.embeddings.coverage_pct >= 95.0 {
|
||||
Theme::success().render(&format!("{:.0}%", result.embeddings.coverage_pct))
|
||||
} else if result.embeddings.coverage_pct >= 50.0 {
|
||||
Theme::warning().render(&format!("{:.0}%", result.embeddings.coverage_pct))
|
||||
} else {
|
||||
Theme::error().render(&format!("{:.0}%", result.embeddings.coverage_pct))
|
||||
};
|
||||
println!(
|
||||
" Embedding coverage: {:.1}% ({}/{})",
|
||||
result.embeddings.coverage_pct,
|
||||
result.embeddings.embedded_documents,
|
||||
result.documents.total
|
||||
" {} embedding coverage ({}/{})",
|
||||
coverage_color, result.embeddings.embedded_documents, result.documents.total,
|
||||
);
|
||||
if result.embeddings.total_chunks > 0 {
|
||||
println!(" Total chunks: {}", result.embeddings.total_chunks);
|
||||
println!(
|
||||
" {}",
|
||||
Theme::dim().render(&format!("{} 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
|
||||
// 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
|
||||
|| result.queues.pending_dependent_fetches_stuck > 0
|
||||
{
|
||||
println!(
|
||||
" Dependent fetch: {} pending, {} failed, {} stuck",
|
||||
result.queues.pending_dependent_fetches,
|
||||
result.queues.pending_dependent_fetches_failed,
|
||||
let mut q = Vec::new();
|
||||
if result.queues.pending_dependent_fetches > 0 {
|
||||
q.push(format!(
|
||||
"{} pending",
|
||||
result.queues.pending_dependent_fetches
|
||||
));
|
||||
}
|
||||
if result.queues.pending_dependent_fetches_failed > 0 {
|
||||
q.push(Theme::error().render(&format!(
|
||||
"{} failed",
|
||||
result.queues.pending_dependent_fetches_failed
|
||||
)));
|
||||
}
|
||||
if result.queues.pending_dependent_fetches_stuck > 0 {
|
||||
q.push(Theme::warning().render(&format!(
|
||||
"{} stuck",
|
||||
result.queues.pending_dependent_fetches_stuck
|
||||
);
|
||||
)));
|
||||
}
|
||||
println!(" dependent fetch: {}", q.join(", "));
|
||||
}
|
||||
} else {
|
||||
section("Queues");
|
||||
println!(" {}", Theme::success().render("all clear"));
|
||||
}
|
||||
|
||||
if let Some(ref integrity) = result.integrity {
|
||||
println!();
|
||||
let status = if integrity.ok {
|
||||
style("OK").green().bold()
|
||||
section("Integrity");
|
||||
if integrity.ok {
|
||||
println!(
|
||||
" {} all checks passed",
|
||||
Theme::success().render("\u{2713}")
|
||||
);
|
||||
} else {
|
||||
style("ISSUES FOUND").red().bold()
|
||||
};
|
||||
println!("{} Integrity: {}", style("Check").cyan().bold(), status);
|
||||
|
||||
if integrity.fts_doc_mismatch {
|
||||
println!(" {} FTS/document count mismatch", style("!").red());
|
||||
println!(
|
||||
" {} FTS/document count mismatch",
|
||||
Theme::error().render("\u{2717}")
|
||||
);
|
||||
}
|
||||
if integrity.orphan_embeddings > 0 {
|
||||
println!(
|
||||
" {} {} orphan embeddings",
|
||||
style("!").red(),
|
||||
Theme::error().render("\u{2717}"),
|
||||
integrity.orphan_embeddings
|
||||
);
|
||||
}
|
||||
if integrity.stale_metadata > 0 {
|
||||
println!(
|
||||
" {} {} stale embedding metadata",
|
||||
style("!").red(),
|
||||
Theme::error().render("\u{2717}"),
|
||||
integrity.stale_metadata
|
||||
);
|
||||
}
|
||||
@@ -401,27 +469,18 @@ pub fn print_stats(result: &StatsResult) {
|
||||
+ integrity.orphan_milestone_events;
|
||||
if orphan_events > 0 {
|
||||
println!(
|
||||
" {} {} orphan resource events (state: {}, label: {}, milestone: {})",
|
||||
style("!").red(),
|
||||
orphan_events,
|
||||
integrity.orphan_state_events,
|
||||
integrity.orphan_label_events,
|
||||
integrity.orphan_milestone_events
|
||||
" {} {} orphan resource events",
|
||||
Theme::error().render("\u{2717}"),
|
||||
orphan_events
|
||||
);
|
||||
}
|
||||
if integrity.queue_stuck_locks > 0 {
|
||||
println!(
|
||||
" {} {} stuck queue locks",
|
||||
style("!").yellow(),
|
||||
Theme::warning().render("!"),
|
||||
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 {
|
||||
@@ -429,17 +488,17 @@ pub fn print_stats(result: &StatsResult) {
|
||||
if repair.dry_run {
|
||||
println!(
|
||||
" {} {}",
|
||||
style("Repair").cyan().bold(),
|
||||
style("(dry run - no changes made)").yellow()
|
||||
Theme::bold().render("Repair"),
|
||||
Theme::warning().render("(dry run)")
|
||||
);
|
||||
} else {
|
||||
println!("{}", style("Repair").cyan().bold());
|
||||
println!(" {}", Theme::bold().render("Repair"));
|
||||
}
|
||||
|
||||
let action = if repair.dry_run {
|
||||
style("would fix").yellow()
|
||||
Theme::warning().render("would fix")
|
||||
} else {
|
||||
style("fixed").green()
|
||||
Theme::success().render("fixed")
|
||||
};
|
||||
|
||||
if repair.fts_rebuilt {
|
||||
@@ -453,15 +512,17 @@ pub fn print_stats(result: &StatsResult) {
|
||||
}
|
||||
if repair.stale_cleared > 0 {
|
||||
println!(
|
||||
" {} {} stale metadata entries cleared",
|
||||
" {} {} stale metadata cleared",
|
||||
action, repair.stale_cleared
|
||||
);
|
||||
}
|
||||
if !repair.fts_rebuilt && repair.orphans_deleted == 0 && repair.stale_cleared == 0 {
|
||||
println!(" No issues to repair.");
|
||||
println!(" {}", Theme::dim().render("nothing to repair"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use console::style;
|
||||
use crate::cli::render::{self, Theme};
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
use serde::Serialize;
|
||||
use std::sync::Arc;
|
||||
@@ -240,7 +240,7 @@ pub async fn run_sync(
|
||||
embed_bar.finish_and_clear();
|
||||
spinner.finish_and_clear();
|
||||
if !options.robot_mode {
|
||||
eprintln!(" {} Embedding skipped ({})", style("warn").yellow(), e);
|
||||
eprintln!(" {} Embedding skipped ({})", Theme::warning().render("warn"), e);
|
||||
}
|
||||
warn!(error = %e, "Embedding stage failed (Ollama may be unavailable), continuing");
|
||||
}
|
||||
@@ -273,37 +273,58 @@ pub fn print_sync(
|
||||
elapsed: std::time::Duration,
|
||||
metrics: Option<&MetricsLayer>,
|
||||
) {
|
||||
println!("{} Sync complete:", style("done").green().bold(),);
|
||||
println!(" Issues updated: {}", result.issues_updated);
|
||||
println!(" MRs updated: {}", result.mrs_updated);
|
||||
// Headline: what happened, how long
|
||||
println!(
|
||||
" Discussions fetched: {}",
|
||||
result.discussions_fetched
|
||||
"\n {} {} issues and {} MRs in {:.1}s",
|
||||
Theme::success().bold().render("Synced"),
|
||||
Theme::bold().render(&result.issues_updated.to_string()),
|
||||
Theme::bold().render(&result.mrs_updated.to_string()),
|
||||
elapsed.as_secs_f64()
|
||||
);
|
||||
if result.mr_diffs_fetched > 0 || result.mr_diffs_failed > 0 {
|
||||
println!(" MR diffs fetched: {}", result.mr_diffs_fetched);
|
||||
if result.mr_diffs_failed > 0 {
|
||||
println!(" MR diffs failed: {}", result.mr_diffs_failed);
|
||||
|
||||
// Detail: supporting counts, compact middle-dot format, zero-suppressed
|
||||
let mut details: Vec<String> = Vec::new();
|
||||
if result.discussions_fetched > 0 {
|
||||
details.push(format!("{} discussions", result.discussions_fetched));
|
||||
}
|
||||
if result.resource_events_fetched > 0 {
|
||||
details.push(format!("{} events", result.resource_events_fetched));
|
||||
}
|
||||
if result.resource_events_fetched > 0 || result.resource_events_failed > 0 {
|
||||
println!(
|
||||
" Resource events fetched: {}",
|
||||
result.resource_events_fetched
|
||||
);
|
||||
if result.mr_diffs_fetched > 0 {
|
||||
details.push(format!("{} diffs", result.mr_diffs_fetched));
|
||||
}
|
||||
if !details.is_empty() {
|
||||
println!(" {}", Theme::dim().render(&details.join(" \u{b7} ")));
|
||||
}
|
||||
|
||||
// Documents: regeneration + embedding as a second detail line
|
||||
let mut doc_parts: Vec<String> = Vec::new();
|
||||
if result.documents_regenerated > 0 {
|
||||
doc_parts.push(format!("{} docs regenerated", result.documents_regenerated));
|
||||
}
|
||||
if result.documents_embedded > 0 {
|
||||
doc_parts.push(format!("{} embedded", result.documents_embedded));
|
||||
}
|
||||
if !doc_parts.is_empty() {
|
||||
println!(" {}", Theme::dim().render(&doc_parts.join(" \u{b7} ")));
|
||||
}
|
||||
|
||||
// Errors: visually prominent, only if non-zero
|
||||
let mut errors: Vec<String> = Vec::new();
|
||||
if result.resource_events_failed > 0 {
|
||||
println!(
|
||||
" Resource events failed: {}",
|
||||
result.resource_events_failed
|
||||
);
|
||||
errors.push(format!("{} event failures", result.resource_events_failed));
|
||||
}
|
||||
if result.mr_diffs_failed > 0 {
|
||||
errors.push(format!("{} diff failures", result.mr_diffs_failed));
|
||||
}
|
||||
println!(
|
||||
" Documents regenerated: {}",
|
||||
result.documents_regenerated
|
||||
);
|
||||
println!(" Documents embedded: {}", result.documents_embedded);
|
||||
println!(" Elapsed: {:.1}s", elapsed.as_secs_f64());
|
||||
if result.status_enrichment_errors > 0 {
|
||||
errors.push(format!("{} status errors", result.status_enrichment_errors));
|
||||
}
|
||||
if !errors.is_empty() {
|
||||
println!(" {}", Theme::error().render(&errors.join(" \u{b7} ")));
|
||||
}
|
||||
|
||||
println!();
|
||||
|
||||
if let Some(metrics) = metrics {
|
||||
let stages = metrics.extract_timings();
|
||||
@@ -313,9 +334,12 @@ pub fn print_sync(
|
||||
}
|
||||
}
|
||||
|
||||
fn section(title: &str) {
|
||||
println!("{}", render::section_divider(title));
|
||||
}
|
||||
|
||||
fn print_timing_summary(stages: &[StageTiming]) {
|
||||
println!();
|
||||
println!("{}", style("Stage timing:").dim());
|
||||
section("Timing");
|
||||
for stage in stages {
|
||||
for sub in &stage.sub_stages {
|
||||
print_stage_line(sub, 1);
|
||||
@@ -331,29 +355,25 @@ fn print_stage_line(stage: &StageTiming, depth: usize) {
|
||||
stage.name.clone()
|
||||
};
|
||||
let pad_width = 30_usize.saturating_sub(indent.len() + name.len());
|
||||
let dots = ".".repeat(pad_width.max(2));
|
||||
let dots = Theme::dim().render(&".".repeat(pad_width.max(2)));
|
||||
|
||||
let mut suffix = String::new();
|
||||
let time_str = Theme::bold().render(&format!("{:.1}s", stage.elapsed_ms as f64 / 1000.0));
|
||||
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
if stage.items_processed > 0 {
|
||||
suffix.push_str(&format!("{} items", stage.items_processed));
|
||||
parts.push(format!("{} items", stage.items_processed));
|
||||
}
|
||||
if stage.errors > 0 {
|
||||
if !suffix.is_empty() {
|
||||
suffix.push_str(", ");
|
||||
}
|
||||
suffix.push_str(&format!("{} errors", stage.errors));
|
||||
parts.push(Theme::error().render(&format!("{} errors", stage.errors)));
|
||||
}
|
||||
if stage.rate_limit_hits > 0 {
|
||||
if !suffix.is_empty() {
|
||||
suffix.push_str(", ");
|
||||
}
|
||||
suffix.push_str(&format!("{} rate limits", stage.rate_limit_hits));
|
||||
parts.push(Theme::warning().render(&format!("{} rate limits", stage.rate_limit_hits)));
|
||||
}
|
||||
|
||||
let time_str = format!("{:.1}s", stage.elapsed_ms as f64 / 1000.0);
|
||||
if suffix.is_empty() {
|
||||
if parts.is_empty() {
|
||||
println!("{indent}{name} {dots} {time_str}");
|
||||
} else {
|
||||
let suffix = parts.join(" \u{b7} ");
|
||||
println!("{indent}{name} {dots} {time_str} ({suffix})");
|
||||
}
|
||||
|
||||
@@ -423,87 +443,52 @@ async fn run_sync_dry_run(config: &Config, options: &SyncOptions) -> Result<Sync
|
||||
|
||||
pub fn print_sync_dry_run(result: &SyncDryRunResult) {
|
||||
println!(
|
||||
"{} {}",
|
||||
style("Sync Dry Run Preview").cyan().bold(),
|
||||
style("(no changes will be made)").yellow()
|
||||
"\n {} {}",
|
||||
Theme::info().bold().render("Dry run"),
|
||||
Theme::dim().render("(no changes will be made)")
|
||||
);
|
||||
println!();
|
||||
|
||||
println!("{}", style("Stage 1: Issues Ingestion").white().bold());
|
||||
println!(
|
||||
" Sync mode: {}",
|
||||
if result.issues_preview.sync_mode == "full" {
|
||||
style("full").yellow()
|
||||
} else {
|
||||
style("incremental").green()
|
||||
}
|
||||
);
|
||||
println!(" Projects: {}", result.issues_preview.projects.len());
|
||||
for project in &result.issues_preview.projects {
|
||||
let sync_status = if !project.has_cursor {
|
||||
style("initial sync").yellow()
|
||||
} else {
|
||||
style("incremental").green()
|
||||
};
|
||||
println!(
|
||||
" {} ({}) - {} existing",
|
||||
&project.path, sync_status, project.existing_count
|
||||
);
|
||||
}
|
||||
println!();
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
style("Stage 2: Merge Requests Ingestion").white().bold()
|
||||
);
|
||||
println!(
|
||||
" Sync mode: {}",
|
||||
if result.mrs_preview.sync_mode == "full" {
|
||||
style("full").yellow()
|
||||
} else {
|
||||
style("incremental").green()
|
||||
}
|
||||
);
|
||||
println!(" Projects: {}", result.mrs_preview.projects.len());
|
||||
for project in &result.mrs_preview.projects {
|
||||
let sync_status = if !project.has_cursor {
|
||||
style("initial sync").yellow()
|
||||
} else {
|
||||
style("incremental").green()
|
||||
};
|
||||
println!(
|
||||
" {} ({}) - {} existing",
|
||||
&project.path, sync_status, project.existing_count
|
||||
);
|
||||
}
|
||||
println!();
|
||||
print_dry_run_entity("Issues", &result.issues_preview);
|
||||
print_dry_run_entity("Merge Requests", &result.mrs_preview);
|
||||
|
||||
// Pipeline stages
|
||||
section("Pipeline");
|
||||
let mut stages: Vec<String> = Vec::new();
|
||||
if result.would_generate_docs {
|
||||
println!(
|
||||
"{} {}",
|
||||
style("Stage 3: Document Generation").white().bold(),
|
||||
style("(would run)").green()
|
||||
);
|
||||
stages.push("generate-docs".to_string());
|
||||
} else {
|
||||
println!(
|
||||
"{} {}",
|
||||
style("Stage 3: Document Generation").white().bold(),
|
||||
style("(skipped)").dim()
|
||||
);
|
||||
stages.push(Theme::dim().render("generate-docs (skip)"));
|
||||
}
|
||||
if result.would_embed {
|
||||
stages.push("embed".to_string());
|
||||
} else {
|
||||
stages.push(Theme::dim().render("embed (skip)"));
|
||||
}
|
||||
println!(" {}", stages.join(" \u{b7} "));
|
||||
}
|
||||
|
||||
if result.would_embed {
|
||||
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!(
|
||||
"{} {}",
|
||||
style("Stage 4: Embedding").white().bold(),
|
||||
style("(would run)").green()
|
||||
" {} \u{b7} {} \u{b7} {} existing",
|
||||
&project.path, sync_status, project.existing_count
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"{} {}",
|
||||
style("Stage 4: Embedding").white().bold(),
|
||||
style("(skipped)").dim()
|
||||
);
|
||||
println!(" {} \u{b7} {}", &project.path, sync_status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use console::style;
|
||||
use crate::cli::render::{self, Theme};
|
||||
use rusqlite::Connection;
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -166,27 +166,6 @@ fn format_duration(ms: i64) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn format_number(n: i64) -> String {
|
||||
let is_negative = n < 0;
|
||||
let abs_n = n.unsigned_abs();
|
||||
let s = abs_n.to_string();
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
let mut result = String::new();
|
||||
|
||||
if is_negative {
|
||||
result.push('-');
|
||||
}
|
||||
|
||||
for (i, c) in chars.iter().enumerate() {
|
||||
if i > 0 && (chars.len() - i).is_multiple_of(3) {
|
||||
result.push(',');
|
||||
}
|
||||
result.push(*c);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SyncStatusJsonOutput {
|
||||
ok: bool,
|
||||
@@ -293,14 +272,14 @@ pub fn print_sync_status_json(result: &SyncStatusResult, elapsed_ms: u64) {
|
||||
}
|
||||
|
||||
pub fn print_sync_status(result: &SyncStatusResult) {
|
||||
println!("{}", style("Recent Sync Runs").bold().underlined());
|
||||
println!("{}", Theme::bold().underline().render("Recent Sync Runs"));
|
||||
println!();
|
||||
|
||||
if result.runs.is_empty() {
|
||||
println!(" {}", style("No sync runs recorded yet.").dim());
|
||||
println!(" {}", Theme::dim().render("No sync runs recorded yet."));
|
||||
println!(
|
||||
" {}",
|
||||
style("Run 'lore sync' or 'lore ingest' to start.").dim()
|
||||
Theme::dim().render("Run 'lore sync' or 'lore ingest' to start.")
|
||||
);
|
||||
} else {
|
||||
for run in &result.runs {
|
||||
@@ -310,16 +289,16 @@ pub fn print_sync_status(result: &SyncStatusResult) {
|
||||
|
||||
println!();
|
||||
|
||||
println!("{}", style("Cursor Positions").bold().underlined());
|
||||
println!("{}", Theme::bold().underline().render("Cursor Positions"));
|
||||
println!();
|
||||
|
||||
if result.cursors.is_empty() {
|
||||
println!(" {}", style("No cursors recorded yet.").dim());
|
||||
println!(" {}", Theme::dim().render("No cursors recorded yet."));
|
||||
} else {
|
||||
for cursor in &result.cursors {
|
||||
println!(
|
||||
" {} ({}):",
|
||||
style(&cursor.project_path).cyan(),
|
||||
Theme::info().render(&cursor.project_path),
|
||||
cursor.resource_type
|
||||
);
|
||||
|
||||
@@ -328,7 +307,10 @@ pub fn print_sync_status(result: &SyncStatusResult) {
|
||||
println!(" Last updated_at: {}", ms_to_iso(ts));
|
||||
}
|
||||
_ => {
|
||||
println!(" Last updated_at: {}", style("Not started").dim());
|
||||
println!(
|
||||
" Last updated_at: {}",
|
||||
Theme::dim().render("Not started")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,40 +322,39 @@ pub fn print_sync_status(result: &SyncStatusResult) {
|
||||
|
||||
println!();
|
||||
|
||||
println!("{}", style("Data Summary").bold().underlined());
|
||||
println!("{}", Theme::bold().underline().render("Data Summary"));
|
||||
println!();
|
||||
|
||||
println!(
|
||||
" Issues: {}",
|
||||
style(format_number(result.summary.issue_count)).bold()
|
||||
Theme::bold().render(&render::format_number(result.summary.issue_count))
|
||||
);
|
||||
println!(
|
||||
" MRs: {}",
|
||||
style(format_number(result.summary.mr_count)).bold()
|
||||
Theme::bold().render(&render::format_number(result.summary.mr_count))
|
||||
);
|
||||
println!(
|
||||
" Discussions: {}",
|
||||
style(format_number(result.summary.discussion_count)).bold()
|
||||
Theme::bold().render(&render::format_number(result.summary.discussion_count))
|
||||
);
|
||||
|
||||
let user_notes = result.summary.note_count - result.summary.system_note_count;
|
||||
println!(
|
||||
" Notes: {} {}",
|
||||
style(format_number(user_notes)).bold(),
|
||||
style(format!(
|
||||
Theme::bold().render(&render::format_number(user_notes)),
|
||||
Theme::dim().render(&format!(
|
||||
"(excluding {} system)",
|
||||
format_number(result.summary.system_note_count)
|
||||
render::format_number(result.summary.system_note_count)
|
||||
))
|
||||
.dim()
|
||||
);
|
||||
}
|
||||
|
||||
fn print_run_line(run: &SyncRunInfo) {
|
||||
let status_styled = match run.status.as_str() {
|
||||
"succeeded" => style(&run.status).green(),
|
||||
"failed" => style(&run.status).red(),
|
||||
"running" => style(&run.status).yellow(),
|
||||
_ => style(&run.status).dim(),
|
||||
"succeeded" => Theme::success().render(&run.status),
|
||||
"failed" => Theme::error().render(&run.status),
|
||||
"running" => Theme::warning().render(&run.status),
|
||||
_ => Theme::dim().render(&run.status),
|
||||
};
|
||||
|
||||
let run_label = run
|
||||
@@ -386,9 +367,9 @@ fn print_run_line(run: &SyncRunInfo) {
|
||||
let time = format_full_datetime(run.started_at);
|
||||
|
||||
let mut parts = vec![
|
||||
format!("{}", style(run_label).bold()),
|
||||
format!("{status_styled}"),
|
||||
format!("{}", style(&run.command).dim()),
|
||||
Theme::bold().render(&run_label),
|
||||
status_styled,
|
||||
Theme::dim().render(&run.command),
|
||||
time,
|
||||
];
|
||||
|
||||
@@ -403,16 +384,13 @@ fn print_run_line(run: &SyncRunInfo) {
|
||||
}
|
||||
|
||||
if run.total_errors > 0 {
|
||||
parts.push(format!(
|
||||
"{}",
|
||||
style(format!("{} errors", run.total_errors)).red()
|
||||
));
|
||||
parts.push(Theme::error().render(&format!("{} errors", run.total_errors)));
|
||||
}
|
||||
|
||||
println!(" {}", parts.join(" | "));
|
||||
|
||||
if let Some(error) = &run.error {
|
||||
println!(" {}", style(error).red());
|
||||
println!(" {}", Theme::error().render(error));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,7 +426,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn format_number_adds_thousands_separators() {
|
||||
assert_eq!(format_number(1000), "1,000");
|
||||
assert_eq!(format_number(1234567), "1,234,567");
|
||||
assert_eq!(render::format_number(1000), "1,000");
|
||||
assert_eq!(render::format_number(1234567), "1,234,567");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use console::{Alignment, pad_str, style};
|
||||
use crate::cli::render::{self, Theme};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::Config;
|
||||
@@ -22,7 +22,7 @@ pub struct TimelineParams {
|
||||
pub project: Option<String>,
|
||||
pub since: Option<String>,
|
||||
pub depth: u32,
|
||||
pub expand_mentions: bool,
|
||||
pub no_mentions: bool,
|
||||
pub limit: usize,
|
||||
pub max_seeds: usize,
|
||||
pub max_entities: usize,
|
||||
@@ -133,7 +133,7 @@ pub async fn run_timeline(config: &Config, params: &TimelineParams) -> Result<Ti
|
||||
&conn,
|
||||
&seed_result.seed_entities,
|
||||
params.depth,
|
||||
params.expand_mentions,
|
||||
!params.no_mentions,
|
||||
params.max_entities,
|
||||
)?;
|
||||
spinner.finish_and_clear();
|
||||
@@ -171,19 +171,21 @@ pub fn print_timeline(result: &TimelineResult) {
|
||||
println!();
|
||||
println!(
|
||||
"{}",
|
||||
style(format!(
|
||||
Theme::bold().render(&format!(
|
||||
"Timeline: \"{}\" ({} events across {} entities)",
|
||||
result.query,
|
||||
result.events.len(),
|
||||
entity_count,
|
||||
))
|
||||
.bold()
|
||||
);
|
||||
println!("{}", "─".repeat(60));
|
||||
println!("{}", "\u{2500}".repeat(60));
|
||||
println!();
|
||||
|
||||
if result.events.is_empty() {
|
||||
println!(" {}", style("No events found for this query.").dim());
|
||||
println!(
|
||||
" {}",
|
||||
Theme::dim().render("No events found for this query.")
|
||||
);
|
||||
println!();
|
||||
return;
|
||||
}
|
||||
@@ -193,12 +195,12 @@ pub fn print_timeline(result: &TimelineResult) {
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("{}", "─".repeat(60));
|
||||
println!("{}", "\u{2500}".repeat(60));
|
||||
print_timeline_footer(result);
|
||||
}
|
||||
|
||||
fn print_timeline_event(event: &TimelineEvent) {
|
||||
let date = format_date(event.timestamp);
|
||||
let date = render::format_date(event.timestamp);
|
||||
let tag = format_event_tag(&event.event_type);
|
||||
let entity_ref = format_entity_ref(&event.entity_type, event.entity_iid);
|
||||
let actor = event
|
||||
@@ -208,18 +210,20 @@ fn print_timeline_event(event: &TimelineEvent) {
|
||||
.unwrap_or_default();
|
||||
let expanded_marker = if event.is_seed { "" } else { " [expanded]" };
|
||||
|
||||
let summary = truncate_summary(&event.summary, 50);
|
||||
let tag_padded = pad_str(&tag, 12, Alignment::Left, None);
|
||||
let summary = render::truncate(&event.summary, 50);
|
||||
let tag_padded = format!("{:<12}", tag);
|
||||
println!("{date} {tag_padded} {entity_ref:7} {summary:50} {actor}{expanded_marker}");
|
||||
|
||||
// Show snippet for evidence notes
|
||||
if let TimelineEventType::NoteEvidence { snippet, .. } = &event.event_type
|
||||
&& !snippet.is_empty()
|
||||
{
|
||||
for line in wrap_snippet(snippet, 60) {
|
||||
let mut lines = render::wrap_lines(snippet, 60);
|
||||
lines.truncate(4);
|
||||
for line in lines {
|
||||
println!(
|
||||
" \"{}\"",
|
||||
style(line).dim()
|
||||
Theme::dim().render(&line)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -229,14 +233,14 @@ fn print_timeline_event(event: &TimelineEvent) {
|
||||
let bar = "\u{2500}".repeat(44);
|
||||
println!(" \u{2500}\u{2500} Discussion {bar}");
|
||||
for note in notes {
|
||||
let note_date = format_date(note.created_at);
|
||||
let note_date = render::format_date(note.created_at);
|
||||
let author = note
|
||||
.author
|
||||
.as_deref()
|
||||
.map(|a| format!("@{a}"))
|
||||
.unwrap_or_else(|| "unknown".to_owned());
|
||||
println!(" {} ({note_date}):", style(author).bold());
|
||||
for line in wrap_text(¬e.body, 60) {
|
||||
println!(" {} ({note_date}):", Theme::bold().render(&author));
|
||||
for line in render::wrap_lines(¬e.body, 60) {
|
||||
println!(" {line}");
|
||||
}
|
||||
}
|
||||
@@ -274,20 +278,20 @@ fn print_timeline_footer(result: &TimelineResult) {
|
||||
|
||||
fn format_event_tag(event_type: &TimelineEventType) -> String {
|
||||
match event_type {
|
||||
TimelineEventType::Created => style("CREATED").green().to_string(),
|
||||
TimelineEventType::Created => Theme::success().render("CREATED"),
|
||||
TimelineEventType::StateChanged { state } => match state.as_str() {
|
||||
"closed" => style("CLOSED").red().to_string(),
|
||||
"reopened" => style("REOPENED").yellow().to_string(),
|
||||
_ => style(state.to_uppercase()).dim().to_string(),
|
||||
"closed" => Theme::error().render("CLOSED"),
|
||||
"reopened" => Theme::warning().render("REOPENED"),
|
||||
_ => Theme::dim().render(&state.to_uppercase()),
|
||||
},
|
||||
TimelineEventType::LabelAdded { .. } => style("LABEL+").blue().to_string(),
|
||||
TimelineEventType::LabelRemoved { .. } => style("LABEL-").blue().to_string(),
|
||||
TimelineEventType::MilestoneSet { .. } => style("MILESTONE+").magenta().to_string(),
|
||||
TimelineEventType::MilestoneRemoved { .. } => style("MILESTONE-").magenta().to_string(),
|
||||
TimelineEventType::Merged => style("MERGED").cyan().to_string(),
|
||||
TimelineEventType::NoteEvidence { .. } => style("NOTE").dim().to_string(),
|
||||
TimelineEventType::DiscussionThread { .. } => style("THREAD").yellow().to_string(),
|
||||
TimelineEventType::CrossReferenced { .. } => style("REF").dim().to_string(),
|
||||
TimelineEventType::LabelAdded { .. } => Theme::info().render("LABEL+"),
|
||||
TimelineEventType::LabelRemoved { .. } => Theme::info().render("LABEL-"),
|
||||
TimelineEventType::MilestoneSet { .. } => Theme::accent().render("MILESTONE+"),
|
||||
TimelineEventType::MilestoneRemoved { .. } => Theme::accent().render("MILESTONE-"),
|
||||
TimelineEventType::Merged => Theme::info().render("MERGED"),
|
||||
TimelineEventType::NoteEvidence { .. } => Theme::dim().render("NOTE"),
|
||||
TimelineEventType::DiscussionThread { .. } => Theme::warning().render("THREAD"),
|
||||
TimelineEventType::CrossReferenced { .. } => Theme::dim().render("REF"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,48 +303,6 @@ fn format_entity_ref(entity_type: &str, iid: i64) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn format_date(ms: i64) -> String {
|
||||
let iso = ms_to_iso(ms);
|
||||
iso.split('T').next().unwrap_or(&iso).to_string()
|
||||
}
|
||||
|
||||
fn truncate_summary(s: &str, max: usize) -> String {
|
||||
if s.chars().count() <= max {
|
||||
s.to_owned()
|
||||
} else {
|
||||
let truncated: String = s.chars().take(max - 3).collect();
|
||||
format!("{truncated}...")
|
||||
}
|
||||
}
|
||||
|
||||
fn wrap_text(text: &str, width: usize) -> Vec<String> {
|
||||
let mut lines = Vec::new();
|
||||
let mut current = String::new();
|
||||
|
||||
for word in text.split_whitespace() {
|
||||
if current.is_empty() {
|
||||
current = word.to_string();
|
||||
} else if current.len() + 1 + word.len() <= width {
|
||||
current.push(' ');
|
||||
current.push_str(word);
|
||||
} else {
|
||||
lines.push(current);
|
||||
current = word.to_string();
|
||||
}
|
||||
}
|
||||
if !current.is_empty() {
|
||||
lines.push(current);
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
fn wrap_snippet(text: &str, width: usize) -> Vec<String> {
|
||||
let mut lines = wrap_text(text, width);
|
||||
lines.truncate(4);
|
||||
lines
|
||||
}
|
||||
|
||||
// ─── Robot JSON output ───────────────────────────────────────────────────────
|
||||
|
||||
/// Render timeline as robot-mode JSON in {ok, data, meta} envelope.
|
||||
@@ -348,7 +310,7 @@ pub fn print_timeline_json_with_meta(
|
||||
result: &TimelineResult,
|
||||
total_events_before_limit: usize,
|
||||
depth: u32,
|
||||
expand_mentions: bool,
|
||||
include_mentions: bool,
|
||||
fields: Option<&[String]>,
|
||||
) {
|
||||
let output = TimelineJsonEnvelope {
|
||||
@@ -357,7 +319,7 @@ pub fn print_timeline_json_with_meta(
|
||||
meta: TimelineMetaJson {
|
||||
search_mode: result.search_mode.clone(),
|
||||
expansion_depth: depth,
|
||||
expand_mentions,
|
||||
include_mentions,
|
||||
total_entities: result.seed_entities.len() + result.expanded_entities.len(),
|
||||
total_events: total_events_before_limit,
|
||||
evidence_notes_included: count_evidence_notes(&result.events),
|
||||
@@ -586,7 +548,7 @@ fn event_type_to_json(event_type: &TimelineEventType) -> (String, serde_json::Va
|
||||
struct TimelineMetaJson {
|
||||
search_mode: String,
|
||||
expansion_depth: u32,
|
||||
expand_mentions: bool,
|
||||
include_mentions: bool,
|
||||
total_entities: usize,
|
||||
total_events: usize,
|
||||
evidence_notes_included: usize,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use console::style;
|
||||
use crate::cli::render::{self, Theme};
|
||||
use rusqlite::Connection;
|
||||
use serde::Serialize;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
@@ -1874,18 +1874,21 @@ fn print_scope_hint(project_path: Option<&str>) {
|
||||
if project_path.is_none() {
|
||||
println!(
|
||||
" {}",
|
||||
style("(aggregated across all projects; use -p to scope)").dim()
|
||||
Theme::dim().render("(aggregated across all projects; use -p to scope)")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn print_expert_human(r: &ExpertResult, project_path: Option<&str>) {
|
||||
println!();
|
||||
println!("{}", style(format!("Experts for {}", r.path_query)).bold());
|
||||
println!(
|
||||
"{}",
|
||||
Theme::bold().render(&format!("Experts for {}", r.path_query))
|
||||
);
|
||||
println!("{}", "\u{2500}".repeat(60));
|
||||
println!(
|
||||
" {}",
|
||||
style(format!(
|
||||
Theme::dim().render(&format!(
|
||||
"(matching {} {})",
|
||||
r.path_match,
|
||||
if r.path_match == "exact" {
|
||||
@@ -1894,26 +1897,28 @@ fn print_expert_human(r: &ExpertResult, project_path: Option<&str>) {
|
||||
"directory prefix"
|
||||
}
|
||||
))
|
||||
.dim()
|
||||
);
|
||||
print_scope_hint(project_path);
|
||||
println!();
|
||||
|
||||
if r.experts.is_empty() {
|
||||
println!(" {}", style("No experts found for this path.").dim());
|
||||
println!(
|
||||
" {}",
|
||||
Theme::dim().render("No experts found for this path.")
|
||||
);
|
||||
println!();
|
||||
return;
|
||||
}
|
||||
|
||||
println!(
|
||||
" {:<16} {:>6} {:>12} {:>6} {:>12} {} {}",
|
||||
style("Username").bold(),
|
||||
style("Score").bold(),
|
||||
style("Reviewed(MRs)").bold(),
|
||||
style("Notes").bold(),
|
||||
style("Authored(MRs)").bold(),
|
||||
style("Last Seen").bold(),
|
||||
style("MR Refs").bold(),
|
||||
Theme::bold().render("Username"),
|
||||
Theme::bold().render("Score"),
|
||||
Theme::bold().render("Reviewed(MRs)"),
|
||||
Theme::bold().render("Notes"),
|
||||
Theme::bold().render("Authored(MRs)"),
|
||||
Theme::bold().render("Last Seen"),
|
||||
Theme::bold().render("MR Refs"),
|
||||
);
|
||||
|
||||
for expert in &r.experts {
|
||||
@@ -1946,12 +1951,12 @@ fn print_expert_human(r: &ExpertResult, project_path: Option<&str>) {
|
||||
};
|
||||
println!(
|
||||
" {:<16} {:>6} {:>12} {:>6} {:>12} {:<12}{}{}",
|
||||
style(format!("@{}", expert.username)).cyan(),
|
||||
Theme::info().render(&format!("@{}", expert.username)),
|
||||
expert.score,
|
||||
reviews,
|
||||
notes,
|
||||
authored,
|
||||
format_relative_time(expert.last_seen_ms),
|
||||
render::format_relative_time(expert.last_seen_ms),
|
||||
if mr_str.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
@@ -1971,17 +1976,17 @@ fn print_expert_human(r: &ExpertResult, project_path: Option<&str>) {
|
||||
};
|
||||
println!(
|
||||
" {:<3} {:<30} {:>30} {:>10} {}",
|
||||
style(&d.role).dim(),
|
||||
Theme::dim().render(&d.role),
|
||||
d.mr_ref,
|
||||
truncate_str(&format!("\"{}\"", d.title), 30),
|
||||
render::truncate(&format!("\"{}\"", d.title), 30),
|
||||
notes_str,
|
||||
style(format_relative_time(d.last_activity_ms)).dim(),
|
||||
Theme::dim().render(&render::format_relative_time(d.last_activity_ms)),
|
||||
);
|
||||
}
|
||||
if details.len() > MAX_DETAIL_DISPLAY {
|
||||
println!(
|
||||
" {}",
|
||||
style(format!("+{} more", details.len() - MAX_DETAIL_DISPLAY)).dim()
|
||||
Theme::dim().render(&format!("+{} more", details.len() - MAX_DETAIL_DISPLAY))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1989,7 +1994,7 @@ fn print_expert_human(r: &ExpertResult, project_path: Option<&str>) {
|
||||
if r.truncated {
|
||||
println!(
|
||||
" {}",
|
||||
style("(showing first -n; rerun with a higher --limit)").dim()
|
||||
Theme::dim().render("(showing first -n; rerun with a higher --limit)")
|
||||
);
|
||||
}
|
||||
println!();
|
||||
@@ -1999,7 +2004,7 @@ fn print_workload_human(r: &WorkloadResult) {
|
||||
println!();
|
||||
println!(
|
||||
"{}",
|
||||
style(format!("@{} -- Workload Summary", r.username)).bold()
|
||||
Theme::bold().render(&format!("@{} -- Workload Summary", r.username))
|
||||
);
|
||||
println!("{}", "\u{2500}".repeat(60));
|
||||
|
||||
@@ -2007,21 +2012,21 @@ fn print_workload_human(r: &WorkloadResult) {
|
||||
println!();
|
||||
println!(
|
||||
" {} ({})",
|
||||
style("Assigned Issues").bold(),
|
||||
Theme::bold().render("Assigned Issues"),
|
||||
r.assigned_issues.len()
|
||||
);
|
||||
for item in &r.assigned_issues {
|
||||
println!(
|
||||
" {} {} {}",
|
||||
style(&item.ref_).cyan(),
|
||||
truncate_str(&item.title, 40),
|
||||
style(format_relative_time(item.updated_at)).dim(),
|
||||
Theme::info().render(&item.ref_),
|
||||
render::truncate(&item.title, 40),
|
||||
Theme::dim().render(&render::format_relative_time(item.updated_at)),
|
||||
);
|
||||
}
|
||||
if r.assigned_issues_truncated {
|
||||
println!(
|
||||
" {}",
|
||||
style("(truncated; rerun with a higher --limit)").dim()
|
||||
Theme::dim().render("(truncated; rerun with a higher --limit)")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2030,23 +2035,23 @@ fn print_workload_human(r: &WorkloadResult) {
|
||||
println!();
|
||||
println!(
|
||||
" {} ({})",
|
||||
style("Authored MRs").bold(),
|
||||
Theme::bold().render("Authored MRs"),
|
||||
r.authored_mrs.len()
|
||||
);
|
||||
for mr in &r.authored_mrs {
|
||||
let draft = if mr.draft { " [draft]" } else { "" };
|
||||
println!(
|
||||
" {} {}{} {}",
|
||||
style(&mr.ref_).cyan(),
|
||||
truncate_str(&mr.title, 35),
|
||||
style(draft).dim(),
|
||||
style(format_relative_time(mr.updated_at)).dim(),
|
||||
Theme::info().render(&mr.ref_),
|
||||
render::truncate(&mr.title, 35),
|
||||
Theme::dim().render(draft),
|
||||
Theme::dim().render(&render::format_relative_time(mr.updated_at)),
|
||||
);
|
||||
}
|
||||
if r.authored_mrs_truncated {
|
||||
println!(
|
||||
" {}",
|
||||
style("(truncated; rerun with a higher --limit)").dim()
|
||||
Theme::dim().render("(truncated; rerun with a higher --limit)")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2055,7 +2060,7 @@ fn print_workload_human(r: &WorkloadResult) {
|
||||
println!();
|
||||
println!(
|
||||
" {} ({})",
|
||||
style("Reviewing MRs").bold(),
|
||||
Theme::bold().render("Reviewing MRs"),
|
||||
r.reviewing_mrs.len()
|
||||
);
|
||||
for mr in &r.reviewing_mrs {
|
||||
@@ -2066,16 +2071,16 @@ fn print_workload_human(r: &WorkloadResult) {
|
||||
.unwrap_or_default();
|
||||
println!(
|
||||
" {} {}{} {}",
|
||||
style(&mr.ref_).cyan(),
|
||||
truncate_str(&mr.title, 30),
|
||||
style(author).dim(),
|
||||
style(format_relative_time(mr.updated_at)).dim(),
|
||||
Theme::info().render(&mr.ref_),
|
||||
render::truncate(&mr.title, 30),
|
||||
Theme::dim().render(&author),
|
||||
Theme::dim().render(&render::format_relative_time(mr.updated_at)),
|
||||
);
|
||||
}
|
||||
if r.reviewing_mrs_truncated {
|
||||
println!(
|
||||
" {}",
|
||||
style("(truncated; rerun with a higher --limit)").dim()
|
||||
Theme::dim().render("(truncated; rerun with a higher --limit)")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2084,22 +2089,22 @@ fn print_workload_human(r: &WorkloadResult) {
|
||||
println!();
|
||||
println!(
|
||||
" {} ({})",
|
||||
style("Unresolved Discussions").bold(),
|
||||
Theme::bold().render("Unresolved Discussions"),
|
||||
r.unresolved_discussions.len()
|
||||
);
|
||||
for disc in &r.unresolved_discussions {
|
||||
println!(
|
||||
" {} {} {} {}",
|
||||
style(&disc.entity_type).dim(),
|
||||
style(&disc.ref_).cyan(),
|
||||
truncate_str(&disc.entity_title, 35),
|
||||
style(format_relative_time(disc.last_note_at)).dim(),
|
||||
Theme::dim().render(&disc.entity_type),
|
||||
Theme::info().render(&disc.ref_),
|
||||
render::truncate(&disc.entity_title, 35),
|
||||
Theme::dim().render(&render::format_relative_time(disc.last_note_at)),
|
||||
);
|
||||
}
|
||||
if r.unresolved_discussions_truncated {
|
||||
println!(
|
||||
" {}",
|
||||
style("(truncated; rerun with a higher --limit)").dim()
|
||||
Theme::dim().render("(truncated; rerun with a higher --limit)")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2112,7 +2117,7 @@ fn print_workload_human(r: &WorkloadResult) {
|
||||
println!();
|
||||
println!(
|
||||
" {}",
|
||||
style("No open work items found for this user.").dim()
|
||||
Theme::dim().render("No open work items found for this user.")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2123,7 +2128,7 @@ fn print_reviews_human(r: &ReviewsResult) {
|
||||
println!();
|
||||
println!(
|
||||
"{}",
|
||||
style(format!("@{} -- Review Patterns", r.username)).bold()
|
||||
Theme::bold().render(&format!("@{} -- Review Patterns", r.username))
|
||||
);
|
||||
println!("{}", "\u{2500}".repeat(60));
|
||||
println!();
|
||||
@@ -2131,7 +2136,7 @@ fn print_reviews_human(r: &ReviewsResult) {
|
||||
if r.total_diffnotes == 0 {
|
||||
println!(
|
||||
" {}",
|
||||
style("No review comments found for this user.").dim()
|
||||
Theme::dim().render("No review comments found for this user.")
|
||||
);
|
||||
println!();
|
||||
return;
|
||||
@@ -2139,24 +2144,24 @@ fn print_reviews_human(r: &ReviewsResult) {
|
||||
|
||||
println!(
|
||||
" {} DiffNotes across {} MRs ({} categorized)",
|
||||
style(r.total_diffnotes).bold(),
|
||||
style(r.mrs_reviewed).bold(),
|
||||
style(r.categorized_count).bold(),
|
||||
Theme::bold().render(&r.total_diffnotes.to_string()),
|
||||
Theme::bold().render(&r.mrs_reviewed.to_string()),
|
||||
Theme::bold().render(&r.categorized_count.to_string()),
|
||||
);
|
||||
println!();
|
||||
|
||||
if !r.categories.is_empty() {
|
||||
println!(
|
||||
" {:<16} {:>6} {:>6}",
|
||||
style("Category").bold(),
|
||||
style("Count").bold(),
|
||||
style("%").bold(),
|
||||
Theme::bold().render("Category"),
|
||||
Theme::bold().render("Count"),
|
||||
Theme::bold().render("%"),
|
||||
);
|
||||
|
||||
for cat in &r.categories {
|
||||
println!(
|
||||
" {:<16} {:>6} {:>5.1}%",
|
||||
style(&cat.name).cyan(),
|
||||
Theme::info().render(&cat.name),
|
||||
cat.count,
|
||||
cat.percentage,
|
||||
);
|
||||
@@ -2168,7 +2173,7 @@ fn print_reviews_human(r: &ReviewsResult) {
|
||||
println!();
|
||||
println!(
|
||||
" {} {} uncategorized (no **prefix** convention)",
|
||||
style("Note:").dim(),
|
||||
Theme::dim().render("Note:"),
|
||||
uncategorized,
|
||||
);
|
||||
}
|
||||
@@ -2180,11 +2185,10 @@ fn print_active_human(r: &ActiveResult, project_path: Option<&str>) {
|
||||
println!();
|
||||
println!(
|
||||
"{}",
|
||||
style(format!(
|
||||
Theme::bold().render(&format!(
|
||||
"Active Discussions ({} unresolved in window)",
|
||||
r.total_unresolved_in_window
|
||||
))
|
||||
.bold()
|
||||
);
|
||||
println!("{}", "\u{2500}".repeat(60));
|
||||
print_scope_hint(project_path);
|
||||
@@ -2193,7 +2197,7 @@ fn print_active_human(r: &ActiveResult, project_path: Option<&str>) {
|
||||
if r.discussions.is_empty() {
|
||||
println!(
|
||||
" {}",
|
||||
style("No active unresolved discussions in this time window.").dim()
|
||||
Theme::dim().render("No active unresolved discussions in this time window.")
|
||||
);
|
||||
println!();
|
||||
return;
|
||||
@@ -2210,20 +2214,20 @@ fn print_active_human(r: &ActiveResult, project_path: Option<&str>) {
|
||||
|
||||
println!(
|
||||
" {} {} {} {} notes {}",
|
||||
style(format!("{prefix}{}", disc.entity_iid)).cyan(),
|
||||
truncate_str(&disc.entity_title, 40),
|
||||
style(format_relative_time(disc.last_note_at)).dim(),
|
||||
Theme::info().render(&format!("{prefix}{}", disc.entity_iid)),
|
||||
render::truncate(&disc.entity_title, 40),
|
||||
Theme::dim().render(&render::format_relative_time(disc.last_note_at)),
|
||||
disc.note_count,
|
||||
style(&disc.project_path).dim(),
|
||||
Theme::dim().render(&disc.project_path),
|
||||
);
|
||||
if !participants_str.is_empty() {
|
||||
println!(" {}", style(participants_str).dim());
|
||||
println!(" {}", Theme::dim().render(&participants_str));
|
||||
}
|
||||
}
|
||||
if r.truncated {
|
||||
println!(
|
||||
" {}",
|
||||
style("(showing first -n; rerun with a higher --limit)").dim()
|
||||
Theme::dim().render("(showing first -n; rerun with a higher --limit)")
|
||||
);
|
||||
}
|
||||
println!();
|
||||
@@ -2231,11 +2235,14 @@ fn print_active_human(r: &ActiveResult, project_path: Option<&str>) {
|
||||
|
||||
fn print_overlap_human(r: &OverlapResult, project_path: Option<&str>) {
|
||||
println!();
|
||||
println!("{}", style(format!("Overlap for {}", r.path_query)).bold());
|
||||
println!(
|
||||
"{}",
|
||||
Theme::bold().render(&format!("Overlap for {}", r.path_query))
|
||||
);
|
||||
println!("{}", "\u{2500}".repeat(60));
|
||||
println!(
|
||||
" {}",
|
||||
style(format!(
|
||||
Theme::dim().render(&format!(
|
||||
"(matching {} {})",
|
||||
r.path_match,
|
||||
if r.path_match == "exact" {
|
||||
@@ -2244,7 +2251,6 @@ fn print_overlap_human(r: &OverlapResult, project_path: Option<&str>) {
|
||||
"directory prefix"
|
||||
}
|
||||
))
|
||||
.dim()
|
||||
);
|
||||
print_scope_hint(project_path);
|
||||
println!();
|
||||
@@ -2252,7 +2258,7 @@ fn print_overlap_human(r: &OverlapResult, project_path: Option<&str>) {
|
||||
if r.users.is_empty() {
|
||||
println!(
|
||||
" {}",
|
||||
style("No overlapping users found for this path.").dim()
|
||||
Theme::dim().render("No overlapping users found for this path.")
|
||||
);
|
||||
println!();
|
||||
return;
|
||||
@@ -2260,11 +2266,11 @@ fn print_overlap_human(r: &OverlapResult, project_path: Option<&str>) {
|
||||
|
||||
println!(
|
||||
" {:<16} {:<6} {:>7} {:<12} {}",
|
||||
style("Username").bold(),
|
||||
style("Role").bold(),
|
||||
style("MRs").bold(),
|
||||
style("Last Seen").bold(),
|
||||
style("MR Refs").bold(),
|
||||
Theme::bold().render("Username"),
|
||||
Theme::bold().render("Role"),
|
||||
Theme::bold().render("MRs"),
|
||||
Theme::bold().render("Last Seen"),
|
||||
Theme::bold().render("MR Refs"),
|
||||
);
|
||||
|
||||
for user in &r.users {
|
||||
@@ -2283,10 +2289,10 @@ fn print_overlap_human(r: &OverlapResult, project_path: Option<&str>) {
|
||||
|
||||
println!(
|
||||
" {:<16} {:<6} {:>7} {:<12} {}{}",
|
||||
style(format!("@{}", user.username)).cyan(),
|
||||
Theme::info().render(&format!("@{}", user.username)),
|
||||
format_overlap_role(user),
|
||||
user.touch_count,
|
||||
format_relative_time(user.last_seen_at),
|
||||
render::format_relative_time(user.last_seen_at),
|
||||
mr_str,
|
||||
overflow,
|
||||
);
|
||||
@@ -2294,7 +2300,7 @@ fn print_overlap_human(r: &OverlapResult, project_path: Option<&str>) {
|
||||
if r.truncated {
|
||||
println!(
|
||||
" {}",
|
||||
style("(showing first -n; rerun with a higher --limit)").dim()
|
||||
Theme::dim().render("(showing first -n; rerun with a higher --limit)")
|
||||
);
|
||||
}
|
||||
println!();
|
||||
@@ -2532,47 +2538,6 @@ fn overlap_to_json(r: &OverlapResult) -> serde_json::Value {
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Helper Functions ────────────────────────────────────────────────────────
|
||||
|
||||
fn format_relative_time(ms_epoch: i64) -> String {
|
||||
let now = now_ms();
|
||||
let diff = now - ms_epoch;
|
||||
|
||||
if diff < 0 {
|
||||
return "in the future".to_string();
|
||||
}
|
||||
|
||||
match diff {
|
||||
d if d < 60_000 => "just now".to_string(),
|
||||
d if d < 3_600_000 => format!("{} min ago", d / 60_000),
|
||||
d if d < 86_400_000 => {
|
||||
let n = d / 3_600_000;
|
||||
format!("{n} {} ago", if n == 1 { "hour" } else { "hours" })
|
||||
}
|
||||
d if d < 604_800_000 => {
|
||||
let n = d / 86_400_000;
|
||||
format!("{n} {} ago", if n == 1 { "day" } else { "days" })
|
||||
}
|
||||
d if d < 2_592_000_000 => {
|
||||
let n = d / 604_800_000;
|
||||
format!("{n} {} ago", if n == 1 { "week" } else { "weeks" })
|
||||
}
|
||||
_ => {
|
||||
let n = diff / 2_592_000_000;
|
||||
format!("{n} {} ago", if n == 1 { "month" } else { "months" })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_str(s: &str, max: usize) -> String {
|
||||
if s.chars().count() <= max {
|
||||
s.to_owned()
|
||||
} else {
|
||||
let truncated: String = s.chars().take(max.saturating_sub(3)).collect();
|
||||
format!("{truncated}...")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod autocorrect;
|
||||
pub mod commands;
|
||||
pub mod progress;
|
||||
pub mod render;
|
||||
pub mod robot;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
@@ -810,7 +811,8 @@ pub struct EmbedArgs {
|
||||
lore timeline i:42 # Shorthand for issue:42
|
||||
lore timeline mr:99 # Direct: MR !99 and related entities
|
||||
lore timeline 'auth' --since 30d -p group/repo # Scoped to project and time
|
||||
lore timeline 'migration' --depth 2 --expand-mentions # Deep cross-reference expansion")]
|
||||
lore timeline 'migration' --depth 2 # Deep cross-reference expansion
|
||||
lore timeline 'auth' --no-mentions # Only 'closes' and 'related' edges")]
|
||||
pub struct TimelineArgs {
|
||||
/// Search text or entity reference (issue:N, i:N, mr:N, m:N)
|
||||
pub query: String,
|
||||
@@ -827,9 +829,9 @@ pub struct TimelineArgs {
|
||||
#[arg(long, default_value = "1", help_heading = "Expansion")]
|
||||
pub depth: u32,
|
||||
|
||||
/// Also follow 'mentioned' edges during expansion (high fan-out)
|
||||
#[arg(long = "expand-mentions", help_heading = "Expansion")]
|
||||
pub expand_mentions: bool,
|
||||
/// Skip 'mentioned' edges during expansion (only follow 'closes' and 'related')
|
||||
#[arg(long = "no-mentions", help_heading = "Expansion")]
|
||||
pub no_mentions: bool,
|
||||
|
||||
/// Maximum number of events to display
|
||||
#[arg(
|
||||
|
||||
969
src/cli/render.rs
Normal file
969
src/cli/render.rs
Normal 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(¤t_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(¤t_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
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use tracing::{debug, error, info, warn};
|
||||
use tracing::{debug, error, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::db::create_connection;
|
||||
@@ -75,7 +75,7 @@ impl AppLock {
|
||||
"INSERT INTO app_locks (name, owner, acquired_at, heartbeat_at) VALUES (?, ?, ?, ?)",
|
||||
(&self.name, &self.owner, now, now),
|
||||
)?;
|
||||
info!(owner = %self.owner, "Lock acquired (new)");
|
||||
debug!(owner = %self.owner, "Lock acquired (new)");
|
||||
}
|
||||
Some((existing_owner, acquired_at, heartbeat_at)) => {
|
||||
let is_stale = now - heartbeat_at > self.stale_lock_ms;
|
||||
@@ -85,7 +85,7 @@ impl AppLock {
|
||||
"UPDATE app_locks SET owner = ?, acquired_at = ?, heartbeat_at = ? WHERE name = ?",
|
||||
(&self.owner, now, now, &self.name),
|
||||
)?;
|
||||
info!(
|
||||
debug!(
|
||||
owner = %self.owner,
|
||||
previous_owner = %existing_owner,
|
||||
was_stale = is_stale,
|
||||
@@ -125,7 +125,7 @@ impl AppLock {
|
||||
"DELETE FROM app_locks WHERE name = ? AND owner = ?",
|
||||
(&self.name, &self.owner),
|
||||
) {
|
||||
Ok(_) => info!(owner = %self.owner, "Lock released"),
|
||||
Ok(_) => debug!(owner = %self.owner, "Lock released"),
|
||||
Err(e) => error!(
|
||||
owner = %self.owner,
|
||||
error = %e,
|
||||
|
||||
@@ -1,7 +1,45 @@
|
||||
use std::fmt;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use tracing_subscriber::fmt::format::{FormatEvent, FormatFields};
|
||||
use tracing_subscriber::registry::LookupSpan;
|
||||
|
||||
/// Compact stderr formatter: `HH:MM:SS LEVEL message key=value`
|
||||
///
|
||||
/// No span context, no full timestamps, no target — just the essentials.
|
||||
/// The JSON file log is unaffected (it uses its own layer).
|
||||
pub struct CompactHumanFormat;
|
||||
|
||||
impl<S, N> FormatEvent<S, N> for CompactHumanFormat
|
||||
where
|
||||
S: tracing::Subscriber + for<'a> LookupSpan<'a>,
|
||||
N: for<'a> FormatFields<'a> + 'static,
|
||||
{
|
||||
fn format_event(
|
||||
&self,
|
||||
ctx: &tracing_subscriber::fmt::FmtContext<'_, S, N>,
|
||||
mut writer: tracing_subscriber::fmt::format::Writer<'_>,
|
||||
event: &tracing::Event<'_>,
|
||||
) -> fmt::Result {
|
||||
let now = chrono::Local::now();
|
||||
let time = now.format("%H:%M:%S");
|
||||
|
||||
let level = *event.metadata().level();
|
||||
let styled = match level {
|
||||
tracing::Level::ERROR => console::style("ERROR").red().bold(),
|
||||
tracing::Level::WARN => console::style(" WARN").yellow(),
|
||||
tracing::Level::INFO => console::style(" INFO").green(),
|
||||
tracing::Level::DEBUG => console::style("DEBUG").dim(),
|
||||
tracing::Level::TRACE => console::style("TRACE").dim(),
|
||||
};
|
||||
|
||||
write!(writer, "{time} {styled} ")?;
|
||||
ctx.format_fields(writer.by_ref(), event)?;
|
||||
writeln!(writer)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_stderr_filter(verbose: u8, quiet: bool) -> EnvFilter {
|
||||
if std::env::var("RUST_LOG").is_ok() {
|
||||
|
||||
@@ -109,11 +109,13 @@ pub async fn ingest_issues(
|
||||
result.issues_needing_discussion_sync = get_issues_needing_discussion_sync(conn, project_id)?;
|
||||
|
||||
info!(
|
||||
fetched = result.fetched,
|
||||
upserted = result.upserted,
|
||||
labels_created = result.labels_created,
|
||||
needing_sync = result.issues_needing_discussion_sync.len(),
|
||||
"Issue ingestion complete"
|
||||
summary = crate::ingestion::nonzero_summary(&[
|
||||
("fetched", result.fetched),
|
||||
("upserted", result.upserted),
|
||||
("labels", result.labels_created),
|
||||
("needing sync", result.issues_needing_discussion_sync.len()),
|
||||
]),
|
||||
"Issue ingestion"
|
||||
);
|
||||
|
||||
Ok(result)
|
||||
|
||||
@@ -50,7 +50,7 @@ pub async fn ingest_merge_requests(
|
||||
if full_sync {
|
||||
reset_sync_cursor(conn, project_id)?;
|
||||
reset_discussion_watermarks(conn, project_id)?;
|
||||
info!("Full sync: cursor and discussion watermarks reset");
|
||||
debug!("Full sync: cursor and discussion watermarks reset");
|
||||
}
|
||||
|
||||
let cursor = get_sync_cursor(conn, project_id)?;
|
||||
@@ -122,12 +122,14 @@ pub async fn ingest_merge_requests(
|
||||
}
|
||||
|
||||
info!(
|
||||
fetched = result.fetched,
|
||||
upserted = result.upserted,
|
||||
labels_created = result.labels_created,
|
||||
assignees_linked = result.assignees_linked,
|
||||
reviewers_linked = result.reviewers_linked,
|
||||
"MR ingestion complete"
|
||||
summary = crate::ingestion::nonzero_summary(&[
|
||||
("fetched", result.fetched),
|
||||
("upserted", result.upserted),
|
||||
("labels", result.labels_created),
|
||||
("assignees", result.assignees_linked),
|
||||
("reviewers", result.reviewers_linked),
|
||||
]),
|
||||
"MR ingestion"
|
||||
);
|
||||
|
||||
Ok(result)
|
||||
|
||||
@@ -14,6 +14,22 @@ pub use merge_requests::{
|
||||
ingest_merge_requests,
|
||||
};
|
||||
pub use mr_discussions::{IngestMrDiscussionsResult, ingest_mr_discussions};
|
||||
/// Format a set of named counters as a compact human-readable summary,
|
||||
/// filtering out zero values and joining with middle-dot separators.
|
||||
/// Returns `"nothing to update"` when all values are zero.
|
||||
pub(crate) fn nonzero_summary(pairs: &[(&str, usize)]) -> String {
|
||||
let parts: Vec<String> = pairs
|
||||
.iter()
|
||||
.filter(|(_, v)| *v > 0)
|
||||
.map(|(k, v)| format!("{v} {k}"))
|
||||
.collect();
|
||||
if parts.is_empty() {
|
||||
"nothing to update".to_string()
|
||||
} else {
|
||||
parts.join(" \u{b7} ")
|
||||
}
|
||||
}
|
||||
|
||||
pub use orchestrator::{
|
||||
DrainResult, IngestMrProjectResult, IngestProjectResult, ProgressCallback, ProgressEvent,
|
||||
ingest_project_issues, ingest_project_issues_with_progress, ingest_project_merge_requests,
|
||||
|
||||
@@ -269,14 +269,14 @@ pub async fn ingest_mr_discussions(
|
||||
}
|
||||
|
||||
info!(
|
||||
mrs_processed = mrs.len(),
|
||||
discussions_fetched = total_result.discussions_fetched,
|
||||
discussions_upserted = total_result.discussions_upserted,
|
||||
notes_upserted = total_result.notes_upserted,
|
||||
notes_skipped = total_result.notes_skipped_bad_timestamp,
|
||||
diffnotes = total_result.diffnotes_count,
|
||||
pagination_succeeded = total_result.pagination_succeeded,
|
||||
"MR discussion ingestion complete"
|
||||
summary = crate::ingestion::nonzero_summary(&[
|
||||
("MRs", mrs.len()),
|
||||
("discussions", total_result.discussions_fetched),
|
||||
("notes", total_result.notes_upserted),
|
||||
("skipped", total_result.notes_skipped_bad_timestamp),
|
||||
("diffnotes", total_result.diffnotes_count),
|
||||
]),
|
||||
"MR discussion ingestion"
|
||||
);
|
||||
|
||||
Ok(total_result)
|
||||
|
||||
@@ -207,7 +207,7 @@ pub async fn ingest_project_issues_with_progress(
|
||||
}
|
||||
|
||||
if signal.is_cancelled() {
|
||||
info!("Shutdown requested after status fetch, skipping DB write");
|
||||
debug!("Shutdown requested after status fetch, skipping DB write");
|
||||
emit(ProgressEvent::StatusEnrichmentComplete {
|
||||
enriched: 0,
|
||||
cleared: 0,
|
||||
@@ -275,12 +275,12 @@ pub async fn ingest_project_issues_with_progress(
|
||||
result.issues_skipped_discussion_sync = total_issues.saturating_sub(issues_needing_sync.len());
|
||||
|
||||
if signal.is_cancelled() {
|
||||
info!("Shutdown requested, returning partial issue results");
|
||||
debug!("Shutdown requested, returning partial issue results");
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
if issues_needing_sync.is_empty() {
|
||||
info!("No issues need discussion sync");
|
||||
debug!("No issues need discussion sync");
|
||||
} else {
|
||||
info!(
|
||||
count = issues_needing_sync.len(),
|
||||
@@ -314,7 +314,7 @@ pub async fn ingest_project_issues_with_progress(
|
||||
}
|
||||
|
||||
if signal.is_cancelled() {
|
||||
info!("Shutdown requested, returning partial issue results");
|
||||
debug!("Shutdown requested, returning partial issue results");
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
@@ -348,16 +348,18 @@ pub async fn ingest_project_issues_with_progress(
|
||||
}
|
||||
|
||||
info!(
|
||||
issues_fetched = result.issues_fetched,
|
||||
issues_upserted = result.issues_upserted,
|
||||
labels_created = result.labels_created,
|
||||
discussions_fetched = result.discussions_fetched,
|
||||
notes_upserted = result.notes_upserted,
|
||||
issues_synced = result.issues_synced_discussions,
|
||||
issues_skipped = result.issues_skipped_discussion_sync,
|
||||
resource_events_fetched = result.resource_events_fetched,
|
||||
resource_events_failed = result.resource_events_failed,
|
||||
"Project ingestion complete"
|
||||
summary = crate::ingestion::nonzero_summary(&[
|
||||
("fetched", result.issues_fetched),
|
||||
("upserted", result.issues_upserted),
|
||||
("labels", result.labels_created),
|
||||
("discussions", result.discussions_fetched),
|
||||
("notes", result.notes_upserted),
|
||||
("synced", result.issues_synced_discussions),
|
||||
("skipped", result.issues_skipped_discussion_sync),
|
||||
("events", result.resource_events_fetched),
|
||||
("event errors", result.resource_events_failed),
|
||||
]),
|
||||
"Project complete"
|
||||
);
|
||||
|
||||
tracing::Span::current().record("items_processed", result.issues_upserted);
|
||||
@@ -445,7 +447,7 @@ async fn sync_discussions_sequential(
|
||||
|
||||
for chunk in issues.chunks(batch_size) {
|
||||
if signal.is_cancelled() {
|
||||
info!("Shutdown requested during discussion sync, returning partial results");
|
||||
debug!("Shutdown requested during discussion sync, returning partial results");
|
||||
break;
|
||||
}
|
||||
for issue in chunk {
|
||||
@@ -549,12 +551,12 @@ pub async fn ingest_project_merge_requests_with_progress(
|
||||
result.mrs_skipped_discussion_sync = total_mrs.saturating_sub(mrs_needing_sync.len());
|
||||
|
||||
if signal.is_cancelled() {
|
||||
info!("Shutdown requested, returning partial MR results");
|
||||
debug!("Shutdown requested, returning partial MR results");
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
if mrs_needing_sync.is_empty() {
|
||||
info!("No MRs need discussion sync");
|
||||
debug!("No MRs need discussion sync");
|
||||
} else {
|
||||
info!(
|
||||
count = mrs_needing_sync.len(),
|
||||
@@ -592,7 +594,7 @@ pub async fn ingest_project_merge_requests_with_progress(
|
||||
}
|
||||
|
||||
if signal.is_cancelled() {
|
||||
info!("Shutdown requested, returning partial MR results");
|
||||
debug!("Shutdown requested, returning partial MR results");
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
@@ -626,7 +628,7 @@ pub async fn ingest_project_merge_requests_with_progress(
|
||||
}
|
||||
|
||||
if signal.is_cancelled() {
|
||||
info!("Shutdown requested, returning partial MR results");
|
||||
debug!("Shutdown requested, returning partial MR results");
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
@@ -679,7 +681,7 @@ pub async fn ingest_project_merge_requests_with_progress(
|
||||
}
|
||||
|
||||
if signal.is_cancelled() {
|
||||
info!("Shutdown requested, returning partial MR results");
|
||||
debug!("Shutdown requested, returning partial MR results");
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
@@ -704,21 +706,23 @@ pub async fn ingest_project_merge_requests_with_progress(
|
||||
}
|
||||
|
||||
info!(
|
||||
mrs_fetched = result.mrs_fetched,
|
||||
mrs_upserted = result.mrs_upserted,
|
||||
labels_created = result.labels_created,
|
||||
discussions_fetched = result.discussions_fetched,
|
||||
notes_upserted = result.notes_upserted,
|
||||
diffnotes = result.diffnotes_count,
|
||||
mrs_synced = result.mrs_synced_discussions,
|
||||
mrs_skipped = result.mrs_skipped_discussion_sync,
|
||||
resource_events_fetched = result.resource_events_fetched,
|
||||
resource_events_failed = result.resource_events_failed,
|
||||
closes_issues_fetched = result.closes_issues_fetched,
|
||||
closes_issues_failed = result.closes_issues_failed,
|
||||
mr_diffs_fetched = result.mr_diffs_fetched,
|
||||
mr_diffs_failed = result.mr_diffs_failed,
|
||||
"MR project ingestion complete"
|
||||
summary = crate::ingestion::nonzero_summary(&[
|
||||
("fetched", result.mrs_fetched),
|
||||
("upserted", result.mrs_upserted),
|
||||
("labels", result.labels_created),
|
||||
("discussions", result.discussions_fetched),
|
||||
("notes", result.notes_upserted),
|
||||
("diffnotes", result.diffnotes_count),
|
||||
("synced", result.mrs_synced_discussions),
|
||||
("skipped", result.mrs_skipped_discussion_sync),
|
||||
("events", result.resource_events_fetched),
|
||||
("event errors", result.resource_events_failed),
|
||||
("closes", result.closes_issues_fetched),
|
||||
("close errors", result.closes_issues_failed),
|
||||
("diffs", result.mr_diffs_fetched),
|
||||
("diff errors", result.mr_diffs_failed),
|
||||
]),
|
||||
"MR project complete"
|
||||
);
|
||||
|
||||
tracing::Span::current().record("items_processed", result.mrs_upserted);
|
||||
@@ -750,7 +754,7 @@ async fn sync_mr_discussions_sequential(
|
||||
|
||||
for chunk in mrs.chunks(batch_size) {
|
||||
if signal.is_cancelled() {
|
||||
info!("Shutdown requested during MR discussion sync, returning partial results");
|
||||
debug!("Shutdown requested during MR discussion sync, returning partial results");
|
||||
break;
|
||||
}
|
||||
let prefetch_futures = chunk.iter().map(|mr| {
|
||||
@@ -947,7 +951,7 @@ async fn drain_resource_events(
|
||||
|
||||
loop {
|
||||
if signal.is_cancelled() {
|
||||
info!("Shutdown requested during resource events drain, returning partial results");
|
||||
debug!("Shutdown requested during resource events drain, returning partial results");
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1269,7 +1273,7 @@ async fn drain_mr_closes_issues(
|
||||
|
||||
loop {
|
||||
if signal.is_cancelled() {
|
||||
info!("Shutdown requested during closes_issues drain, returning partial results");
|
||||
debug!("Shutdown requested during closes_issues drain, returning partial results");
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1526,7 +1530,7 @@ async fn drain_mr_diffs(
|
||||
|
||||
loop {
|
||||
if signal.is_cancelled() {
|
||||
info!("Shutdown requested during mr_diffs drain, returning partial results");
|
||||
debug!("Shutdown requested during mr_diffs drain, returning partial results");
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
223
src/main.rs
223
src/main.rs
@@ -1,5 +1,4 @@
|
||||
use clap::Parser;
|
||||
use console::style;
|
||||
use dialoguer::{Confirm, Input};
|
||||
use serde::Serialize;
|
||||
use strsim::jaro_winkler;
|
||||
@@ -26,6 +25,7 @@ use lore::cli::commands::{
|
||||
run_list_issues, run_list_mrs, run_search, run_show_issue, run_show_mr, run_stats, run_sync,
|
||||
run_sync_status, run_timeline, run_who,
|
||||
};
|
||||
use lore::cli::render::{ColorMode, LoreRenderer, Theme};
|
||||
use lore::cli::robot::{RobotMeta, strip_schemas};
|
||||
use lore::cli::{
|
||||
Cli, Commands, CountArgs, EmbedArgs, GenerateDocsArgs, IngestArgs, IssuesArgs, MrsArgs,
|
||||
@@ -116,7 +116,7 @@ async fn main() {
|
||||
}
|
||||
} else {
|
||||
let stderr_layer = tracing_subscriber::fmt::layer()
|
||||
.with_target(false)
|
||||
.event_format(logging::CompactHumanFormat)
|
||||
.with_writer(lore::cli::progress::SuspendingWriter)
|
||||
.with_filter(stderr_filter);
|
||||
|
||||
@@ -146,13 +146,23 @@ async fn main() {
|
||||
|
||||
// I1: Respect NO_COLOR convention (https://no-color.org/)
|
||||
if std::env::var("NO_COLOR").is_ok_and(|v| !v.is_empty()) {
|
||||
LoreRenderer::init(ColorMode::Never);
|
||||
console::set_colors_enabled(false);
|
||||
} else {
|
||||
match cli.color.as_str() {
|
||||
"never" => console::set_colors_enabled(false),
|
||||
"always" => console::set_colors_enabled(true),
|
||||
"auto" => {}
|
||||
"never" => {
|
||||
LoreRenderer::init(ColorMode::Never);
|
||||
console::set_colors_enabled(false);
|
||||
}
|
||||
"always" => {
|
||||
LoreRenderer::init(ColorMode::Always);
|
||||
console::set_colors_enabled(true);
|
||||
}
|
||||
"auto" => {
|
||||
LoreRenderer::init(ColorMode::Auto);
|
||||
}
|
||||
other => {
|
||||
LoreRenderer::init(ColorMode::Auto);
|
||||
eprintln!("Warning: unknown color mode '{}', using auto", other);
|
||||
}
|
||||
}
|
||||
@@ -277,8 +287,9 @@ async fn main() {
|
||||
} else {
|
||||
eprintln!(
|
||||
"{}",
|
||||
style("warning: 'lore list' is deprecated, use 'lore issues' or 'lore mrs'")
|
||||
.yellow()
|
||||
Theme::warning().render(
|
||||
"warning: 'lore list' is deprecated, use 'lore issues' or 'lore mrs'"
|
||||
)
|
||||
);
|
||||
}
|
||||
handle_list_compat(
|
||||
@@ -318,11 +329,10 @@ async fn main() {
|
||||
} else {
|
||||
eprintln!(
|
||||
"{}",
|
||||
style(format!(
|
||||
Theme::warning().render(&format!(
|
||||
"warning: 'lore show' is deprecated, use 'lore {}s {}'",
|
||||
entity, iid
|
||||
))
|
||||
.yellow()
|
||||
);
|
||||
}
|
||||
handle_show_compat(
|
||||
@@ -342,7 +352,8 @@ async fn main() {
|
||||
} else {
|
||||
eprintln!(
|
||||
"{}",
|
||||
style("warning: 'lore auth-test' is deprecated, use 'lore auth'").yellow()
|
||||
Theme::warning()
|
||||
.render("warning: 'lore auth-test' is deprecated, use 'lore auth'")
|
||||
);
|
||||
}
|
||||
handle_auth_test(cli.config.as_deref(), robot_mode).await
|
||||
@@ -355,7 +366,8 @@ async fn main() {
|
||||
} else {
|
||||
eprintln!(
|
||||
"{}",
|
||||
style("warning: 'lore sync-status' is deprecated, use 'lore status'").yellow()
|
||||
Theme::warning()
|
||||
.render("warning: 'lore sync-status' is deprecated, use 'lore status'")
|
||||
);
|
||||
}
|
||||
handle_sync_status_cmd(cli.config.as_deref(), robot_mode).await
|
||||
@@ -397,9 +409,20 @@ fn handle_error(e: Box<dyn std::error::Error>, robot_mode: bool) -> ! {
|
||||
);
|
||||
std::process::exit(gi_error.exit_code());
|
||||
} else {
|
||||
eprintln!("{} {}", style("Error:").red(), gi_error);
|
||||
eprintln!("{} {}", Theme::error().render("Error:"), gi_error);
|
||||
if let Some(suggestion) = gi_error.suggestion() {
|
||||
eprintln!("{} {}", style("Hint:").yellow(), suggestion);
|
||||
eprintln!("{} {}", Theme::warning().render("Hint:"), suggestion);
|
||||
}
|
||||
let actions = gi_error.actions();
|
||||
if !actions.is_empty() {
|
||||
eprintln!();
|
||||
for action in &actions {
|
||||
eprintln!(
|
||||
" {} {}",
|
||||
Theme::dim().render("$"),
|
||||
Theme::bold().render(action)
|
||||
);
|
||||
}
|
||||
}
|
||||
std::process::exit(gi_error.exit_code());
|
||||
}
|
||||
@@ -420,7 +443,7 @@ fn handle_error(e: Box<dyn std::error::Error>, robot_mode: bool) -> ! {
|
||||
})
|
||||
);
|
||||
} else {
|
||||
eprintln!("{} {}", style("Error:").red(), e);
|
||||
eprintln!("{} {}", Theme::error().render("Error:"), e);
|
||||
}
|
||||
std::process::exit(1);
|
||||
}
|
||||
@@ -459,7 +482,7 @@ fn emit_correction_warnings(result: &CorrectionResult, robot_mode: bool) {
|
||||
for c in &result.corrections {
|
||||
eprintln!(
|
||||
"{} {}",
|
||||
style("Auto-corrected:").yellow(),
|
||||
Theme::warning().render("Auto-corrected:"),
|
||||
autocorrect::format_teaching_note(c)
|
||||
);
|
||||
}
|
||||
@@ -984,7 +1007,7 @@ async fn handle_ingest(
|
||||
if !robot_mode && !quiet {
|
||||
println!(
|
||||
"{}",
|
||||
style("Ingesting all content (issues + merge requests)...").blue()
|
||||
Theme::info().render("Ingesting all content (issues + merge requests)...")
|
||||
);
|
||||
println!();
|
||||
}
|
||||
@@ -1027,7 +1050,7 @@ async fn handle_ingest(
|
||||
if !robot_mode {
|
||||
eprintln!(
|
||||
"{}",
|
||||
style("Interrupted by Ctrl+C. Partial data has been saved.").yellow()
|
||||
Theme::warning().render("Interrupted by Ctrl+C. Partial data has been saved.")
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
@@ -1037,6 +1060,12 @@ async fn handle_ingest(
|
||||
let total_items: usize = stages.iter().map(|s| s.items_processed).sum();
|
||||
let total_errors: usize = stages.iter().map(|s| s.errors).sum();
|
||||
let _ = recorder.succeed(&recorder_conn, &stages, total_items, total_errors);
|
||||
if !robot_mode && !quiet {
|
||||
eprintln!(
|
||||
"{}",
|
||||
Theme::dim().render("Hint: Run 'lore generate-docs' to update searchable documents, then 'lore embed' for vectors.")
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -1311,11 +1340,10 @@ async fn handle_init(
|
||||
if non_interactive {
|
||||
eprintln!(
|
||||
"{}",
|
||||
style(format!(
|
||||
Theme::error().render(&format!(
|
||||
"Config file exists at {}. Use --force to overwrite.",
|
||||
config_path.display()
|
||||
))
|
||||
.red()
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
@@ -1329,7 +1357,7 @@ async fn handle_init(
|
||||
.interact()?;
|
||||
|
||||
if !confirm {
|
||||
println!("{}", style("Cancelled.").yellow());
|
||||
println!("{}", Theme::warning().render("Cancelled."));
|
||||
std::process::exit(2);
|
||||
}
|
||||
confirmed_overwrite = true;
|
||||
@@ -1408,7 +1436,7 @@ async fn handle_init(
|
||||
None
|
||||
};
|
||||
|
||||
println!("{}", style("\nValidating configuration...").blue());
|
||||
println!("{}", Theme::info().render("Validating configuration..."));
|
||||
|
||||
let result = run_init(
|
||||
InitInputs {
|
||||
@@ -1427,35 +1455,43 @@ async fn handle_init(
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
style(format!(
|
||||
"\n✓ Authenticated as @{} ({})",
|
||||
Theme::success().render(&format!(
|
||||
"\n\u{2713} Authenticated as @{} ({})",
|
||||
result.user.username, result.user.name
|
||||
))
|
||||
.green()
|
||||
);
|
||||
|
||||
for project in &result.projects {
|
||||
println!(
|
||||
"{}",
|
||||
style(format!("✓ {} ({})", project.path, project.name)).green()
|
||||
Theme::success().render(&format!("\u{2713} {} ({})", project.path, project.name))
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(ref dp) = result.default_project {
|
||||
println!("{}", style(format!("✓ Default project: {dp}")).green());
|
||||
println!(
|
||||
"{}",
|
||||
Theme::success().render(&format!("\u{2713} Default project: {dp}"))
|
||||
);
|
||||
}
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
style(format!("\n✓ Config written to {}", result.config_path)).green()
|
||||
Theme::success().render(&format!(
|
||||
"\n\u{2713} Config written to {}",
|
||||
result.config_path
|
||||
))
|
||||
);
|
||||
println!(
|
||||
"{}",
|
||||
style(format!("✓ Database initialized at {}", result.data_dir)).green()
|
||||
Theme::success().render(&format!(
|
||||
"\u{2713} Database initialized at {}",
|
||||
result.data_dir
|
||||
))
|
||||
);
|
||||
println!(
|
||||
"{}",
|
||||
style("\nSetup complete! Run 'lore doctor' to verify.").blue()
|
||||
Theme::info().render("\nSetup complete! Run 'lore doctor' to verify.")
|
||||
);
|
||||
|
||||
Ok(())
|
||||
@@ -1518,9 +1554,9 @@ async fn handle_auth_test(
|
||||
})
|
||||
);
|
||||
} else {
|
||||
eprintln!("{} {}", style("Error:").red(), e);
|
||||
eprintln!("{} {}", Theme::error().render("Error:"), e);
|
||||
if let Some(suggestion) = e.suggestion() {
|
||||
eprintln!("{} {}", style("Hint:").yellow(), suggestion);
|
||||
eprintln!("{} {}", Theme::warning().render("Hint:"), suggestion);
|
||||
}
|
||||
}
|
||||
std::process::exit(e.exit_code());
|
||||
@@ -1647,7 +1683,7 @@ fn handle_backup(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>> {
|
||||
} else {
|
||||
eprintln!(
|
||||
"{} The 'backup' command is not yet implemented.",
|
||||
style("Error:").red()
|
||||
Theme::error().render("Error:")
|
||||
);
|
||||
}
|
||||
std::process::exit(1);
|
||||
@@ -1669,7 +1705,7 @@ fn handle_reset(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>> {
|
||||
} else {
|
||||
eprintln!(
|
||||
"{} The 'reset' command is not yet implemented.",
|
||||
style("Error:").red()
|
||||
Theme::error().render("Error:")
|
||||
);
|
||||
}
|
||||
std::process::exit(1);
|
||||
@@ -1728,11 +1764,11 @@ async fn handle_migrate(
|
||||
} else {
|
||||
eprintln!(
|
||||
"{}",
|
||||
style(format!("Database not found at {}", db_path.display())).red()
|
||||
Theme::error().render(&format!("Database not found at {}", db_path.display()))
|
||||
);
|
||||
eprintln!(
|
||||
"{}",
|
||||
style("Run 'lore init' first to create the database.").yellow()
|
||||
Theme::warning().render("Run 'lore init' first to create the database.")
|
||||
);
|
||||
}
|
||||
std::process::exit(10);
|
||||
@@ -1744,7 +1780,7 @@ async fn handle_migrate(
|
||||
if !robot_mode {
|
||||
println!(
|
||||
"{}",
|
||||
style(format!("Current schema version: {}", before_version)).blue()
|
||||
Theme::info().render(&format!("Current schema version: {}", before_version))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1768,14 +1804,16 @@ async fn handle_migrate(
|
||||
} else if after_version > before_version {
|
||||
println!(
|
||||
"{}",
|
||||
style(format!(
|
||||
Theme::success().render(&format!(
|
||||
"Migrations applied: {} -> {}",
|
||||
before_version, after_version
|
||||
))
|
||||
.green()
|
||||
);
|
||||
} else {
|
||||
println!("{}", style("Database is already up to date.").green());
|
||||
println!(
|
||||
"{}",
|
||||
Theme::success().render("Database is already up to date.")
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -1813,7 +1851,7 @@ async fn handle_timeline(
|
||||
.map(String::from),
|
||||
since: args.since,
|
||||
depth: args.depth,
|
||||
expand_mentions: args.expand_mentions,
|
||||
no_mentions: args.no_mentions,
|
||||
limit: args.limit,
|
||||
max_seeds: args.max_seeds,
|
||||
max_entities: args.max_entities,
|
||||
@@ -1828,7 +1866,7 @@ async fn handle_timeline(
|
||||
&result,
|
||||
result.total_events_before_limit,
|
||||
params.depth,
|
||||
params.expand_mentions,
|
||||
!params.no_mentions,
|
||||
args.fields.as_deref(),
|
||||
);
|
||||
} else {
|
||||
@@ -1900,10 +1938,25 @@ async fn handle_generate_docs(
|
||||
|
||||
let project = config.effective_project(args.project.as_deref());
|
||||
let result = run_generate_docs(&config, args.full, project, None)?;
|
||||
let elapsed = start.elapsed();
|
||||
if robot_mode {
|
||||
print_generate_docs_json(&result, start.elapsed().as_millis() as u64);
|
||||
print_generate_docs_json(&result, elapsed.as_millis() as u64);
|
||||
} else {
|
||||
print_generate_docs(&result);
|
||||
if elapsed.as_secs() >= 1 {
|
||||
eprintln!(
|
||||
"{}",
|
||||
Theme::dim().render(&format!(" Done in {:.1}s", elapsed.as_secs_f64()))
|
||||
);
|
||||
}
|
||||
if result.regenerated > 0 {
|
||||
eprintln!(
|
||||
"{}",
|
||||
Theme::dim().render(
|
||||
"Hint: Run 'lore embed' to update vector embeddings for changed documents."
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1913,6 +1966,10 @@ async fn handle_embed(
|
||||
args: EmbedArgs,
|
||||
robot_mode: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let config = Config::load(config_override)?;
|
||||
let full = args.full && !args.no_full;
|
||||
@@ -1928,11 +1985,45 @@ async fn handle_embed(
|
||||
std::process::exit(130);
|
||||
});
|
||||
|
||||
let result = run_embed(&config, full, retry_failed, None, &signal).await?;
|
||||
let embed_bar = if robot_mode {
|
||||
ProgressBar::hidden()
|
||||
} else {
|
||||
let b = lore::cli::progress::multi().add(ProgressBar::new(0));
|
||||
b.set_style(
|
||||
ProgressStyle::default_bar()
|
||||
.template(" {spinner:.blue} Generating embeddings [{bar:30.cyan/dim}] {pos}/{len}")
|
||||
.unwrap()
|
||||
.progress_chars("=> "),
|
||||
);
|
||||
b
|
||||
};
|
||||
let bar_clone = embed_bar.clone();
|
||||
let tick_started = Arc::new(AtomicBool::new(false));
|
||||
let tick_clone = Arc::clone(&tick_started);
|
||||
let progress_cb: Box<dyn Fn(usize, usize)> = Box::new(move |processed, total| {
|
||||
if total > 0 {
|
||||
if !tick_clone.swap(true, Ordering::Relaxed) {
|
||||
bar_clone.enable_steady_tick(std::time::Duration::from_millis(100));
|
||||
}
|
||||
bar_clone.set_length(total as u64);
|
||||
bar_clone.set_position(processed as u64);
|
||||
}
|
||||
});
|
||||
|
||||
let result = run_embed(&config, full, retry_failed, Some(progress_cb), &signal).await?;
|
||||
embed_bar.finish_and_clear();
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
if robot_mode {
|
||||
print_embed_json(&result, start.elapsed().as_millis() as u64);
|
||||
print_embed_json(&result, elapsed.as_millis() as u64);
|
||||
} else {
|
||||
print_embed(&result);
|
||||
if elapsed.as_secs() >= 1 {
|
||||
eprintln!(
|
||||
"{}",
|
||||
Theme::dim().render(&format!(" Done in {:.1}s", elapsed.as_secs_f64()))
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1962,7 +2053,7 @@ async fn handle_sync_cmd(
|
||||
dry_run,
|
||||
};
|
||||
|
||||
// For dry_run, skip recording and just show the preview
|
||||
// For dry run, skip recording and just show the preview
|
||||
if dry_run {
|
||||
let signal = ShutdownSignal::new();
|
||||
run_sync(&config, options, None, &signal).await?;
|
||||
@@ -2003,13 +2094,13 @@ async fn handle_sync_cmd(
|
||||
eprintln!();
|
||||
eprintln!(
|
||||
"{}",
|
||||
console::style("Interrupted by Ctrl+C. Partial results:").yellow()
|
||||
Theme::warning().render("Interrupted by Ctrl+C. Partial results:")
|
||||
);
|
||||
print_sync(&result, elapsed, Some(metrics));
|
||||
if released > 0 {
|
||||
eprintln!(
|
||||
"{}",
|
||||
console::style(format!("Released {released} locked jobs")).dim()
|
||||
Theme::dim().render(&format!("Released {released} locked jobs"))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2121,9 +2212,9 @@ async fn handle_health(
|
||||
} else {
|
||||
let status = |ok: bool| {
|
||||
if ok {
|
||||
style("pass").green()
|
||||
Theme::success().render("pass")
|
||||
} else {
|
||||
style("FAIL").red()
|
||||
Theme::error().render("FAIL")
|
||||
}
|
||||
};
|
||||
println!(
|
||||
@@ -2135,13 +2226,13 @@ async fn handle_health(
|
||||
println!("Schema: {} (v{})", status(schema_current), schema_version);
|
||||
println!();
|
||||
if healthy {
|
||||
println!("{}", style("Healthy").green().bold());
|
||||
println!("{}", Theme::success().bold().render("Healthy"));
|
||||
} else {
|
||||
println!(
|
||||
"{}",
|
||||
style("Unhealthy - run 'lore doctor' for details")
|
||||
.red()
|
||||
Theme::error()
|
||||
.bold()
|
||||
.render("Unhealthy - run 'lore doctor' for details")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2243,7 +2334,7 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
|
||||
},
|
||||
"sync": {
|
||||
"description": "Full sync pipeline: ingest -> generate-docs -> embed",
|
||||
"flags": ["--full", "--no-full", "--force", "--no-force", "--no-embed", "--no-docs", "--no-events", "--dry-run", "--no-dry-run"],
|
||||
"flags": ["--full", "--no-full", "--force", "--no-force", "--no-embed", "--no-docs", "--no-events", "--no-file-changes", "--dry-run", "--no-dry-run"],
|
||||
"example": "lore --robot sync",
|
||||
"response_schema": {
|
||||
"ok": "bool",
|
||||
@@ -2382,7 +2473,7 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
|
||||
},
|
||||
"timeline": {
|
||||
"description": "Chronological timeline of events matching a keyword query or entity reference",
|
||||
"flags": ["<QUERY>", "-p/--project", "--since <duration>", "--depth <n>", "--expand-mentions", "-n/--limit", "--fields <list>", "--max-seeds", "--max-entities", "--max-evidence"],
|
||||
"flags": ["<QUERY>", "-p/--project", "--since <duration>", "--depth <n>", "--no-mentions", "-n/--limit", "--fields <list>", "--max-seeds", "--max-entities", "--max-evidence"],
|
||||
"query_syntax": {
|
||||
"search": "Any text -> hybrid search seeding (FTS + vector)",
|
||||
"entity_direct": "issue:N, i:N, mr:N, m:N -> direct entity seeding (no search, no Ollama)"
|
||||
@@ -2397,7 +2488,7 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
|
||||
},
|
||||
"who": {
|
||||
"description": "People intelligence: experts, workload, active discussions, overlap, review patterns",
|
||||
"flags": ["<target>", "--path <path>", "--active", "--overlap <path>", "--reviews", "--since <duration>", "-p/--project", "-n/--limit", "--fields <list>"],
|
||||
"flags": ["<target>", "--path <path>", "--active", "--overlap <path>", "--reviews", "--since <duration>", "-p/--project", "-n/--limit", "--fields <list>", "--detail", "--no-detail", "--as-of <date>", "--explain-score", "--include-bots", "--all-history"],
|
||||
"modes": {
|
||||
"expert": "lore who <file-path> -- Who knows about this area? (also: --path for root files)",
|
||||
"workload": "lore who <username> -- What is someone working on?",
|
||||
@@ -2423,6 +2514,16 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
|
||||
"active_minimal": ["entity_type", "iid", "title", "participants"]
|
||||
}
|
||||
},
|
||||
"drift": {
|
||||
"description": "Detect discussion divergence from original issue intent",
|
||||
"flags": ["<entity_type: issues>", "<IID>", "--threshold <0.0-1.0>", "-p/--project <path>"],
|
||||
"example": "lore --robot drift issues 42 --threshold 0.4",
|
||||
"response_schema": {
|
||||
"ok": "bool",
|
||||
"data": {"entity_type": "string", "iid": "int", "title": "string", "threshold": "float", "divergent_discussions": "[{discussion_id:string, similarity:float, snippet:string}]"},
|
||||
"meta": {"elapsed_ms": "int"}
|
||||
}
|
||||
},
|
||||
"notes": {
|
||||
"description": "List notes from discussions with rich filtering",
|
||||
"flags": ["--limit/-n <N>", "--author/-a <username>", "--note-type <type>", "--contains <text>", "--for-issue <iid>", "--for-mr <iid>", "-p/--project <path>", "--since <period>", "--until <period>", "--path <filepath>", "--resolution <any|unresolved|resolved>", "--sort <created|updated>", "--asc", "--include-system", "--note-id <id>", "--gitlab-note-id <id>", "--discussion-id <id>", "--format <table|json|jsonl|csv>", "--fields <list|minimal>", "--open"],
|
||||
@@ -2511,7 +2612,7 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
|
||||
"temporal_intelligence": [
|
||||
"lore --robot sync",
|
||||
"lore --robot timeline '<keyword>' --since 30d",
|
||||
"lore --robot timeline '<keyword>' --depth 2 --expand-mentions"
|
||||
"lore --robot timeline '<keyword>' --depth 2"
|
||||
],
|
||||
"people_intelligence": [
|
||||
"lore --robot who src/path/to/feature/",
|
||||
@@ -2762,7 +2863,10 @@ async fn handle_list_compat(
|
||||
Ok(())
|
||||
}
|
||||
_ => {
|
||||
eprintln!("{}", style(format!("Unknown entity: {entity}")).red());
|
||||
eprintln!(
|
||||
"{}",
|
||||
Theme::error().render(&format!("Unknown entity: {entity}"))
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -2799,7 +2903,10 @@ async fn handle_show_compat(
|
||||
Ok(())
|
||||
}
|
||||
_ => {
|
||||
eprintln!("{}", style(format!("Unknown entity: {entity}")).red());
|
||||
eprintln!(
|
||||
"{}",
|
||||
Theme::error().render(&format!("Unknown entity: {entity}"))
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,7 +257,8 @@ mod tests {
|
||||
#[test]
|
||||
fn test_raw_mode_leading_wildcard_falls_back_to_safe() {
|
||||
let result = to_fts_query("* OR auth", FtsQueryMode::Raw);
|
||||
assert_eq!(result, "\"*\" \"OR\" \"auth\"");
|
||||
// Falls back to Safe mode; OR is an FTS5 operator so it passes through unquoted
|
||||
assert_eq!(result, "\"*\" OR \"auth\"");
|
||||
|
||||
let result = to_fts_query("*", FtsQueryMode::Raw);
|
||||
assert_eq!(result, "\"*\"");
|
||||
|
||||
Reference in New Issue
Block a user