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 | | Code | Meaning |
|------|---------| |------|---------|
| 0 | Success | | 0 | Success |
| 1 | Internal error | | 1 | Internal error / health check failed / not implemented |
| 2 | Config not found | | 2 | Usage error (invalid flags or arguments) |
| 3 | Config invalid | | 3 | Config invalid |
| 4 | Token not set | | 4 | Token not set |
| 5 | GitLab auth failed | | 5 | GitLab auth failed |
@@ -111,6 +111,17 @@ Errors return structured JSON to stderr:
| 11 | Migration failed | | 11 | Migration failed |
| 12 | I/O error | | 12 | I/O error |
| 13 | Transform 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 ### Best Practices

11
Cargo.lock generated
View File

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

View File

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

View File

@@ -406,8 +406,8 @@ Errors return structured JSON to stderr:
| Code | Meaning | | Code | Meaning |
|------|---------| |------|---------|
| 0 | Success | | 0 | Success |
| 1 | Internal error | | 1 | Internal error / health check failed / not implemented |
| 2 | Config not found | | 2 | Usage error (invalid flags or arguments) |
| 3 | Config invalid | | 3 | Config invalid |
| 4 | Token not set | | 4 | Token not set |
| 5 | GitLab auth failed | | 5 | GitLab auth failed |
@@ -419,6 +419,19 @@ Errors return structured JSON to stderr:
| 11 | Migration failed | | 11 | Migration failed |
| 12 | I/O error | | 12 | I/O error |
| 13 | Transform 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 ## Global Options
@@ -428,6 +441,24 @@ lore --robot <command> # Machine-readable JSON
lore -J <command> # JSON shorthand 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 ## Database Schema
Data is stored in SQLite with WAL mode and foreign keys enabled. Main tables: 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::db::create_connection;
use crate::core::error::Result; use crate::core::error::Result;
use crate::core::paths::get_db_path; 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; use crate::Config;
const FULL_MODE_CHUNK_SIZE: i64 = 2000; const FULL_MODE_CHUNK_SIZE: i64 = 2000;
@@ -81,18 +82,7 @@ fn seed_dirty(
loop { loop {
let inserted = if let Some(project) = project_filter { let inserted = if let Some(project) = project_filter {
// Resolve project to ID for filtering let project_id = resolve_project(conn, project)?;
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;
};
conn.execute( conn.execute(
&format!( &format!(
@@ -101,7 +91,7 @@ fn seed_dirty(
FROM {table} WHERE id > ?3 AND project_id = ?4 ORDER BY id LIMIT ?5 FROM {table} WHERE id > ?3 AND project_id = ?4 ORDER BY id LIMIT ?5
ON CONFLICT(source_type, source_id) DO NOTHING" 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 { } else {
conn.execute( conn.execute(

View File

@@ -10,6 +10,7 @@ use crate::core::db::create_connection;
use crate::core::error::{LoreError, Result}; use crate::core::error::{LoreError, Result};
use crate::core::lock::{AppLock, LockOptions}; use crate::core::lock::{AppLock, LockOptions};
use crate::core::paths::get_db_path; use crate::core::paths::get_db_path;
use crate::core::project::resolve_project;
use crate::gitlab::GitLabClient; use crate::gitlab::GitLabClient;
use crate::ingestion::{ use crate::ingestion::{
IngestMrProjectResult, IngestProjectResult, ProgressEvent, ingest_project_issues_with_progress, IngestMrProjectResult, IngestProjectResult, ProgressEvent, ingest_project_issues_with_progress,
@@ -40,6 +41,36 @@ pub struct IngestResult {
pub notes_upserted: usize, 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. /// Run the ingest command.
pub async fn run_ingest( pub async fn run_ingest(
config: &Config, config: &Config,
@@ -47,7 +78,7 @@ pub async fn run_ingest(
project_filter: Option<&str>, project_filter: Option<&str>,
force: bool, force: bool,
full: bool, full: bool,
robot_mode: bool, display: IngestDisplay,
) -> Result<IngestResult> { ) -> Result<IngestResult> {
// Validate resource type early // Validate resource type early
if resource_type != "issues" && resource_type != "mrs" { 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 flag is set, reset sync cursors and discussion watermarks for a complete re-fetch
if full { if full {
if !robot_mode { if display.show_text {
println!( println!(
"{}", "{}",
style("Full sync: resetting cursors to fetch all data...").yellow() style("Full sync: resetting cursors to fetch all data...").yellow()
@@ -139,7 +170,7 @@ pub async fn run_ingest(
} else { } else {
"merge requests" "merge requests"
}; };
if !robot_mode { if display.show_text {
println!("{}", style(format!("Ingesting {type_label}...")).blue()); println!("{}", style(format!("Ingesting {type_label}...")).blue());
println!(); println!();
} }
@@ -147,7 +178,7 @@ pub async fn run_ingest(
// Sync each project // Sync each project
for (local_project_id, gitlab_project_id, path) in &projects { for (local_project_id, gitlab_project_id, path) in &projects {
// Show spinner while fetching (only in interactive mode) // Show spinner while fetching (only in interactive mode)
let spinner = if robot_mode { let spinner = if !display.show_progress {
ProgressBar::hidden() ProgressBar::hidden()
} else { } else {
let s = ProgressBar::new_spinner(); 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) // 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() ProgressBar::hidden()
} else { } else {
let b = ProgressBar::new(0); let b = ProgressBar::new(0);
@@ -178,7 +209,7 @@ pub async fn run_ingest(
// Create progress callback (no-op in robot mode) // Create progress callback (no-op in robot mode)
let spinner_clone = spinner.clone(); let spinner_clone = spinner.clone();
let disc_bar_clone = disc_bar.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(|_| {}) Box::new(|_| {})
} else { } else {
Box::new(move |event: ProgressEvent| match event { Box::new(move |event: ProgressEvent| match event {
@@ -225,7 +256,7 @@ pub async fn run_ingest(
disc_bar.finish_and_clear(); disc_bar.finish_and_clear();
// Print per-project summary (only in interactive mode) // Print per-project summary (only in interactive mode)
if !robot_mode { if display.show_text {
print_issue_project_summary(path, &result); print_issue_project_summary(path, &result);
} }
@@ -254,7 +285,7 @@ pub async fn run_ingest(
disc_bar.finish_and_clear(); disc_bar.finish_and_clear();
// Print per-project summary (only in interactive mode) // Print per-project summary (only in interactive mode)
if !robot_mode { if display.show_text {
print_mr_project_summary(path, &result); print_mr_project_summary(path, &result);
} }
@@ -283,16 +314,39 @@ fn get_projects_to_sync(
configured_projects: &[crate::core::config::ProjectConfig], configured_projects: &[crate::core::config::ProjectConfig],
filter: Option<&str>, filter: Option<&str>,
) -> Result<Vec<(i64, i64, String)>> { ) -> 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 { // Verify the resolved project is in our config
if let Some(filter_path) = filter let row: Option<(i64, String)> = conn
&& !project_config.path.contains(filter_path) .query_row(
{ "SELECT gitlab_project_id, path_with_namespace FROM projects WHERE id = ?1",
continue; [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 let result: Option<(i64, i64)> = conn
.query_row( .query_row(
"SELECT id, gitlab_project_id FROM projects WHERE path_with_namespace = ?", "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::Config;
use crate::core::db::create_connection; 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::paths::get_db_path;
use crate::core::project::resolve_project;
use crate::core::time::{ms_to_iso, now_ms, parse_since}; 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. /// Issue row for display.
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct IssueListRow { 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(); let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(project) = filters.project { if let Some(project) = filters.project {
// Exact match or suffix match after '/' to avoid partial matches let project_id = resolve_project(conn, project)?;
// e.g. "foo" matches "group/foo" but NOT "group/foobar" where_clauses.push("i.project_id = ?");
where_clauses.push("(p.path_with_namespace = ? OR p.path_with_namespace LIKE ?)"); params.push(Box::new(project_id));
params.push(Box::new(project.to_string()));
params.push(Box::new(format!("%/{project}")));
} }
if let Some(state) = filters.state if let Some(state) = filters.state
@@ -264,9 +273,13 @@ fn query_issues(conn: &Connection, filters: &ListFilters) -> Result<ListResult>
} }
// Handle since filter // Handle since filter
if let Some(since_str) = filters.since if let Some(since_str) = filters.since {
&& let Some(cutoff_ms) = parse_since(since_str) 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 >= ?"); where_clauses.push("i.updated_at >= ?");
params.push(Box::new(cutoff_ms)); 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(); let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(project) = filters.project { if let Some(project) = filters.project {
// Exact match or suffix match after '/' to avoid partial matches let project_id = resolve_project(conn, project)?;
// e.g. "foo" matches "group/foo" but NOT "group/foobar" where_clauses.push("m.project_id = ?");
where_clauses.push("(p.path_with_namespace = ? OR p.path_with_namespace LIKE ?)"); params.push(Box::new(project_id));
params.push(Box::new(project.to_string()));
params.push(Box::new(format!("%/{project}")));
} }
if let Some(state) = filters.state if let Some(state) = filters.state
@@ -461,9 +472,13 @@ fn query_mrs(conn: &Connection, filters: &MrListFilters) -> Result<MrListResult>
} }
// Handle since filter // Handle since filter
if let Some(since_str) = filters.since if let Some(since_str) = filters.since {
&& let Some(cutoff_ms) = parse_since(since_str) 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 >= ?"); where_clauses.push("m.updated_at >= ?");
params.push(Box::new(cutoff_ms)); params.push(Box::new(cutoff_ms));
} }
@@ -628,10 +643,22 @@ fn format_relative_time(ms_epoch: i64) -> String {
match diff { match diff {
d if d < 60_000 => "just now".to_string(), d if d < 60_000 => "just now".to_string(),
d if d < 3_600_000 => format!("{} min ago", d / 60_000), 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 < 86_400_000 => {
d if d < 604_800_000 => format!("{} days ago", d / 86_400_000), let n = d / 3_600_000;
d if d < 2_592_000_000 => format!("{} weeks ago", d / 604_800_000), format!("{n} {} ago", if n == 1 { "hour" } else { "hours" })
_ => format!("{} months ago", diff / 2_592_000_000), }
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 discussions = format_discussions(issue.discussion_count, issue.unresolved_count);
let state_cell = if issue.state == "opened" { let state_cell = if issue.state == "opened" {
Cell::new(&issue.state).fg(Color::Green) colored_cell(&issue.state, Color::Green)
} else { } else {
Cell::new(&issue.state).fg(Color::DarkGrey) colored_cell(&issue.state, Color::DarkGrey)
}; };
table.add_row(vec![ table.add_row(vec![
Cell::new(format!("#{}", issue.iid)).fg(Color::Cyan), colored_cell(format!("#{}", issue.iid), Color::Cyan),
Cell::new(title), Cell::new(title),
state_cell, state_cell,
Cell::new(assignee).fg(Color::Magenta), colored_cell(assignee, Color::Magenta),
Cell::new(labels).fg(Color::Yellow), colored_cell(labels, Color::Yellow),
Cell::new(discussions), 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 { for mr in &result.mrs {
// Add [DRAFT] prefix for draft MRs
let title = if mr.draft { let title = if mr.draft {
format!("[DRAFT] {}", truncate_with_ellipsis(&mr.title, 38)) format!("[DRAFT] {}", truncate_with_ellipsis(&mr.title, 38))
} else { } else {
@@ -819,25 +845,24 @@ pub fn print_list_mrs(result: &MrListResult) {
let discussions = format_discussions(mr.discussion_count, mr.unresolved_count); let discussions = format_discussions(mr.discussion_count, mr.unresolved_count);
let state_cell = match mr.state.as_str() { let state_cell = match mr.state.as_str() {
"opened" => Cell::new(&mr.state).fg(Color::Green), "opened" => colored_cell(&mr.state, Color::Green),
"merged" => Cell::new(&mr.state).fg(Color::Magenta), "merged" => colored_cell(&mr.state, Color::Magenta),
"closed" => Cell::new(&mr.state).fg(Color::Red), "closed" => colored_cell(&mr.state, Color::Red),
"locked" => Cell::new(&mr.state).fg(Color::Yellow), "locked" => colored_cell(&mr.state, Color::Yellow),
_ => Cell::new(&mr.state).fg(Color::DarkGrey), _ => colored_cell(&mr.state, Color::DarkGrey),
}; };
table.add_row(vec![ table.add_row(vec![
Cell::new(format!("!{}", mr.iid)).fg(Color::Cyan), colored_cell(format!("!{}", mr.iid), Color::Cyan),
Cell::new(title), Cell::new(title),
state_cell, state_cell,
Cell::new(format!( colored_cell(
"@{}", format!("@{}", truncate_with_ellipsis(&mr.author_username, 12)),
truncate_with_ellipsis(&mr.author_username, 12) Color::Magenta,
)) ),
.fg(Color::Magenta), colored_cell(branches, Color::Blue),
Cell::new(branches).fg(Color::Blue),
Cell::new(discussions), 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::{ pub use search::{
print_search_results, print_search_results_json, run_search, SearchCliFilters, SearchResponse, 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 init::{InitInputs, InitOptions, InitResult, run_init};
pub use list::{ pub use list::{
ListFilters, MrListFilters, open_issue_in_browser, open_mr_in_browser, print_list_issues, 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)) .map(|p| resolve_project(&conn, p))
.transpose()?; .transpose()?;
let after = cli_filters.after.as_deref().and_then(parse_since); let after = cli_filters
let updated_after = cli_filters.updated_after.as_deref().and_then(parse_since); .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| { let path = cli_filters.path.as_deref().map(|p| {
if p.ends_with('/') { if p.ends_with('/') {
@@ -192,7 +214,7 @@ pub fn run_search(
results.push(SearchResultDisplay { results.push(SearchResultDisplay {
document_id: row.document_id, document_id: row.document_id,
source_type: row.source_type.clone(), source_type: row.source_type.clone(),
title: row.title.clone(), title: row.title.clone().unwrap_or_default(),
url: row.url.clone(), url: row.url.clone(),
author: row.author.clone(), author: row.author.clone(),
created_at: row.created_at.map(ms_to_iso), created_at: row.created_at.map(ms_to_iso),
@@ -219,7 +241,7 @@ pub fn run_search(
struct HydratedRow { struct HydratedRow {
document_id: i64, document_id: i64,
source_type: String, source_type: String,
title: String, title: Option<String>,
url: Option<String>, url: Option<String>,
author: Option<String>, author: Option<String>,
created_at: Option<i64>, created_at: Option<i64>,

View File

@@ -8,6 +8,7 @@ use crate::Config;
use crate::core::db::create_connection; use crate::core::db::create_connection;
use crate::core::error::{LoreError, Result}; use crate::core::error::{LoreError, Result};
use crate::core::paths::get_db_path; use crate::core::paths::get_db_path;
use crate::core::project::resolve_project;
use crate::core::time::ms_to_iso; use crate::core::time::ms_to_iso;
/// Merge request metadata for display. /// Merge request metadata for display.
@@ -145,18 +146,20 @@ struct IssueRow {
/// Find issue by iid, optionally filtered by project. /// Find issue by iid, optionally filtered by project.
fn find_issue(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Result<IssueRow> { 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 { let (sql, params): (&str, Vec<Box<dyn rusqlite::ToSql>>) = match project_filter {
Some(project) => ( Some(project) => {
"SELECT i.id, i.iid, i.title, i.description, i.state, i.author_username, let project_id = resolve_project(conn, project)?;
i.created_at, i.updated_at, i.web_url, p.path_with_namespace (
FROM issues i "SELECT i.id, i.iid, i.title, i.description, i.state, i.author_username,
JOIN projects p ON i.project_id = p.id i.created_at, i.updated_at, i.web_url, p.path_with_namespace
WHERE i.iid = ? AND (p.path_with_namespace = ? OR p.path_with_namespace LIKE ?)", FROM issues i
vec![ JOIN projects p ON i.project_id = p.id
Box::new(iid), WHERE i.iid = ? AND i.project_id = ?",
Box::new(project.to_string()), vec![
Box::new(format!("%/{}", project)), Box::new(iid),
], Box::new(project_id),
), ],
)
}
None => ( None => (
"SELECT i.id, i.iid, i.title, i.description, i.state, i.author_username, "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 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. /// Find MR by iid, optionally filtered by project.
fn find_mr(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Result<MrRow> { 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 { let (sql, params): (&str, Vec<Box<dyn rusqlite::ToSql>>) = match project_filter {
Some(project) => ( Some(project) => {
"SELECT m.id, m.iid, m.title, m.description, m.state, m.draft, let project_id = resolve_project(conn, project)?;
m.author_username, m.source_branch, m.target_branch, (
m.created_at, m.updated_at, m.merged_at, m.closed_at, "SELECT m.id, m.iid, m.title, m.description, m.state, m.draft,
m.web_url, p.path_with_namespace m.author_username, m.source_branch, m.target_branch,
FROM merge_requests m m.created_at, m.updated_at, m.merged_at, m.closed_at,
JOIN projects p ON m.project_id = p.id m.web_url, p.path_with_namespace
WHERE m.iid = ? AND (p.path_with_namespace = ? OR p.path_with_namespace LIKE ?)", FROM merge_requests m
vec![ JOIN projects p ON m.project_id = p.id
Box::new(iid), WHERE m.iid = ? AND m.project_id = ?",
Box::new(project.to_string()), vec![
Box::new(format!("%/{}", project)), Box::new(iid),
], Box::new(project_id),
), ],
)
}
None => ( None => (
"SELECT m.id, m.iid, m.title, m.description, m.state, m.draft, "SELECT m.id, m.iid, m.title, m.description, m.state, m.draft,
m.author_username, m.source_branch, m.target_branch, 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::embed::run_embed;
use super::generate_docs::run_generate_docs; use super::generate_docs::run_generate_docs;
use super::ingest::run_ingest; use super::ingest::{IngestDisplay, run_ingest};
/// Options for the sync command. /// Options for the sync command.
#[derive(Debug, Default)] #[derive(Debug, Default)]
@@ -18,6 +18,7 @@ pub struct SyncOptions {
pub force: bool, pub force: bool,
pub no_embed: bool, pub no_embed: bool,
pub no_docs: bool, pub no_docs: bool,
pub robot_mode: bool,
} }
/// Result of the sync command. /// Result of the sync command.
@@ -34,15 +35,21 @@ pub struct SyncResult {
pub async fn run_sync(config: &Config, options: SyncOptions) -> Result<SyncResult> { pub async fn run_sync(config: &Config, options: SyncOptions) -> Result<SyncResult> {
let mut result = SyncResult::default(); let mut result = SyncResult::default();
let ingest_display = if options.robot_mode {
IngestDisplay::silent()
} else {
IngestDisplay::progress_only()
};
// Stage 1: Ingest issues // Stage 1: Ingest issues
info!("Sync stage 1/4: ingesting 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.issues_updated = issues_result.issues_upserted;
result.discussions_fetched += issues_result.discussions_fetched; result.discussions_fetched += issues_result.discussions_fetched;
// Stage 2: Ingest MRs // Stage 2: Ingest MRs
info!("Sync stage 2/4: ingesting merge requests"); 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.mrs_updated = mrs_result.mrs_upserted;
result.discussions_fetched += mrs_result.discussions_fetched; result.discussions_fetched += mrs_result.discussions_fetched;

View File

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

View File

@@ -24,6 +24,8 @@ pub enum ErrorCode {
OllamaUnavailable, OllamaUnavailable,
OllamaModelNotFound, OllamaModelNotFound,
EmbeddingFailed, EmbeddingFailed,
NotFound,
Ambiguous,
} }
impl std::fmt::Display for ErrorCode { impl std::fmt::Display for ErrorCode {
@@ -45,6 +47,8 @@ impl std::fmt::Display for ErrorCode {
Self::OllamaUnavailable => "OLLAMA_UNAVAILABLE", Self::OllamaUnavailable => "OLLAMA_UNAVAILABLE",
Self::OllamaModelNotFound => "OLLAMA_MODEL_NOT_FOUND", Self::OllamaModelNotFound => "OLLAMA_MODEL_NOT_FOUND",
Self::EmbeddingFailed => "EMBEDDING_FAILED", Self::EmbeddingFailed => "EMBEDDING_FAILED",
Self::NotFound => "NOT_FOUND",
Self::Ambiguous => "AMBIGUOUS",
}; };
write!(f, "{code}") write!(f, "{code}")
} }
@@ -55,7 +59,7 @@ impl ErrorCode {
pub fn exit_code(&self) -> i32 { pub fn exit_code(&self) -> i32 {
match self { match self {
Self::InternalError => 1, Self::InternalError => 1,
Self::ConfigNotFound => 2, Self::ConfigNotFound => 20,
Self::ConfigInvalid => 3, Self::ConfigInvalid => 3,
Self::TokenNotSet => 4, Self::TokenNotSet => 4,
Self::GitLabAuthFailed => 5, Self::GitLabAuthFailed => 5,
@@ -70,6 +74,8 @@ impl ErrorCode {
Self::OllamaUnavailable => 14, Self::OllamaUnavailable => 14,
Self::OllamaModelNotFound => 15, Self::OllamaModelNotFound => 15,
Self::EmbeddingFailed => 16, Self::EmbeddingFailed => 16,
Self::NotFound => 17,
Self::Ambiguous => 18,
} }
} }
} }
@@ -174,8 +180,8 @@ impl LoreError {
Self::Json(_) => ErrorCode::InternalError, Self::Json(_) => ErrorCode::InternalError,
Self::Io(_) => ErrorCode::IoError, Self::Io(_) => ErrorCode::IoError,
Self::Transform(_) => ErrorCode::TransformError, Self::Transform(_) => ErrorCode::TransformError,
Self::NotFound(_) => ErrorCode::GitLabNotFound, Self::NotFound(_) => ErrorCode::NotFound,
Self::Ambiguous(_) => ErrorCode::GitLabNotFound, Self::Ambiguous(_) => ErrorCode::Ambiguous,
Self::Other(_) => ErrorCode::InternalError, Self::Other(_) => ErrorCode::InternalError,
Self::OllamaUnavailable { .. } => ErrorCode::OllamaUnavailable, Self::OllamaUnavailable { .. } => ErrorCode::OllamaUnavailable,
Self::OllamaModelNotFound { .. } => ErrorCode::OllamaModelNotFound, 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: /// Resolve a project string to a project_id using cascading match:
/// 1. Exact match on path_with_namespace /// 1. Exact match on path_with_namespace
/// 2. Case-insensitive exact match /// 2. Case-insensitive exact match
/// 3. Suffix match (only if unambiguous) /// 3. Suffix match (e.g., "auth-service" matches "group/auth-service") — only if unambiguous
/// 4. Error with available projects list /// 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> { pub fn resolve_project(conn: &Connection, project_str: &str) -> Result<i64> {
// Step 1: Exact match // Step 1: Exact match
let exact = conn.query_row( 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), 1 => return Ok(suffix_matches[0].0),
n if n > 1 => { n if n > 1 => {
let matching: Vec<String> = suffix_matches.iter().map(|(_, p)| p.clone()).collect(); 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 '{}' is ambiguous. Matching projects:\n{}\n\nHint: Use the full path, e.g., --project={}",
project_str, project_str,
matching.iter().map(|p| format!(" {}", p)).collect::<Vec<_>>().join("\n"), 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( let mut all_stmt = conn.prepare(
"SELECT path_with_namespace FROM projects ORDER BY path_with_namespace" "SELECT path_with_namespace FROM projects ORDER BY path_with_namespace"
)?; )?;
@@ -143,6 +169,48 @@ mod tests {
assert!(msg.contains("frontend/auth-service")); 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] #[test]
fn test_no_match() { fn test_no_match() {
let conn = setup_db(); 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, 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_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, run_search, run_show_issue, run_show_mr, run_stats, run_sync, run_sync_status, SyncOptions,
IngestDisplay,
}; };
use lore::cli::{ use lore::cli::{
Cli, Commands, CountArgs, EmbedArgs, GenerateDocsArgs, IngestArgs, IssuesArgs, MrsArgs, Cli, Commands, CountArgs, EmbedArgs, GenerateDocsArgs, IngestArgs, IssuesArgs, MrsArgs,
@@ -32,6 +33,12 @@ use lore::core::paths::get_db_path;
#[tokio::main] #[tokio::main]
async fn 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 // Initialize logging with indicatif support for clean progress bar output
let indicatif_layer = tracing_indicatif::IndicatifLayer::new(); let indicatif_layer = tracing_indicatif::IndicatifLayer::new();
@@ -52,6 +59,16 @@ async fn main() {
let cli = Cli::parse(); let cli = Cli::parse();
let robot_mode = cli.is_robot_mode(); 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 { let result = match cli.command {
Commands::Issues(args) => handle_issues(cli.config.as_deref(), args, robot_mode).await, 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, 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::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::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::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) => { Commands::Count(args) => {
handle_count(cli.config.as_deref(), args, robot_mode).await 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::Auth => handle_auth_test(cli.config.as_deref(), robot_mode).await,
Commands::Doctor => handle_doctor(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::Version => handle_version(robot_mode),
Commands::Completions { shell } => handle_completions(&shell),
Commands::Init { Commands::Init {
force, force,
non_interactive, non_interactive,
@@ -116,10 +134,12 @@ async fn main() {
target_branch, target_branch,
source_branch, source_branch,
} => { } => {
eprintln!( if !robot_mode {
"{}", eprintln!(
style("warning: 'lore list' is deprecated, use 'lore issues' or 'lore mrs'").yellow() "{}",
); style("warning: 'lore list' is deprecated, use 'lore issues' or 'lore mrs'").yellow()
);
}
handle_list_compat( handle_list_compat(
cli.config.as_deref(), cli.config.as_deref(),
&entity, &entity,
@@ -150,14 +170,16 @@ async fn main() {
iid, iid,
project, project,
} => { } => {
eprintln!( if !robot_mode {
"{}", eprintln!(
style(format!( "{}",
"warning: 'lore show' is deprecated, use 'lore {}s {}'", style(format!(
entity, iid "warning: 'lore show' is deprecated, use 'lore {}s {}'",
)) entity, iid
.yellow() ))
); .yellow()
);
}
handle_show_compat( handle_show_compat(
cli.config.as_deref(), cli.config.as_deref(),
&entity, &entity,
@@ -168,17 +190,21 @@ async fn main() {
.await .await
} }
Commands::AuthTest => { Commands::AuthTest => {
eprintln!( if !robot_mode {
"{}", eprintln!(
style("warning: 'lore auth-test' is deprecated, use 'lore auth'").yellow() "{}",
); style("warning: 'lore auth-test' is deprecated, use 'lore auth'").yellow()
);
}
handle_auth_test(cli.config.as_deref(), robot_mode).await handle_auth_test(cli.config.as_deref(), robot_mode).await
} }
Commands::SyncStatus => { Commands::SyncStatus => {
eprintln!( if !robot_mode {
"{}", eprintln!(
style("warning: 'lore sync-status' is deprecated, use 'lore status'").yellow() "{}",
); style("warning: 'lore sync-status' is deprecated, use 'lore status'").yellow()
);
}
handle_sync_status_cmd(cli.config.as_deref(), robot_mode).await handle_sync_status_cmd(cli.config.as_deref(), robot_mode).await
} }
}; };
@@ -259,7 +285,10 @@ async fn handle_issues(
robot_mode: bool, robot_mode: bool,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let config = Config::load(config_override)?; 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 { if let Some(iid) = args.iid {
// Show mode // Show mode
@@ -281,14 +310,14 @@ async fn handle_issues(
milestone: args.milestone.as_deref(), milestone: args.milestone.as_deref(),
since: args.since.as_deref(), since: args.since.as_deref(),
due_before: args.due_before.as_deref(), due_before: args.due_before.as_deref(),
has_due_date: args.has_due, has_due_date: has_due,
sort: &args.sort, sort: &args.sort,
order, order,
}; };
let result = run_list_issues(&config, filters)?; let result = run_list_issues(&config, filters)?;
if args.open { if open {
open_issue_in_browser(&result); open_issue_in_browser(&result);
} else if robot_mode { } else if robot_mode {
print_list_issues_json(&result); print_list_issues_json(&result);
@@ -306,7 +335,9 @@ async fn handle_mrs(
robot_mode: bool, robot_mode: bool,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let config = Config::load(config_override)?; 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 { if let Some(iid) = args.iid {
// Show mode // Show mode
@@ -337,7 +368,7 @@ async fn handle_mrs(
let result = run_list_mrs(&config, filters)?; let result = run_list_mrs(&config, filters)?;
if args.open { if open {
open_mr_in_browser(&result); open_mr_in_browser(&result);
} else if robot_mode { } else if robot_mode {
print_list_mrs_json(&result); print_list_mrs_json(&result);
@@ -353,8 +384,17 @@ async fn handle_ingest(
config_override: Option<&str>, config_override: Option<&str>,
args: IngestArgs, args: IngestArgs,
robot_mode: bool, robot_mode: bool,
quiet: bool,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let config = Config::load(config_override)?; 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() { match args.entity.as_deref() {
Some(resource_type) => { Some(resource_type) => {
@@ -363,9 +403,9 @@ async fn handle_ingest(
&config, &config,
resource_type, resource_type,
args.project.as_deref(), args.project.as_deref(),
args.force, force,
args.full, full,
robot_mode, display,
) )
.await?; .await?;
@@ -377,7 +417,7 @@ async fn handle_ingest(
} }
None => { None => {
// Ingest everything: issues then MRs // Ingest everything: issues then MRs
if !robot_mode { if !robot_mode && !quiet {
println!( println!(
"{}", "{}",
style("Ingesting all content (issues + merge requests)...").blue() style("Ingesting all content (issues + merge requests)...").blue()
@@ -389,9 +429,9 @@ async fn handle_ingest(
&config, &config,
"issues", "issues",
args.project.as_deref(), args.project.as_deref(),
args.force, force,
args.full, full,
robot_mode, display,
) )
.await?; .await?;
@@ -399,9 +439,9 @@ async fn handle_ingest(
&config, &config,
"mrs", "mrs",
args.project.as_deref(), args.project.as_deref(),
args.force, force,
args.full, full,
robot_mode, display,
) )
.await?; .await?;
@@ -823,65 +863,81 @@ struct VersionOutput {
#[derive(Serialize)] #[derive(Serialize)]
struct VersionData { struct VersionData {
version: String, 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>> { fn handle_version(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>> {
let version = env!("CARGO_PKG_VERSION").to_string(); let version = env!("CARGO_PKG_VERSION").to_string();
let git_hash = env!("GIT_HASH").to_string();
if robot_mode { if robot_mode {
let output = VersionOutput { let output = VersionOutput {
ok: true, 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)?); println!("{}", serde_json::to_string(&output)?);
} else { } else if git_hash.is_empty() {
println!("lore version {}", version); println!("lore version {}", version);
} else {
println!("lore version {} ({})", version, git_hash);
} }
Ok(()) Ok(())
} }
/// JSON output for not-implemented commands. fn handle_completions(shell: &str) -> Result<(), Box<dyn std::error::Error>> {
#[derive(Serialize)] use clap::CommandFactory;
struct NotImplementedOutput { use clap_complete::{Shell, generate};
ok: bool,
data: NotImplementedData,
}
#[derive(Serialize)] let shell = match shell {
struct NotImplementedData { "bash" => Shell::Bash,
status: String, "zsh" => Shell::Zsh,
command: String, "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>> { fn handle_backup(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>> {
if robot_mode { if robot_mode {
let output = NotImplementedOutput { let output = RobotErrorWithSuggestion {
ok: true, error: RobotErrorSuggestionData {
data: NotImplementedData { code: "NOT_IMPLEMENTED".to_string(),
status: "not_implemented".to_string(), message: "The 'backup' command is not yet implemented.".to_string(),
command: "backup".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 { } 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>> { fn handle_reset(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>> {
if robot_mode { if robot_mode {
let output = NotImplementedOutput { let output = RobotErrorWithSuggestion {
ok: true, error: RobotErrorSuggestionData {
data: NotImplementedData { code: "NOT_IMPLEMENTED".to_string(),
status: "not_implemented".to_string(), message: "The 'reset' command is not yet implemented.".to_string(),
command: "reset".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 { } 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. /// JSON output for migrate command.
@@ -987,7 +1043,9 @@ async fn handle_stats(
robot_mode: bool, robot_mode: bool,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let config = Config::load(config_override)?; 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 { if robot_mode {
print_stats_json(&result); print_stats_json(&result);
} else { } else {
@@ -1002,6 +1060,7 @@ async fn handle_search(
robot_mode: bool, robot_mode: bool,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let config = Config::load(config_override)?; let config = Config::load(config_override)?;
let explain = args.explain && !args.no_explain;
let fts_mode = match args.fts_mode.as_str() { let fts_mode = match args.fts_mode.as_str() {
"raw" => lore::search::FtsQueryMode::Raw, "raw" => lore::search::FtsQueryMode::Raw,
@@ -1020,7 +1079,7 @@ async fn handle_search(
}; };
let start = std::time::Instant::now(); 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; let elapsed_ms = start.elapsed().as_millis() as u64;
if robot_mode { if robot_mode {
@@ -1053,7 +1112,8 @@ async fn handle_embed(
robot_mode: bool, robot_mode: bool,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let config = Config::load(config_override)?; 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 { if robot_mode {
print_embed_json(&result); print_embed_json(&result);
} else { } else {
@@ -1069,10 +1129,11 @@ async fn handle_sync_cmd(
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let config = Config::load(config_override)?; let config = Config::load(config_override)?;
let options = SyncOptions { let options = SyncOptions {
full: args.full, full: args.full && !args.no_full,
force: args.force, force: args.force && !args.no_force,
no_embed: args.no_embed, no_embed: args.no_embed,
no_docs: args.no_docs, no_docs: args.no_docs,
robot_mode,
}; };
let start = std::time::Instant::now(); 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!({ let exit_codes = serde_json::json!({
"0": "Success", "0": "Success",
"1": "Internal error / health check failed", "1": "Internal error / health check failed / not implemented",
"2": "Config not found / missing flags", "2": "Usage error (invalid flags or arguments)",
"3": "Config invalid", "3": "Config invalid",
"4": "Token not set", "4": "Token not set",
"5": "GitLab auth failed", "5": "GitLab auth failed",
@@ -1313,7 +1374,13 @@ fn handle_robot_docs(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>>
"10": "Database error", "10": "Database error",
"11": "Migration failed", "11": "Migration failed",
"12": "I/O error", "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!({ let workflows = serde_json::json!({