7 Commits

Author SHA1 Message Date
Taylor Eernisse
51c370fac2 feat(project): Add substring matching and use Ambiguous error for resolution
Extend resolve_project() with a 4th cascade step: case-insensitive
substring match when exact, case-insensitive, and suffix matches all
fail. This allows shorthand like "typescript" to match
"vs/typescript-code" when unambiguous. Multi-match still returns an
error with all candidates listed.

Also change ambiguity errors from LoreError::Other to LoreError::Ambiguous
so they get the proper AMBIGUOUS error code (exit 18) instead of
INTERNAL_ERROR.

Includes tests for unambiguous substring, case-insensitive substring,
ambiguous substring, and suffix-preferred-over-substring ordering.

Co-Authored-By: Claude (us.anthropic.claude-opus-4-5-20251101-v1:0) <noreply@anthropic.com>
2026-01-30 16:55:23 -05:00
Taylor Eernisse
7b7d781a19 docs: Update exit codes, add config precedence and shell completions
Exit code tables (README + AGENTS.md):
- Add codes 14-16 (Ollama unavailable, model not found, embedding failed)
- Add code 20 (Config not found, remapped from 2)
- Clarify code 1 (now includes health check failed + not implemented)
- Clarify code 2 (now exclusively usage/parsing errors from clap)

New sections:
- Configuration Precedence: CLI flags > env vars > config file > defaults
- Shell Completions: bash, zsh, fish, powershell installation instructions

Co-Authored-By: Claude (us.anthropic.claude-opus-4-5-20251101-v1:0) <noreply@anthropic.com>
2026-01-30 16:55:02 -05:00
Taylor Eernisse
03ea51513d feat(main): Wire SIGPIPE, color, quiet, completions, and negation flag handling
Runtime setup:
- Reset SIGPIPE to SIG_DFL on Unix at the very start of main() so
  piping to head/grep doesn't cause a panic.
- Apply --color flag to console::set_colors_enabled() after CLI parse.
- Extract quiet flag and thread it to handle_ingest.

Command dispatch:
- Add Completions match arm using clap_complete::generate().
- Resolve all --no-X negation flags in handlers: asc, has_due, open
  (issues/mrs), force/full (ingest/sync), check (stats), explain
  (search), retry_failed (embed).
- Auto-enable --check when --repair is used in handle_stats.
- Suppress deprecation warnings in robot mode for List, Show, AuthTest,
  and SyncStatus deprecated aliases.

Stubs:
- Change handle_backup/handle_reset from ok:true to structured error
  JSON on stderr with exit code 1. Remove unused NotImplementedOutput
  and NotImplementedData structs.

Version:
- Include GIT_HASH env var in handle_version output (human and robot).
- Add git_hash field to VersionData with skip_serializing_if for None.

Robot-docs:
- Update exit code table with codes 14-18 (Ollama, NotFound, Ambiguous)
  and code 20 (ConfigNotFound). Clarify code 1 and 2 descriptions.

Co-Authored-By: Claude (us.anthropic.claude-opus-4-5-20251101-v1:0) <noreply@anthropic.com>
2026-01-30 16:54:53 -05:00
Taylor Eernisse
667f70e177 refactor(commands): Add IngestDisplay, resolve_project, and color-aware tables
Ingest:
- Introduce IngestDisplay struct with show_progress/show_text booleans
  to decouple progress bars from text output. Replaces the robot_mode
  bool parameter with explicit display control, enabling sync to show
  progress without duplicating summary text (progress_only mode).
- Use resolve_project() for --project filtering instead of LIKE queries,
  providing proper error messages for ambiguous or missing projects.

List:
- Add colored_cell() helper that checks console::colors_enabled() before
  applying comfy-table foreground colors, bridging the gap between the
  console and comfy-table crates for --color flag support.
- Use resolve_project() for project filtering (exact ID match).
- Improve since filter to return explicit errors instead of silently
  ignoring invalid values.
- Improve format_relative_time for proper singular/plural forms.

Search:
- Validate --after/--updated-after with explicit error messages.
- Handle optional title field (Option<String>) in HydratedRow.

Show:
- Use resolve_project() for project disambiguation.

Sync:
- Thread robot_mode via SyncOptions for IngestDisplay selection.
- Use IngestDisplay::progress_only() in interactive sync mode.

GenerateDocs:
- Use resolve_project() for --project filtering.

Co-Authored-By: Claude (us.anthropic.claude-opus-4-5-20251101-v1:0) <noreply@anthropic.com>
2026-01-30 16:54:36 -05:00
Taylor Eernisse
585b746461 feat(cli): Add --color, --quiet, --no-X negations, completions, and help headings
Global flags:
- --color (auto|always|never) for explicit color control
- --quiet/-q to suppress non-essential output
- Hidden Completions subcommand for bash/zsh/fish/powershell

Flag negation (--no-X) with overrides_with for: has-due, asc, open
(issues/mrs), force/full (ingest/sync), check (stats), explain (search),
retry-failed (embed). Enables scripted flag composition where later flags
override earlier ones.

Validation:
- value_parser on search --mode, --type, --fts-mode for early rejection
- Remove requires="check" from --repair (auto-enabled in handler)

Polish:
- help_heading groups (Filters, Sorting, Output, Actions) on issues,
  mrs, and search args for cleaner --help output
- Hide Backup, Reset, and Completions from --help

Co-Authored-By: Claude (us.anthropic.claude-opus-4-5-20251101-v1:0) <noreply@anthropic.com>
2026-01-30 16:54:18 -05:00
Taylor Eernisse
730ddef339 fix(error): Remap ConfigNotFound to exit 20 and add NotFound/Ambiguous codes
ConfigNotFound previously used exit code 2 which collides with clap's
usage error code. Remap it to exit 20 to avoid ambiguity. Also add
dedicated NotFound (exit 17) and Ambiguous (exit 18) error codes with
proper ErrorCode variants and Display implementations, replacing the
previous incorrect mapping of these errors to GitLabNotFound.

Co-Authored-By: Claude (us.anthropic.claude-opus-4-5-20251101-v1:0) <noreply@anthropic.com>
2026-01-30 16:54:02 -05:00
Taylor Eernisse
5508d8464a build: Add clap_complete, libc dependencies and git hash build script
Add clap_complete for shell completion generation and libc (unix-only)
for SIGPIPE handling. Create build.rs to embed the git commit hash at
compile time via cargo:rustc-env=GIT_HASH, enabling `lore version` to
display the short hash alongside the version number.

Co-Authored-By: Claude (us.anthropic.claude-opus-4-5-20251101-v1:0) <noreply@anthropic.com>
2026-01-30 16:53:51 -05:00
16 changed files with 599 additions and 234 deletions

View File

@@ -98,8 +98,8 @@ Errors return structured JSON to stderr:
| Code | Meaning |
|------|---------|
| 0 | Success |
| 1 | Internal error |
| 2 | Config not found |
| 1 | Internal error / health check failed / not implemented |
| 2 | Usage error (invalid flags or arguments) |
| 3 | Config invalid |
| 4 | Token not set |
| 5 | GitLab auth failed |
@@ -111,6 +111,17 @@ Errors return structured JSON to stderr:
| 11 | Migration failed |
| 12 | I/O error |
| 13 | Transform error |
| 14 | Ollama unavailable |
| 15 | Ollama model not found |
| 16 | Embedding failed |
| 20 | Config not found |
### Configuration Precedence
1. CLI flags (highest priority)
2. Environment variables (`LORE_ROBOT`, `GITLAB_TOKEN`, `LORE_CONFIG_PATH`)
3. Config file (`~/.config/lore/config.json`)
4. Built-in defaults (lowest priority)
### Best Practices

11
Cargo.lock generated
View File

@@ -211,6 +211,15 @@ dependencies = [
"strsim",
]
[[package]]
name = "clap_complete"
version = "4.5.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "430b4dc2b5e3861848de79627b2bedc9f3342c7da5173a14eaa5d0f8dc18ae5d"
dependencies = [
"clap",
]
[[package]]
name = "clap_derive"
version = "4.5.49"
@@ -1085,6 +1094,7 @@ dependencies = [
"async-stream",
"chrono",
"clap",
"clap_complete",
"comfy-table",
"console",
"dialoguer",
@@ -1092,6 +1102,7 @@ dependencies = [
"flate2",
"futures",
"indicatif",
"libc",
"open",
"rand",
"reqwest",

View File

@@ -21,6 +21,7 @@ serde_json = "1"
# CLI
clap = { version = "4", features = ["derive", "env"] }
clap_complete = "4"
dialoguer = "0.12"
console = "0.16"
indicatif = "0.18"
@@ -46,6 +47,9 @@ flate2 = "1"
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4"] }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

View File

@@ -406,8 +406,8 @@ Errors return structured JSON to stderr:
| Code | Meaning |
|------|---------|
| 0 | Success |
| 1 | Internal error |
| 2 | Config not found |
| 1 | Internal error / health check failed / not implemented |
| 2 | Usage error (invalid flags or arguments) |
| 3 | Config invalid |
| 4 | Token not set |
| 5 | GitLab auth failed |
@@ -419,6 +419,19 @@ Errors return structured JSON to stderr:
| 11 | Migration failed |
| 12 | I/O error |
| 13 | Transform error |
| 14 | Ollama unavailable |
| 15 | Ollama model not found |
| 16 | Embedding failed |
| 20 | Config not found |
## Configuration Precedence
Settings are resolved in this order (highest to lowest priority):
1. CLI flags (`--robot`, `--config`, `--color`)
2. Environment variables (`LORE_ROBOT`, `GITLAB_TOKEN`, `LORE_CONFIG_PATH`)
3. Config file (`~/.config/lore/config.json`)
4. Built-in defaults
## Global Options
@@ -428,6 +441,24 @@ lore --robot <command> # Machine-readable JSON
lore -J <command> # JSON shorthand
```
## Shell Completions
Generate shell completions for tab-completion support:
```bash
# Bash (add to ~/.bashrc)
lore completions bash > ~/.local/share/bash-completion/completions/lore
# Zsh (add to ~/.zshrc: fpath=(~/.zfunc $fpath))
lore completions zsh > ~/.zfunc/_lore
# Fish
lore completions fish > ~/.config/fish/completions/lore.fish
# PowerShell (add to $PROFILE)
lore completions powershell >> $PROFILE
```
## Database Schema
Data is stored in SQLite with WAL mode and foreign keys enabled. Main tables:

10
build.rs Normal file
View File

@@ -0,0 +1,10 @@
fn main() {
let hash = std::process::Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.unwrap_or_default();
println!("cargo:rustc-env=GIT_HASH={}", hash.trim());
println!("cargo:rerun-if-changed=.git/HEAD");
}

View File

@@ -8,7 +8,8 @@ use tracing::info;
use crate::core::db::create_connection;
use crate::core::error::Result;
use crate::core::paths::get_db_path;
use crate::documents::{regenerate_dirty_documents, SourceType};
use crate::core::project::resolve_project;
use crate::documents::{SourceType, regenerate_dirty_documents};
use crate::Config;
const FULL_MODE_CHUNK_SIZE: i64 = 2000;
@@ -81,18 +82,7 @@ fn seed_dirty(
loop {
let inserted = if let Some(project) = project_filter {
// Resolve project to ID for filtering
let project_id: Option<i64> = conn
.query_row(
"SELECT id FROM projects WHERE path_with_namespace = ?1 COLLATE NOCASE",
[project],
|row| row.get(0),
)
.ok();
let Some(pid) = project_id else {
break;
};
let project_id = resolve_project(conn, project)?;
conn.execute(
&format!(
@@ -101,7 +91,7 @@ fn seed_dirty(
FROM {table} WHERE id > ?3 AND project_id = ?4 ORDER BY id LIMIT ?5
ON CONFLICT(source_type, source_id) DO NOTHING"
),
rusqlite::params![type_str, now, last_id, pid, FULL_MODE_CHUNK_SIZE],
rusqlite::params![type_str, now, last_id, project_id, FULL_MODE_CHUNK_SIZE],
)?
} else {
conn.execute(

View File

@@ -10,6 +10,7 @@ use crate::core::db::create_connection;
use crate::core::error::{LoreError, Result};
use crate::core::lock::{AppLock, LockOptions};
use crate::core::paths::get_db_path;
use crate::core::project::resolve_project;
use crate::gitlab::GitLabClient;
use crate::ingestion::{
IngestMrProjectResult, IngestProjectResult, ProgressEvent, ingest_project_issues_with_progress,
@@ -40,6 +41,36 @@ pub struct IngestResult {
pub notes_upserted: usize,
}
/// Controls what interactive UI elements `run_ingest` displays.
///
/// Separates progress indicators (spinners, bars) from text output (headers,
/// per-project summaries) so callers like `sync` can show progress without
/// duplicating summary text.
#[derive(Debug, Clone, Copy)]
pub struct IngestDisplay {
/// Show animated spinners and progress bars.
pub show_progress: bool,
/// Show text headers ("Ingesting...") and per-project summary lines.
pub show_text: bool,
}
impl IngestDisplay {
/// Interactive mode: everything visible.
pub fn interactive() -> Self {
Self { show_progress: true, show_text: true }
}
/// Robot/JSON mode: everything hidden.
pub fn silent() -> Self {
Self { show_progress: false, show_text: false }
}
/// Progress only (used by sync in interactive mode).
pub fn progress_only() -> Self {
Self { show_progress: true, show_text: false }
}
}
/// Run the ingest command.
pub async fn run_ingest(
config: &Config,
@@ -47,7 +78,7 @@ pub async fn run_ingest(
project_filter: Option<&str>,
force: bool,
full: bool,
robot_mode: bool,
display: IngestDisplay,
) -> Result<IngestResult> {
// Validate resource type early
if resource_type != "issues" && resource_type != "mrs" {
@@ -86,7 +117,7 @@ pub async fn run_ingest(
// If --full flag is set, reset sync cursors and discussion watermarks for a complete re-fetch
if full {
if !robot_mode {
if display.show_text {
println!(
"{}",
style("Full sync: resetting cursors to fetch all data...").yellow()
@@ -139,7 +170,7 @@ pub async fn run_ingest(
} else {
"merge requests"
};
if !robot_mode {
if display.show_text {
println!("{}", style(format!("Ingesting {type_label}...")).blue());
println!();
}
@@ -147,7 +178,7 @@ pub async fn run_ingest(
// Sync each project
for (local_project_id, gitlab_project_id, path) in &projects {
// Show spinner while fetching (only in interactive mode)
let spinner = if robot_mode {
let spinner = if !display.show_progress {
ProgressBar::hidden()
} else {
let s = ProgressBar::new_spinner();
@@ -162,7 +193,7 @@ pub async fn run_ingest(
};
// Progress bar for discussion sync (hidden until needed, or always hidden in robot mode)
let disc_bar = if robot_mode {
let disc_bar = if !display.show_progress {
ProgressBar::hidden()
} else {
let b = ProgressBar::new(0);
@@ -178,7 +209,7 @@ pub async fn run_ingest(
// Create progress callback (no-op in robot mode)
let spinner_clone = spinner.clone();
let disc_bar_clone = disc_bar.clone();
let progress_callback: crate::ingestion::ProgressCallback = if robot_mode {
let progress_callback: crate::ingestion::ProgressCallback = if !display.show_progress {
Box::new(|_| {})
} else {
Box::new(move |event: ProgressEvent| match event {
@@ -225,7 +256,7 @@ pub async fn run_ingest(
disc_bar.finish_and_clear();
// Print per-project summary (only in interactive mode)
if !robot_mode {
if display.show_text {
print_issue_project_summary(path, &result);
}
@@ -254,7 +285,7 @@ pub async fn run_ingest(
disc_bar.finish_and_clear();
// Print per-project summary (only in interactive mode)
if !robot_mode {
if display.show_text {
print_mr_project_summary(path, &result);
}
@@ -283,16 +314,39 @@ fn get_projects_to_sync(
configured_projects: &[crate::core::config::ProjectConfig],
filter: Option<&str>,
) -> Result<Vec<(i64, i64, String)>> {
let mut projects = Vec::new();
// If a filter is provided, resolve it to a specific project
if let Some(filter_str) = filter {
let project_id = resolve_project(conn, filter_str)?;
for project_config in configured_projects {
if let Some(filter_path) = filter
&& !project_config.path.contains(filter_path)
{
continue;
// Verify the resolved project is in our config
let row: Option<(i64, String)> = conn
.query_row(
"SELECT gitlab_project_id, path_with_namespace FROM projects WHERE id = ?1",
[project_id],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.ok();
if let Some((gitlab_id, path)) = row {
// Confirm it's a configured project
if configured_projects.iter().any(|p| p.path == path) {
return Ok(vec![(project_id, gitlab_id, path)]);
}
return Err(LoreError::Other(format!(
"Project '{}' exists in database but is not in configuration",
path
)));
}
// Get project from database
return Err(LoreError::Other(format!(
"Project '{}' not found in database",
filter_str
)));
}
// No filter: return all configured projects
let mut projects = Vec::new();
for project_config in configured_projects {
let result: Option<(i64, i64)> = conn
.query_row(
"SELECT id, gitlab_project_id FROM projects WHERE path_with_namespace = ?",

View File

@@ -6,10 +6,21 @@ use serde::Serialize;
use crate::Config;
use crate::core::db::create_connection;
use crate::core::error::Result;
use crate::core::error::{LoreError, Result};
use crate::core::paths::get_db_path;
use crate::core::project::resolve_project;
use crate::core::time::{ms_to_iso, now_ms, parse_since};
/// Apply foreground color to a Cell only if colors are enabled.
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
}
}
/// Issue row for display.
#[derive(Debug, Serialize)]
pub struct IssueListRow {
@@ -232,11 +243,9 @@ fn query_issues(conn: &Connection, filters: &ListFilters) -> Result<ListResult>
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(project) = filters.project {
// Exact match or suffix match after '/' to avoid partial matches
// e.g. "foo" matches "group/foo" but NOT "group/foobar"
where_clauses.push("(p.path_with_namespace = ? OR p.path_with_namespace LIKE ?)");
params.push(Box::new(project.to_string()));
params.push(Box::new(format!("%/{project}")));
let project_id = resolve_project(conn, project)?;
where_clauses.push("i.project_id = ?");
params.push(Box::new(project_id));
}
if let Some(state) = filters.state
@@ -264,9 +273,13 @@ fn query_issues(conn: &Connection, filters: &ListFilters) -> Result<ListResult>
}
// Handle since filter
if let Some(since_str) = filters.since
&& let Some(cutoff_ms) = parse_since(since_str)
{
if let Some(since_str) = filters.since {
let cutoff_ms = parse_since(since_str).ok_or_else(|| {
LoreError::Other(format!(
"Invalid --since value '{}'. Use relative (7d, 2w, 1m) or absolute (YYYY-MM-DD) format.",
since_str
))
})?;
where_clauses.push("i.updated_at >= ?");
params.push(Box::new(cutoff_ms));
}
@@ -419,11 +432,9 @@ fn query_mrs(conn: &Connection, filters: &MrListFilters) -> Result<MrListResult>
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(project) = filters.project {
// Exact match or suffix match after '/' to avoid partial matches
// e.g. "foo" matches "group/foo" but NOT "group/foobar"
where_clauses.push("(p.path_with_namespace = ? OR p.path_with_namespace LIKE ?)");
params.push(Box::new(project.to_string()));
params.push(Box::new(format!("%/{project}")));
let project_id = resolve_project(conn, project)?;
where_clauses.push("m.project_id = ?");
params.push(Box::new(project_id));
}
if let Some(state) = filters.state
@@ -461,9 +472,13 @@ fn query_mrs(conn: &Connection, filters: &MrListFilters) -> Result<MrListResult>
}
// Handle since filter
if let Some(since_str) = filters.since
&& let Some(cutoff_ms) = parse_since(since_str)
{
if let Some(since_str) = filters.since {
let cutoff_ms = parse_since(since_str).ok_or_else(|| {
LoreError::Other(format!(
"Invalid --since value '{}'. Use relative (7d, 2w, 1m) or absolute (YYYY-MM-DD) format.",
since_str
))
})?;
where_clauses.push("m.updated_at >= ?");
params.push(Box::new(cutoff_ms));
}
@@ -628,10 +643,22 @@ fn format_relative_time(ms_epoch: i64) -> 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 => format!("{} hours ago", d / 3_600_000),
d if d < 604_800_000 => format!("{} days ago", d / 86_400_000),
d if d < 2_592_000_000 => format!("{} weeks ago", d / 604_800_000),
_ => format!("{} months ago", diff / 2_592_000_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" })
}
}
}
@@ -735,19 +762,19 @@ pub fn print_list_issues(result: &ListResult) {
let discussions = format_discussions(issue.discussion_count, issue.unresolved_count);
let state_cell = if issue.state == "opened" {
Cell::new(&issue.state).fg(Color::Green)
colored_cell(&issue.state, Color::Green)
} else {
Cell::new(&issue.state).fg(Color::DarkGrey)
colored_cell(&issue.state, Color::DarkGrey)
};
table.add_row(vec![
Cell::new(format!("#{}", issue.iid)).fg(Color::Cyan),
colored_cell(format!("#{}", issue.iid), Color::Cyan),
Cell::new(title),
state_cell,
Cell::new(assignee).fg(Color::Magenta),
Cell::new(labels).fg(Color::Yellow),
colored_cell(assignee, Color::Magenta),
colored_cell(labels, Color::Yellow),
Cell::new(discussions),
Cell::new(relative_time).fg(Color::DarkGrey),
colored_cell(relative_time, Color::DarkGrey),
]);
}
@@ -807,7 +834,6 @@ pub fn print_list_mrs(result: &MrListResult) {
]);
for mr in &result.mrs {
// Add [DRAFT] prefix for draft MRs
let title = if mr.draft {
format!("[DRAFT] {}", truncate_with_ellipsis(&mr.title, 38))
} else {
@@ -819,25 +845,24 @@ pub fn print_list_mrs(result: &MrListResult) {
let discussions = format_discussions(mr.discussion_count, mr.unresolved_count);
let state_cell = match mr.state.as_str() {
"opened" => Cell::new(&mr.state).fg(Color::Green),
"merged" => Cell::new(&mr.state).fg(Color::Magenta),
"closed" => Cell::new(&mr.state).fg(Color::Red),
"locked" => Cell::new(&mr.state).fg(Color::Yellow),
_ => Cell::new(&mr.state).fg(Color::DarkGrey),
"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),
};
table.add_row(vec![
Cell::new(format!("!{}", mr.iid)).fg(Color::Cyan),
colored_cell(format!("!{}", mr.iid), Color::Cyan),
Cell::new(title),
state_cell,
Cell::new(format!(
"@{}",
truncate_with_ellipsis(&mr.author_username, 12)
))
.fg(Color::Magenta),
Cell::new(branches).fg(Color::Blue),
colored_cell(
format!("@{}", truncate_with_ellipsis(&mr.author_username, 12)),
Color::Magenta,
),
colored_cell(branches, Color::Blue),
Cell::new(discussions),
Cell::new(relative_time).fg(Color::DarkGrey),
colored_cell(relative_time, Color::DarkGrey),
]);
}

View File

@@ -23,7 +23,7 @@ pub use stats::{print_stats, print_stats_json, run_stats};
pub use search::{
print_search_results, print_search_results_json, run_search, SearchCliFilters, SearchResponse,
};
pub use ingest::{print_ingest_summary, print_ingest_summary_json, run_ingest};
pub use ingest::{IngestDisplay, print_ingest_summary, print_ingest_summary_json, run_ingest};
pub use init::{InitInputs, InitOptions, InitResult, run_init};
pub use list::{
ListFilters, MrListFilters, open_issue_in_browser, open_mr_in_browser, print_list_issues,

View File

@@ -104,8 +104,30 @@ pub fn run_search(
.map(|p| resolve_project(&conn, p))
.transpose()?;
let after = cli_filters.after.as_deref().and_then(parse_since);
let updated_after = cli_filters.updated_after.as_deref().and_then(parse_since);
let after = cli_filters
.after
.as_deref()
.map(|s| {
parse_since(s).ok_or_else(|| {
LoreError::Other(format!(
"Invalid --after value '{}'. Use relative (7d, 2w, 1m) or absolute (YYYY-MM-DD) format.",
s
))
})
})
.transpose()?;
let updated_after = cli_filters
.updated_after
.as_deref()
.map(|s| {
parse_since(s).ok_or_else(|| {
LoreError::Other(format!(
"Invalid --updated-after value '{}'. Use relative (7d, 2w, 1m) or absolute (YYYY-MM-DD) format.",
s
))
})
})
.transpose()?;
let path = cli_filters.path.as_deref().map(|p| {
if p.ends_with('/') {
@@ -192,7 +214,7 @@ pub fn run_search(
results.push(SearchResultDisplay {
document_id: row.document_id,
source_type: row.source_type.clone(),
title: row.title.clone(),
title: row.title.clone().unwrap_or_default(),
url: row.url.clone(),
author: row.author.clone(),
created_at: row.created_at.map(ms_to_iso),
@@ -219,7 +241,7 @@ pub fn run_search(
struct HydratedRow {
document_id: i64,
source_type: String,
title: String,
title: Option<String>,
url: Option<String>,
author: Option<String>,
created_at: Option<i64>,

View File

@@ -8,6 +8,7 @@ use crate::Config;
use crate::core::db::create_connection;
use crate::core::error::{LoreError, Result};
use crate::core::paths::get_db_path;
use crate::core::project::resolve_project;
use crate::core::time::ms_to_iso;
/// Merge request metadata for display.
@@ -145,18 +146,20 @@ struct IssueRow {
/// Find issue by iid, optionally filtered by project.
fn find_issue(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Result<IssueRow> {
let (sql, params): (&str, Vec<Box<dyn rusqlite::ToSql>>) = match project_filter {
Some(project) => (
Some(project) => {
let project_id = resolve_project(conn, project)?;
(
"SELECT i.id, i.iid, i.title, i.description, i.state, i.author_username,
i.created_at, i.updated_at, i.web_url, p.path_with_namespace
FROM issues i
JOIN projects p ON i.project_id = p.id
WHERE i.iid = ? AND (p.path_with_namespace = ? OR p.path_with_namespace LIKE ?)",
WHERE i.iid = ? AND i.project_id = ?",
vec![
Box::new(iid),
Box::new(project.to_string()),
Box::new(format!("%/{}", project)),
Box::new(project_id),
],
),
)
}
None => (
"SELECT i.id, i.iid, i.title, i.description, i.state, i.author_username,
i.created_at, i.updated_at, i.web_url, p.path_with_namespace
@@ -333,20 +336,22 @@ struct MrRow {
/// Find MR by iid, optionally filtered by project.
fn find_mr(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Result<MrRow> {
let (sql, params): (&str, Vec<Box<dyn rusqlite::ToSql>>) = match project_filter {
Some(project) => (
Some(project) => {
let project_id = resolve_project(conn, project)?;
(
"SELECT m.id, m.iid, m.title, m.description, m.state, m.draft,
m.author_username, m.source_branch, m.target_branch,
m.created_at, m.updated_at, m.merged_at, m.closed_at,
m.web_url, p.path_with_namespace
FROM merge_requests m
JOIN projects p ON m.project_id = p.id
WHERE m.iid = ? AND (p.path_with_namespace = ? OR p.path_with_namespace LIKE ?)",
WHERE m.iid = ? AND m.project_id = ?",
vec![
Box::new(iid),
Box::new(project.to_string()),
Box::new(format!("%/{}", project)),
Box::new(project_id),
],
),
)
}
None => (
"SELECT m.id, m.iid, m.title, m.description, m.state, m.draft,
m.author_username, m.source_branch, m.target_branch,

View File

@@ -9,7 +9,7 @@ use crate::core::error::Result;
use super::embed::run_embed;
use super::generate_docs::run_generate_docs;
use super::ingest::run_ingest;
use super::ingest::{IngestDisplay, run_ingest};
/// Options for the sync command.
#[derive(Debug, Default)]
@@ -18,6 +18,7 @@ pub struct SyncOptions {
pub force: bool,
pub no_embed: bool,
pub no_docs: bool,
pub robot_mode: bool,
}
/// Result of the sync command.
@@ -34,15 +35,21 @@ pub struct SyncResult {
pub async fn run_sync(config: &Config, options: SyncOptions) -> Result<SyncResult> {
let mut result = SyncResult::default();
let ingest_display = if options.robot_mode {
IngestDisplay::silent()
} else {
IngestDisplay::progress_only()
};
// Stage 1: Ingest issues
info!("Sync stage 1/4: ingesting issues");
let issues_result = run_ingest(config, "issues", None, options.force, options.full, true).await?;
let issues_result = run_ingest(config, "issues", None, options.force, options.full, ingest_display).await?;
result.issues_updated = issues_result.issues_upserted;
result.discussions_fetched += issues_result.discussions_fetched;
// Stage 2: Ingest MRs
info!("Sync stage 2/4: ingesting merge requests");
let mrs_result = run_ingest(config, "mrs", None, options.force, options.full, true).await?;
let mrs_result = run_ingest(config, "mrs", None, options.force, options.full, ingest_display).await?;
result.mrs_updated = mrs_result.mrs_upserted;
result.discussions_fetched += mrs_result.discussions_fetched;

View File

@@ -22,6 +22,14 @@ pub struct Cli {
#[arg(short = 'J', long = "json", global = true)]
pub json: bool,
/// Color output: auto (default), always, or never
#[arg(long, global = true, value_parser = ["auto", "always", "never"], default_value = "auto")]
pub color: String,
/// Suppress non-essential output
#[arg(short = 'q', long, global = true)]
pub quiet: bool,
#[command(subcommand)]
pub command: Commands,
}
@@ -84,9 +92,11 @@ pub enum Commands {
},
/// Create timestamped database backup
#[command(hide = true)]
Backup,
/// Delete database and reset all state
#[command(hide = true)]
Reset {
/// Skip confirmation prompt
#[arg(short = 'y', long)]
@@ -119,6 +129,14 @@ pub enum Commands {
#[command(name = "robot-docs")]
RobotDocs,
/// Generate shell completions
#[command(hide = true)]
Completions {
/// Shell to generate completions for
#[arg(value_parser = ["bash", "zsh", "fish", "powershell"])]
shell: String,
},
// --- Hidden backward-compat aliases ---
/// List issues or MRs (deprecated: use 'lore issues' or 'lore mrs')
#[command(hide = true)]
@@ -195,56 +213,65 @@ pub struct IssuesArgs {
pub iid: Option<i64>,
/// Maximum results
#[arg(short = 'n', long = "limit", default_value = "50")]
#[arg(short = 'n', long = "limit", default_value = "50", help_heading = "Output")]
pub limit: usize,
/// Filter by state (opened, closed, all)
#[arg(short = 's', long)]
#[arg(short = 's', long, help_heading = "Filters")]
pub state: Option<String>,
/// Filter by project path
#[arg(short = 'p', long)]
#[arg(short = 'p', long, help_heading = "Filters")]
pub project: Option<String>,
/// Filter by author username
#[arg(short = 'a', long)]
#[arg(short = 'a', long, help_heading = "Filters")]
pub author: Option<String>,
/// Filter by assignee username
#[arg(short = 'A', long)]
#[arg(short = 'A', long, help_heading = "Filters")]
pub assignee: Option<String>,
/// Filter by label (repeatable, AND logic)
#[arg(short = 'l', long)]
#[arg(short = 'l', long, help_heading = "Filters")]
pub label: Option<Vec<String>>,
/// Filter by milestone title
#[arg(short = 'm', long)]
#[arg(short = 'm', long, help_heading = "Filters")]
pub milestone: Option<String>,
/// Filter by time (7d, 2w, 1m, or YYYY-MM-DD)
#[arg(long)]
#[arg(long, help_heading = "Filters")]
pub since: Option<String>,
/// Filter by due date (before this date, YYYY-MM-DD)
#[arg(long = "due-before")]
#[arg(long = "due-before", help_heading = "Filters")]
pub due_before: Option<String>,
/// Show only issues with a due date
#[arg(long = "has-due")]
#[arg(long = "has-due", help_heading = "Filters", overrides_with = "no_has_due")]
pub has_due: bool,
#[arg(long = "no-has-due", hide = true, overrides_with = "has_due")]
pub no_has_due: bool,
/// Sort field (updated, created, iid)
#[arg(long, value_parser = ["updated", "created", "iid"], default_value = "updated")]
#[arg(long, value_parser = ["updated", "created", "iid"], default_value = "updated", help_heading = "Sorting")]
pub sort: String,
/// Sort ascending (default: descending)
#[arg(long)]
#[arg(long, help_heading = "Sorting", overrides_with = "no_asc")]
pub asc: bool,
#[arg(long = "no-asc", hide = true, overrides_with = "asc")]
pub no_asc: bool,
/// Open first matching item in browser
#[arg(short = 'o', long)]
#[arg(short = 'o', long, help_heading = "Actions", overrides_with = "no_open")]
pub open: bool,
#[arg(long = "no-open", hide = true, overrides_with = "open")]
pub no_open: bool,
}
/// Arguments for `lore mrs [IID]`
@@ -254,64 +281,70 @@ pub struct MrsArgs {
pub iid: Option<i64>,
/// Maximum results
#[arg(short = 'n', long = "limit", default_value = "50")]
#[arg(short = 'n', long = "limit", default_value = "50", help_heading = "Output")]
pub limit: usize,
/// Filter by state (opened, merged, closed, locked, all)
#[arg(short = 's', long)]
#[arg(short = 's', long, help_heading = "Filters")]
pub state: Option<String>,
/// Filter by project path
#[arg(short = 'p', long)]
#[arg(short = 'p', long, help_heading = "Filters")]
pub project: Option<String>,
/// Filter by author username
#[arg(short = 'a', long)]
#[arg(short = 'a', long, help_heading = "Filters")]
pub author: Option<String>,
/// Filter by assignee username
#[arg(short = 'A', long)]
#[arg(short = 'A', long, help_heading = "Filters")]
pub assignee: Option<String>,
/// Filter by reviewer username
#[arg(short = 'r', long)]
#[arg(short = 'r', long, help_heading = "Filters")]
pub reviewer: Option<String>,
/// Filter by label (repeatable, AND logic)
#[arg(short = 'l', long)]
#[arg(short = 'l', long, help_heading = "Filters")]
pub label: Option<Vec<String>>,
/// Filter by time (7d, 2w, 1m, or YYYY-MM-DD)
#[arg(long)]
#[arg(long, help_heading = "Filters")]
pub since: Option<String>,
/// Show only draft MRs
#[arg(short = 'd', long, conflicts_with = "no_draft")]
#[arg(short = 'd', long, conflicts_with = "no_draft", help_heading = "Filters")]
pub draft: bool,
/// Exclude draft MRs
#[arg(short = 'D', long = "no-draft", conflicts_with = "draft")]
#[arg(short = 'D', long = "no-draft", conflicts_with = "draft", help_heading = "Filters")]
pub no_draft: bool,
/// Filter by target branch
#[arg(long)]
#[arg(long, help_heading = "Filters")]
pub target: Option<String>,
/// Filter by source branch
#[arg(long)]
#[arg(long, help_heading = "Filters")]
pub source: Option<String>,
/// Sort field (updated, created, iid)
#[arg(long, value_parser = ["updated", "created", "iid"], default_value = "updated")]
#[arg(long, value_parser = ["updated", "created", "iid"], default_value = "updated", help_heading = "Sorting")]
pub sort: String,
/// Sort ascending (default: descending)
#[arg(long)]
#[arg(long, help_heading = "Sorting", overrides_with = "no_asc")]
pub asc: bool,
#[arg(long = "no-asc", hide = true, overrides_with = "asc")]
pub no_asc: bool,
/// Open first matching item in browser
#[arg(short = 'o', long)]
#[arg(short = 'o', long, help_heading = "Actions", overrides_with = "no_open")]
pub open: bool,
#[arg(long = "no-open", hide = true, overrides_with = "open")]
pub no_open: bool,
}
/// Arguments for `lore ingest [ENTITY]`
@@ -326,23 +359,32 @@ pub struct IngestArgs {
pub project: Option<String>,
/// Override stale sync lock
#[arg(short = 'f', long)]
#[arg(short = 'f', long, overrides_with = "no_force")]
pub force: bool,
#[arg(long = "no-force", hide = true, overrides_with = "force")]
pub no_force: bool,
/// Full re-sync: reset cursors and fetch all data from scratch
#[arg(long)]
#[arg(long, overrides_with = "no_full")]
pub full: bool,
#[arg(long = "no-full", hide = true, overrides_with = "full")]
pub no_full: bool,
}
/// Arguments for `lore stats`
#[derive(Parser)]
pub struct StatsArgs {
/// Run integrity checks
#[arg(long)]
#[arg(long, overrides_with = "no_check")]
pub check: bool,
/// Repair integrity issues (requires --check)
#[arg(long, requires = "check")]
#[arg(long = "no-check", hide = true, overrides_with = "check")]
pub no_check: bool,
/// Repair integrity issues (auto-enables --check)
#[arg(long)]
pub repair: bool,
}
@@ -353,47 +395,50 @@ pub struct SearchArgs {
pub query: String,
/// Search mode (lexical, hybrid, semantic)
#[arg(long, default_value = "hybrid")]
#[arg(long, default_value = "hybrid", value_parser = ["lexical", "hybrid", "semantic"], help_heading = "Output")]
pub mode: String,
/// Filter by source type (issue, mr, discussion)
#[arg(long = "type", value_name = "TYPE")]
#[arg(long = "type", value_name = "TYPE", value_parser = ["issue", "mr", "discussion"], help_heading = "Filters")]
pub source_type: Option<String>,
/// Filter by author username
#[arg(long)]
#[arg(long, help_heading = "Filters")]
pub author: Option<String>,
/// Filter by project path
#[arg(short = 'p', long)]
#[arg(short = 'p', long, help_heading = "Filters")]
pub project: Option<String>,
/// Filter by label (repeatable, AND logic)
#[arg(long, action = clap::ArgAction::Append)]
#[arg(long, action = clap::ArgAction::Append, help_heading = "Filters")]
pub label: Vec<String>,
/// Filter by file path (trailing / for prefix match)
#[arg(long)]
#[arg(long, help_heading = "Filters")]
pub path: Option<String>,
/// Filter by created after (7d, 2w, or YYYY-MM-DD)
#[arg(long)]
#[arg(long, help_heading = "Filters")]
pub after: Option<String>,
/// Filter by updated after (7d, 2w, or YYYY-MM-DD)
#[arg(long = "updated-after")]
#[arg(long = "updated-after", help_heading = "Filters")]
pub updated_after: Option<String>,
/// Maximum results (default 20, max 100)
#[arg(short = 'n', long = "limit", default_value = "20")]
#[arg(short = 'n', long = "limit", default_value = "20", help_heading = "Output")]
pub limit: usize,
/// Show ranking explanation per result
#[arg(long)]
#[arg(long, help_heading = "Output", overrides_with = "no_explain")]
pub explain: bool,
#[arg(long = "no-explain", hide = true, overrides_with = "explain")]
pub no_explain: bool,
/// FTS query mode: safe (default) or raw
#[arg(long = "fts-mode", default_value = "safe")]
#[arg(long = "fts-mode", default_value = "safe", value_parser = ["safe", "raw"], help_heading = "Output")]
pub fts_mode: String,
}
@@ -413,13 +458,19 @@ pub struct GenerateDocsArgs {
#[derive(Parser)]
pub struct SyncArgs {
/// Reset cursors, fetch everything
#[arg(long)]
#[arg(long, overrides_with = "no_full")]
pub full: bool,
#[arg(long = "no-full", hide = true, overrides_with = "full")]
pub no_full: bool,
/// Override stale lock
#[arg(long)]
#[arg(long, overrides_with = "no_force")]
pub force: bool,
#[arg(long = "no-force", hide = true, overrides_with = "force")]
pub no_force: bool,
/// Skip embedding step
#[arg(long)]
pub no_embed: bool,
@@ -433,8 +484,11 @@ pub struct SyncArgs {
#[derive(Parser)]
pub struct EmbedArgs {
/// Retry previously failed embeddings
#[arg(long)]
#[arg(long, overrides_with = "no_retry_failed")]
pub retry_failed: bool,
#[arg(long = "no-retry-failed", hide = true, overrides_with = "retry_failed")]
pub no_retry_failed: bool,
}
/// Arguments for `lore count <ENTITY>`

View File

@@ -24,6 +24,8 @@ pub enum ErrorCode {
OllamaUnavailable,
OllamaModelNotFound,
EmbeddingFailed,
NotFound,
Ambiguous,
}
impl std::fmt::Display for ErrorCode {
@@ -45,6 +47,8 @@ impl std::fmt::Display for ErrorCode {
Self::OllamaUnavailable => "OLLAMA_UNAVAILABLE",
Self::OllamaModelNotFound => "OLLAMA_MODEL_NOT_FOUND",
Self::EmbeddingFailed => "EMBEDDING_FAILED",
Self::NotFound => "NOT_FOUND",
Self::Ambiguous => "AMBIGUOUS",
};
write!(f, "{code}")
}
@@ -55,7 +59,7 @@ impl ErrorCode {
pub fn exit_code(&self) -> i32 {
match self {
Self::InternalError => 1,
Self::ConfigNotFound => 2,
Self::ConfigNotFound => 20,
Self::ConfigInvalid => 3,
Self::TokenNotSet => 4,
Self::GitLabAuthFailed => 5,
@@ -70,6 +74,8 @@ impl ErrorCode {
Self::OllamaUnavailable => 14,
Self::OllamaModelNotFound => 15,
Self::EmbeddingFailed => 16,
Self::NotFound => 17,
Self::Ambiguous => 18,
}
}
}
@@ -174,8 +180,8 @@ impl LoreError {
Self::Json(_) => ErrorCode::InternalError,
Self::Io(_) => ErrorCode::IoError,
Self::Transform(_) => ErrorCode::TransformError,
Self::NotFound(_) => ErrorCode::GitLabNotFound,
Self::Ambiguous(_) => ErrorCode::GitLabNotFound,
Self::NotFound(_) => ErrorCode::NotFound,
Self::Ambiguous(_) => ErrorCode::Ambiguous,
Self::Other(_) => ErrorCode::InternalError,
Self::OllamaUnavailable { .. } => ErrorCode::OllamaUnavailable,
Self::OllamaModelNotFound { .. } => ErrorCode::OllamaModelNotFound,

View File

@@ -5,8 +5,9 @@ use super::error::{LoreError, Result};
/// Resolve a project string to a project_id using cascading match:
/// 1. Exact match on path_with_namespace
/// 2. Case-insensitive exact match
/// 3. Suffix match (only if unambiguous)
/// 4. Error with available projects list
/// 3. Suffix match (e.g., "auth-service" matches "group/auth-service") — only if unambiguous
/// 4. Substring match (e.g., "typescript" matches "vs/typescript-code") — only if unambiguous
/// 5. Error with available projects list
pub fn resolve_project(conn: &Connection, project_str: &str) -> Result<i64> {
// Step 1: Exact match
let exact = conn.query_row(
@@ -44,7 +45,7 @@ pub fn resolve_project(conn: &Connection, project_str: &str) -> Result<i64> {
1 => return Ok(suffix_matches[0].0),
n if n > 1 => {
let matching: Vec<String> = suffix_matches.iter().map(|(_, p)| p.clone()).collect();
return Err(LoreError::Other(format!(
return Err(LoreError::Ambiguous(format!(
"Project '{}' is ambiguous. Matching projects:\n{}\n\nHint: Use the full path, e.g., --project={}",
project_str,
matching.iter().map(|p| format!(" {}", p)).collect::<Vec<_>>().join("\n"),
@@ -54,7 +55,32 @@ pub fn resolve_project(conn: &Connection, project_str: &str) -> Result<i64> {
_ => {}
}
// Step 4: No match — list available projects
// Step 4: Case-insensitive substring match (unambiguous)
let mut substr_stmt = conn.prepare(
"SELECT id, path_with_namespace FROM projects
WHERE LOWER(path_with_namespace) LIKE '%' || LOWER(?1) || '%'"
)?;
let substr_matches: Vec<(i64, String)> = substr_stmt
.query_map(rusqlite::params![project_str], |row| {
Ok((row.get(0)?, row.get(1)?))
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
match substr_matches.len() {
1 => return Ok(substr_matches[0].0),
n if n > 1 => {
let matching: Vec<String> = substr_matches.iter().map(|(_, p)| p.clone()).collect();
return Err(LoreError::Ambiguous(format!(
"Project '{}' is ambiguous. Matching projects:\n{}\n\nHint: Use the full path, e.g., --project={}",
project_str,
matching.iter().map(|p| format!(" {}", p)).collect::<Vec<_>>().join("\n"),
matching[0]
)));
}
_ => {}
}
// Step 5: No match — list available projects
let mut all_stmt = conn.prepare(
"SELECT path_with_namespace FROM projects ORDER BY path_with_namespace"
)?;
@@ -143,6 +169,48 @@ mod tests {
assert!(msg.contains("frontend/auth-service"));
}
#[test]
fn test_substring_unambiguous() {
let conn = setup_db();
insert_project(&conn, 1, "vs/python-code");
insert_project(&conn, 2, "vs/typescript-code");
let id = resolve_project(&conn, "typescript").unwrap();
assert_eq!(id, 2);
}
#[test]
fn test_substring_case_insensitive() {
let conn = setup_db();
insert_project(&conn, 1, "vs/python-code");
insert_project(&conn, 2, "vs/typescript-code");
let id = resolve_project(&conn, "TypeScript").unwrap();
assert_eq!(id, 2);
}
#[test]
fn test_substring_ambiguous() {
let conn = setup_db();
insert_project(&conn, 1, "vs/python-code");
insert_project(&conn, 2, "vs/typescript-code");
// "code" matches both projects
let err = resolve_project(&conn, "code").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("ambiguous"), "Expected ambiguous error, got: {}", msg);
assert!(msg.contains("vs/python-code"));
assert!(msg.contains("vs/typescript-code"));
}
#[test]
fn test_suffix_preferred_over_substring() {
// Suffix match (step 3) should resolve before substring (step 4)
let conn = setup_db();
insert_project(&conn, 1, "backend/auth-service");
insert_project(&conn, 2, "backend/auth-service-v2");
// "auth-service" is an exact suffix of project 1
let id = resolve_project(&conn, "auth-service").unwrap();
assert_eq!(id, 1);
}
#[test]
fn test_no_match() {
let conn = setup_db();

View File

@@ -20,6 +20,7 @@ use lore::cli::commands::{
print_show_mr_json, print_sync_status, print_sync_status_json, run_auth_test, run_count,
run_doctor, run_embed, run_generate_docs, run_ingest, run_init, run_list_issues, run_list_mrs,
run_search, run_show_issue, run_show_mr, run_stats, run_sync, run_sync_status, SyncOptions,
IngestDisplay,
};
use lore::cli::{
Cli, Commands, CountArgs, EmbedArgs, GenerateDocsArgs, IngestArgs, IssuesArgs, MrsArgs,
@@ -32,6 +33,12 @@ use lore::core::paths::get_db_path;
#[tokio::main]
async fn main() {
// Reset SIGPIPE to default behavior so piping (e.g. `lore issues | head`) doesn't panic
#[cfg(unix)]
unsafe {
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
}
// Initialize logging with indicatif support for clean progress bar output
let indicatif_layer = tracing_indicatif::IndicatifLayer::new();
@@ -52,6 +59,16 @@ async fn main() {
let cli = Cli::parse();
let robot_mode = cli.is_robot_mode();
// Apply color settings (console crate handles NO_COLOR/CLICOLOR natively in "auto" mode)
match cli.color.as_str() {
"never" => console::set_colors_enabled(false),
"always" => console::set_colors_enabled(true),
"auto" => {} // console crate handles this natively
_ => unreachable!(),
}
let quiet = cli.quiet;
let result = match cli.command {
Commands::Issues(args) => handle_issues(cli.config.as_deref(), args, robot_mode).await,
Commands::Mrs(args) => handle_mrs(cli.config.as_deref(), args, robot_mode).await,
@@ -59,7 +76,7 @@ async fn main() {
Commands::Stats(args) => handle_stats(cli.config.as_deref(), args, robot_mode).await,
Commands::Embed(args) => handle_embed(cli.config.as_deref(), args, robot_mode).await,
Commands::Sync(args) => handle_sync_cmd(cli.config.as_deref(), args, robot_mode).await,
Commands::Ingest(args) => handle_ingest(cli.config.as_deref(), args, robot_mode).await,
Commands::Ingest(args) => handle_ingest(cli.config.as_deref(), args, robot_mode, quiet).await,
Commands::Count(args) => {
handle_count(cli.config.as_deref(), args, robot_mode).await
}
@@ -67,6 +84,7 @@ async fn main() {
Commands::Auth => handle_auth_test(cli.config.as_deref(), robot_mode).await,
Commands::Doctor => handle_doctor(cli.config.as_deref(), robot_mode).await,
Commands::Version => handle_version(robot_mode),
Commands::Completions { shell } => handle_completions(&shell),
Commands::Init {
force,
non_interactive,
@@ -116,10 +134,12 @@ async fn main() {
target_branch,
source_branch,
} => {
if !robot_mode {
eprintln!(
"{}",
style("warning: 'lore list' is deprecated, use 'lore issues' or 'lore mrs'").yellow()
);
}
handle_list_compat(
cli.config.as_deref(),
&entity,
@@ -150,6 +170,7 @@ async fn main() {
iid,
project,
} => {
if !robot_mode {
eprintln!(
"{}",
style(format!(
@@ -158,6 +179,7 @@ async fn main() {
))
.yellow()
);
}
handle_show_compat(
cli.config.as_deref(),
&entity,
@@ -168,17 +190,21 @@ async fn main() {
.await
}
Commands::AuthTest => {
if !robot_mode {
eprintln!(
"{}",
style("warning: 'lore auth-test' is deprecated, use 'lore auth'").yellow()
);
}
handle_auth_test(cli.config.as_deref(), robot_mode).await
}
Commands::SyncStatus => {
if !robot_mode {
eprintln!(
"{}",
style("warning: 'lore sync-status' is deprecated, use 'lore status'").yellow()
);
}
handle_sync_status_cmd(cli.config.as_deref(), robot_mode).await
}
};
@@ -259,7 +285,10 @@ async fn handle_issues(
robot_mode: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let config = Config::load(config_override)?;
let order = if args.asc { "asc" } else { "desc" };
let asc = args.asc && !args.no_asc;
let has_due = args.has_due && !args.no_has_due;
let open = args.open && !args.no_open;
let order = if asc { "asc" } else { "desc" };
if let Some(iid) = args.iid {
// Show mode
@@ -281,14 +310,14 @@ async fn handle_issues(
milestone: args.milestone.as_deref(),
since: args.since.as_deref(),
due_before: args.due_before.as_deref(),
has_due_date: args.has_due,
has_due_date: has_due,
sort: &args.sort,
order,
};
let result = run_list_issues(&config, filters)?;
if args.open {
if open {
open_issue_in_browser(&result);
} else if robot_mode {
print_list_issues_json(&result);
@@ -306,7 +335,9 @@ async fn handle_mrs(
robot_mode: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let config = Config::load(config_override)?;
let order = if args.asc { "asc" } else { "desc" };
let asc = args.asc && !args.no_asc;
let open = args.open && !args.no_open;
let order = if asc { "asc" } else { "desc" };
if let Some(iid) = args.iid {
// Show mode
@@ -337,7 +368,7 @@ async fn handle_mrs(
let result = run_list_mrs(&config, filters)?;
if args.open {
if open {
open_mr_in_browser(&result);
} else if robot_mode {
print_list_mrs_json(&result);
@@ -353,8 +384,17 @@ async fn handle_ingest(
config_override: Option<&str>,
args: IngestArgs,
robot_mode: bool,
quiet: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let config = Config::load(config_override)?;
let display = if robot_mode || quiet {
IngestDisplay::silent()
} else {
IngestDisplay::interactive()
};
let force = args.force && !args.no_force;
let full = args.full && !args.no_full;
match args.entity.as_deref() {
Some(resource_type) => {
@@ -363,9 +403,9 @@ async fn handle_ingest(
&config,
resource_type,
args.project.as_deref(),
args.force,
args.full,
robot_mode,
force,
full,
display,
)
.await?;
@@ -377,7 +417,7 @@ async fn handle_ingest(
}
None => {
// Ingest everything: issues then MRs
if !robot_mode {
if !robot_mode && !quiet {
println!(
"{}",
style("Ingesting all content (issues + merge requests)...").blue()
@@ -389,9 +429,9 @@ async fn handle_ingest(
&config,
"issues",
args.project.as_deref(),
args.force,
args.full,
robot_mode,
force,
full,
display,
)
.await?;
@@ -399,9 +439,9 @@ async fn handle_ingest(
&config,
"mrs",
args.project.as_deref(),
args.force,
args.full,
robot_mode,
force,
full,
display,
)
.await?;
@@ -823,65 +863,81 @@ struct VersionOutput {
#[derive(Serialize)]
struct VersionData {
version: String,
#[serde(skip_serializing_if = "Option::is_none")]
git_hash: Option<String>,
}
fn handle_version(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>> {
let version = env!("CARGO_PKG_VERSION").to_string();
let git_hash = env!("GIT_HASH").to_string();
if robot_mode {
let output = VersionOutput {
ok: true,
data: VersionData { version },
data: VersionData {
version,
git_hash: if git_hash.is_empty() {
None
} else {
Some(git_hash)
},
},
};
println!("{}", serde_json::to_string(&output)?);
} else {
} else if git_hash.is_empty() {
println!("lore version {}", version);
} else {
println!("lore version {} ({})", version, git_hash);
}
Ok(())
}
/// JSON output for not-implemented commands.
#[derive(Serialize)]
struct NotImplementedOutput {
ok: bool,
data: NotImplementedData,
}
fn handle_completions(shell: &str) -> Result<(), Box<dyn std::error::Error>> {
use clap::CommandFactory;
use clap_complete::{Shell, generate};
#[derive(Serialize)]
struct NotImplementedData {
status: String,
command: String,
let shell = match shell {
"bash" => Shell::Bash,
"zsh" => Shell::Zsh,
"fish" => Shell::Fish,
"powershell" => Shell::PowerShell,
_ => unreachable!(),
};
let mut cmd = Cli::command();
generate(shell, &mut cmd, "lore", &mut std::io::stdout());
Ok(())
}
fn handle_backup(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>> {
if robot_mode {
let output = NotImplementedOutput {
ok: true,
data: NotImplementedData {
status: "not_implemented".to_string(),
command: "backup".to_string(),
let output = RobotErrorWithSuggestion {
error: RobotErrorSuggestionData {
code: "NOT_IMPLEMENTED".to_string(),
message: "The 'backup' command is not yet implemented.".to_string(),
suggestion: "Use manual database backup: cp ~/.local/share/lore/lore.db ~/.local/share/lore/lore.db.bak".to_string(),
},
};
println!("{}", serde_json::to_string(&output)?);
eprintln!("{}", serde_json::to_string(&output)?);
} else {
println!("lore backup - not yet implemented");
eprintln!("{} The 'backup' command is not yet implemented.", style("Error:").red());
}
Ok(())
std::process::exit(1);
}
fn handle_reset(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>> {
if robot_mode {
let output = NotImplementedOutput {
ok: true,
data: NotImplementedData {
status: "not_implemented".to_string(),
command: "reset".to_string(),
let output = RobotErrorWithSuggestion {
error: RobotErrorSuggestionData {
code: "NOT_IMPLEMENTED".to_string(),
message: "The 'reset' command is not yet implemented.".to_string(),
suggestion: "Manually delete the database: rm ~/.local/share/lore/lore.db".to_string(),
},
};
println!("{}", serde_json::to_string(&output)?);
eprintln!("{}", serde_json::to_string(&output)?);
} else {
println!("lore reset - not yet implemented");
eprintln!("{} The 'reset' command is not yet implemented.", style("Error:").red());
}
Ok(())
std::process::exit(1);
}
/// JSON output for migrate command.
@@ -987,7 +1043,9 @@ async fn handle_stats(
robot_mode: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let config = Config::load(config_override)?;
let result = run_stats(&config, args.check, args.repair)?;
// Auto-enable --check when --repair is used
let check = (args.check && !args.no_check) || args.repair;
let result = run_stats(&config, check, args.repair)?;
if robot_mode {
print_stats_json(&result);
} else {
@@ -1002,6 +1060,7 @@ async fn handle_search(
robot_mode: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let config = Config::load(config_override)?;
let explain = args.explain && !args.no_explain;
let fts_mode = match args.fts_mode.as_str() {
"raw" => lore::search::FtsQueryMode::Raw,
@@ -1020,7 +1079,7 @@ async fn handle_search(
};
let start = std::time::Instant::now();
let response = run_search(&config, &args.query, cli_filters, fts_mode, args.explain)?;
let response = run_search(&config, &args.query, cli_filters, fts_mode, explain)?;
let elapsed_ms = start.elapsed().as_millis() as u64;
if robot_mode {
@@ -1053,7 +1112,8 @@ async fn handle_embed(
robot_mode: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let config = Config::load(config_override)?;
let result = run_embed(&config, args.retry_failed).await?;
let retry_failed = args.retry_failed && !args.no_retry_failed;
let result = run_embed(&config, retry_failed).await?;
if robot_mode {
print_embed_json(&result);
} else {
@@ -1069,10 +1129,11 @@ async fn handle_sync_cmd(
) -> Result<(), Box<dyn std::error::Error>> {
let config = Config::load(config_override)?;
let options = SyncOptions {
full: args.full,
force: args.force,
full: args.full && !args.no_full,
force: args.force && !args.no_force,
no_embed: args.no_embed,
no_docs: args.no_docs,
robot_mode,
};
let start = std::time::Instant::now();
@@ -1301,8 +1362,8 @@ fn handle_robot_docs(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>>
let exit_codes = serde_json::json!({
"0": "Success",
"1": "Internal error / health check failed",
"2": "Config not found / missing flags",
"1": "Internal error / health check failed / not implemented",
"2": "Usage error (invalid flags or arguments)",
"3": "Config invalid",
"4": "Token not set",
"5": "GitLab auth failed",
@@ -1313,7 +1374,13 @@ fn handle_robot_docs(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>>
"10": "Database error",
"11": "Migration failed",
"12": "I/O error",
"13": "Transform error"
"13": "Transform error",
"14": "Ollama unavailable",
"15": "Ollama model not found",
"16": "Embedding failed",
"17": "Not found",
"18": "Ambiguous match",
"20": "Config not found"
});
let workflows = serde_json::json!({