feat(cron): add lore cron command for automated sync scheduling
Add lore cron {install,uninstall,status} to manage a crontab entry that
runs lore sync on a configurable interval. Supports both human and robot
output modes.
Core implementation (src/core/cron.rs):
- install_cron: appends a tagged crontab entry, detects existing entries
- uninstall_cron: removes the tagged entry
- cron_status: reads crontab + checks last-sync time from the database
- Unix-only (#[cfg(unix)]) — compiles out on Windows
CLI wiring:
- CronAction enum and CronArgs in cli/mod.rs with after_help examples
- Robot JSON envelope with RobotMeta timing for all 3 sub-actions
- Dispatch in main.rs
Also in this commit:
- Add after_help example blocks to Status, Auth, Doctor, Init, Migrate,
Health commands for better discoverability
- Add LORE_ICONS env var documentation to CLI help text
- Simplify notes format dispatch in main.rs (removed csv/jsonl paths)
- Update commands/mod.rs re-exports for cron + notes cleanup
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
292
src/cli/commands/cron.rs
Normal file
292
src/cli/commands/cron.rs
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::Config;
|
||||||
|
use crate::cli::render::Theme;
|
||||||
|
use crate::cli::robot::RobotMeta;
|
||||||
|
use crate::core::cron::{
|
||||||
|
CronInstallResult, CronStatusResult, CronUninstallResult, cron_status, install_cron,
|
||||||
|
uninstall_cron,
|
||||||
|
};
|
||||||
|
use crate::core::db::create_connection;
|
||||||
|
use crate::core::error::Result;
|
||||||
|
use crate::core::paths::get_db_path;
|
||||||
|
use crate::core::time::ms_to_iso;
|
||||||
|
|
||||||
|
// ── install ──
|
||||||
|
|
||||||
|
pub fn run_cron_install(interval_minutes: u32) -> Result<CronInstallResult> {
|
||||||
|
install_cron(interval_minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn print_cron_install(result: &CronInstallResult) {
|
||||||
|
if result.replaced {
|
||||||
|
println!(
|
||||||
|
" {} cron entry updated (was already installed)",
|
||||||
|
Theme::success().render("Updated")
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
println!(
|
||||||
|
" {} cron entry installed",
|
||||||
|
Theme::success().render("Installed")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
println!(" {} {}", Theme::dim().render("entry:"), result.entry);
|
||||||
|
println!(
|
||||||
|
" {} every {} minutes",
|
||||||
|
Theme::dim().render("interval:"),
|
||||||
|
result.interval_minutes
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" {} {}",
|
||||||
|
Theme::dim().render("log:"),
|
||||||
|
result.log_path.display()
|
||||||
|
);
|
||||||
|
|
||||||
|
if cfg!(target_os = "macos") {
|
||||||
|
println!();
|
||||||
|
println!(
|
||||||
|
" {} On macOS, the terminal running cron may need",
|
||||||
|
Theme::warning().render("Note:")
|
||||||
|
);
|
||||||
|
println!(" Full Disk Access in System Settings > Privacy & Security.");
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct CronInstallJson {
|
||||||
|
ok: bool,
|
||||||
|
data: CronInstallData,
|
||||||
|
meta: RobotMeta,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct CronInstallData {
|
||||||
|
action: &'static str,
|
||||||
|
entry: String,
|
||||||
|
interval_minutes: u32,
|
||||||
|
log_path: String,
|
||||||
|
replaced: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn print_cron_install_json(result: &CronInstallResult, elapsed_ms: u64) {
|
||||||
|
let output = CronInstallJson {
|
||||||
|
ok: true,
|
||||||
|
data: CronInstallData {
|
||||||
|
action: "install",
|
||||||
|
entry: result.entry.clone(),
|
||||||
|
interval_minutes: result.interval_minutes,
|
||||||
|
log_path: result.log_path.display().to_string(),
|
||||||
|
replaced: result.replaced,
|
||||||
|
},
|
||||||
|
meta: RobotMeta { elapsed_ms },
|
||||||
|
};
|
||||||
|
if let Ok(json) = serde_json::to_string(&output) {
|
||||||
|
println!("{json}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── uninstall ──
|
||||||
|
|
||||||
|
pub fn run_cron_uninstall() -> Result<CronUninstallResult> {
|
||||||
|
uninstall_cron()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn print_cron_uninstall(result: &CronUninstallResult) {
|
||||||
|
if result.was_installed {
|
||||||
|
println!(
|
||||||
|
" {} cron entry removed",
|
||||||
|
Theme::success().render("Removed")
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
println!(
|
||||||
|
" {} no lore-sync cron entry found",
|
||||||
|
Theme::dim().render("Nothing to remove:")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct CronUninstallJson {
|
||||||
|
ok: bool,
|
||||||
|
data: CronUninstallData,
|
||||||
|
meta: RobotMeta,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct CronUninstallData {
|
||||||
|
action: &'static str,
|
||||||
|
was_installed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn print_cron_uninstall_json(result: &CronUninstallResult, elapsed_ms: u64) {
|
||||||
|
let output = CronUninstallJson {
|
||||||
|
ok: true,
|
||||||
|
data: CronUninstallData {
|
||||||
|
action: "uninstall",
|
||||||
|
was_installed: result.was_installed,
|
||||||
|
},
|
||||||
|
meta: RobotMeta { elapsed_ms },
|
||||||
|
};
|
||||||
|
if let Ok(json) = serde_json::to_string(&output) {
|
||||||
|
println!("{json}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── status ──
|
||||||
|
|
||||||
|
pub fn run_cron_status(config: &Config) -> Result<CronStatusInfo> {
|
||||||
|
let status = cron_status()?;
|
||||||
|
|
||||||
|
// Query last sync run from DB
|
||||||
|
let last_sync = get_last_sync_time(config).unwrap_or_default();
|
||||||
|
|
||||||
|
Ok(CronStatusInfo { status, last_sync })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CronStatusInfo {
|
||||||
|
pub status: CronStatusResult,
|
||||||
|
pub last_sync: Option<LastSyncInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LastSyncInfo {
|
||||||
|
pub started_at_iso: String,
|
||||||
|
pub status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_last_sync_time(config: &Config) -> Result<Option<LastSyncInfo>> {
|
||||||
|
let db_path = get_db_path(config.storage.db_path.as_deref());
|
||||||
|
if !db_path.exists() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let conn = create_connection(&db_path)?;
|
||||||
|
let result = conn.query_row(
|
||||||
|
"SELECT started_at, status FROM sync_runs ORDER BY started_at DESC LIMIT 1",
|
||||||
|
[],
|
||||||
|
|row| {
|
||||||
|
let started_at: i64 = row.get(0)?;
|
||||||
|
let status: String = row.get(1)?;
|
||||||
|
Ok(LastSyncInfo {
|
||||||
|
started_at_iso: ms_to_iso(started_at),
|
||||||
|
status,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
);
|
||||||
|
match result {
|
||||||
|
Ok(info) => Ok(Some(info)),
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||||
|
// Table may not exist if migrations haven't run yet
|
||||||
|
Err(rusqlite::Error::SqliteFailure(_, Some(ref msg))) if msg.contains("no such table") => {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn print_cron_status(info: &CronStatusInfo) {
|
||||||
|
if info.status.installed {
|
||||||
|
println!(
|
||||||
|
" {} lore-sync is installed in crontab",
|
||||||
|
Theme::success().render("Installed")
|
||||||
|
);
|
||||||
|
if let Some(interval) = info.status.interval_minutes {
|
||||||
|
println!(
|
||||||
|
" {} every {} minutes",
|
||||||
|
Theme::dim().render("interval:"),
|
||||||
|
interval
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(ref binary) = info.status.binary_path {
|
||||||
|
let label = if info.status.binary_mismatch {
|
||||||
|
Theme::warning().render("binary:")
|
||||||
|
} else {
|
||||||
|
Theme::dim().render("binary:")
|
||||||
|
};
|
||||||
|
println!(" {label} {binary}");
|
||||||
|
if info.status.binary_mismatch
|
||||||
|
&& let Some(ref current) = info.status.current_binary
|
||||||
|
{
|
||||||
|
println!(
|
||||||
|
" {}",
|
||||||
|
Theme::warning().render(&format!(" current binary is {current} (mismatch!)"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(ref log) = info.status.log_path {
|
||||||
|
println!(" {} {}", Theme::dim().render("log:"), log.display());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
println!(
|
||||||
|
" {} lore-sync is not installed in crontab",
|
||||||
|
Theme::dim().render("Not installed:")
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" {} lore cron install",
|
||||||
|
Theme::dim().render("install with:")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref last) = info.last_sync {
|
||||||
|
println!(
|
||||||
|
" {} {} ({})",
|
||||||
|
Theme::dim().render("last sync:"),
|
||||||
|
last.started_at_iso,
|
||||||
|
last.status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct CronStatusJson {
|
||||||
|
ok: bool,
|
||||||
|
data: CronStatusData,
|
||||||
|
meta: RobotMeta,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct CronStatusData {
|
||||||
|
installed: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
interval_minutes: Option<u32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
binary_path: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
current_binary: Option<String>,
|
||||||
|
binary_mismatch: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
log_path: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
cron_entry: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
last_sync_at: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
last_sync_status: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn print_cron_status_json(info: &CronStatusInfo, elapsed_ms: u64) {
|
||||||
|
let output = CronStatusJson {
|
||||||
|
ok: true,
|
||||||
|
data: CronStatusData {
|
||||||
|
installed: info.status.installed,
|
||||||
|
interval_minutes: info.status.interval_minutes,
|
||||||
|
binary_path: info.status.binary_path.clone(),
|
||||||
|
current_binary: info.status.current_binary.clone(),
|
||||||
|
binary_mismatch: info.status.binary_mismatch,
|
||||||
|
log_path: info
|
||||||
|
.status
|
||||||
|
.log_path
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| p.display().to_string()),
|
||||||
|
cron_entry: info.status.cron_entry.clone(),
|
||||||
|
last_sync_at: info.last_sync.as_ref().map(|s| s.started_at_iso.clone()),
|
||||||
|
last_sync_status: info.last_sync.as_ref().map(|s| s.status.clone()),
|
||||||
|
},
|
||||||
|
meta: RobotMeta { elapsed_ms },
|
||||||
|
};
|
||||||
|
if let Ok(json) = serde_json::to_string(&output) {
|
||||||
|
println!("{json}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
pub mod auth_test;
|
pub mod auth_test;
|
||||||
pub mod count;
|
pub mod count;
|
||||||
|
#[cfg(unix)]
|
||||||
|
pub mod cron;
|
||||||
pub mod doctor;
|
pub mod doctor;
|
||||||
pub mod drift;
|
pub mod drift;
|
||||||
pub mod embed;
|
pub mod embed;
|
||||||
@@ -22,6 +24,12 @@ pub use count::{
|
|||||||
print_count, print_count_json, print_event_count, print_event_count_json, run_count,
|
print_count, print_count_json, print_event_count, print_event_count_json, run_count,
|
||||||
run_count_events,
|
run_count_events,
|
||||||
};
|
};
|
||||||
|
#[cfg(unix)]
|
||||||
|
pub use cron::{
|
||||||
|
print_cron_install, print_cron_install_json, print_cron_status, print_cron_status_json,
|
||||||
|
print_cron_uninstall, print_cron_uninstall_json, run_cron_install, run_cron_status,
|
||||||
|
run_cron_uninstall,
|
||||||
|
};
|
||||||
pub use doctor::{DoctorChecks, print_doctor_results, run_doctor};
|
pub use doctor::{DoctorChecks, print_doctor_results, run_doctor};
|
||||||
pub use drift::{DriftResponse, print_drift_human, print_drift_json, run_drift};
|
pub use drift::{DriftResponse, print_drift_human, print_drift_json, run_drift};
|
||||||
pub use embed::{print_embed, print_embed_json, run_embed};
|
pub use embed::{print_embed, print_embed_json, run_embed};
|
||||||
@@ -35,8 +43,7 @@ pub use init::{InitInputs, InitOptions, InitResult, run_init};
|
|||||||
pub use list::{
|
pub use list::{
|
||||||
ListFilters, MrListFilters, NoteListFilters, open_issue_in_browser, open_mr_in_browser,
|
ListFilters, MrListFilters, NoteListFilters, open_issue_in_browser, open_mr_in_browser,
|
||||||
print_list_issues, print_list_issues_json, print_list_mrs, print_list_mrs_json,
|
print_list_issues, print_list_issues_json, print_list_mrs, print_list_mrs_json,
|
||||||
print_list_notes, print_list_notes_csv, print_list_notes_json, print_list_notes_jsonl,
|
print_list_notes, print_list_notes_json, query_notes, run_list_issues, run_list_mrs,
|
||||||
query_notes, run_list_issues, run_list_mrs,
|
|
||||||
};
|
};
|
||||||
pub use search::{
|
pub use search::{
|
||||||
SearchCliFilters, SearchResponse, print_search_results, print_search_results_json, run_search,
|
SearchCliFilters, SearchResponse, print_search_results, print_search_results_json, run_search,
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ use std::io::IsTerminal;
|
|||||||
GITLAB_TOKEN GitLab personal access token (or name set in config)
|
GITLAB_TOKEN GitLab personal access token (or name set in config)
|
||||||
LORE_ROBOT Enable robot/JSON mode (non-empty, non-zero value)
|
LORE_ROBOT Enable robot/JSON mode (non-empty, non-zero value)
|
||||||
LORE_CONFIG_PATH Override config file location
|
LORE_CONFIG_PATH Override config file location
|
||||||
NO_COLOR Disable color output (any non-empty value)")]
|
NO_COLOR Disable color output (any non-empty value)
|
||||||
|
LORE_ICONS Override icon set: nerd, unicode, or ascii
|
||||||
|
NERD_FONTS Enable Nerd Font icons when set to a non-empty value")]
|
||||||
pub struct Cli {
|
pub struct Cli {
|
||||||
/// Path to config file
|
/// Path to config file
|
||||||
#[arg(short = 'c', long, global = true, help = "Path to config file")]
|
#[arg(short = 'c', long, global = true, help = "Path to config file")]
|
||||||
@@ -135,19 +137,35 @@ pub enum Commands {
|
|||||||
Count(CountArgs),
|
Count(CountArgs),
|
||||||
|
|
||||||
/// Show sync state
|
/// Show sync state
|
||||||
#[command(visible_alias = "st")]
|
#[command(
|
||||||
|
visible_alias = "st",
|
||||||
|
after_help = "\x1b[1mExamples:\x1b[0m
|
||||||
|
lore status # Show last sync times per project
|
||||||
|
lore --robot status # JSON output for automation"
|
||||||
|
)]
|
||||||
Status,
|
Status,
|
||||||
|
|
||||||
/// Verify GitLab authentication
|
/// Verify GitLab authentication
|
||||||
|
#[command(after_help = "\x1b[1mExamples:\x1b[0m
|
||||||
|
lore auth # Verify token and show user info
|
||||||
|
lore --robot auth # JSON output for automation")]
|
||||||
Auth,
|
Auth,
|
||||||
|
|
||||||
/// Check environment health
|
/// Check environment health
|
||||||
|
#[command(after_help = "\x1b[1mExamples:\x1b[0m
|
||||||
|
lore doctor # Check config, token, database, Ollama
|
||||||
|
lore --robot doctor # JSON output for automation")]
|
||||||
Doctor,
|
Doctor,
|
||||||
|
|
||||||
/// Show version information
|
/// Show version information
|
||||||
Version,
|
Version,
|
||||||
|
|
||||||
/// Initialize configuration and database
|
/// Initialize configuration and database
|
||||||
|
#[command(after_help = "\x1b[1mExamples:\x1b[0m
|
||||||
|
lore init # Interactive setup
|
||||||
|
lore init --force # Overwrite existing config
|
||||||
|
lore --robot init --gitlab-url https://gitlab.com \\
|
||||||
|
--token-env-var GITLAB_TOKEN --projects group/repo # Non-interactive setup")]
|
||||||
Init {
|
Init {
|
||||||
/// Skip overwrite confirmation
|
/// Skip overwrite confirmation
|
||||||
#[arg(short = 'f', long)]
|
#[arg(short = 'f', long)]
|
||||||
@@ -174,11 +192,14 @@ pub enum Commands {
|
|||||||
default_project: Option<String>,
|
default_project: Option<String>,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Back up local database (not yet implemented)
|
||||||
#[command(hide = true)]
|
#[command(hide = true)]
|
||||||
Backup,
|
Backup,
|
||||||
|
|
||||||
|
/// Reset local database (not yet implemented)
|
||||||
#[command(hide = true)]
|
#[command(hide = true)]
|
||||||
Reset {
|
Reset {
|
||||||
|
/// Skip confirmation prompt
|
||||||
#[arg(short = 'y', long)]
|
#[arg(short = 'y', long)]
|
||||||
yes: bool,
|
yes: bool,
|
||||||
},
|
},
|
||||||
@@ -202,9 +223,15 @@ pub enum Commands {
|
|||||||
Sync(SyncArgs),
|
Sync(SyncArgs),
|
||||||
|
|
||||||
/// Run pending database migrations
|
/// Run pending database migrations
|
||||||
|
#[command(after_help = "\x1b[1mExamples:\x1b[0m
|
||||||
|
lore migrate # Apply pending migrations
|
||||||
|
lore --robot migrate # JSON output for automation")]
|
||||||
Migrate,
|
Migrate,
|
||||||
|
|
||||||
/// Quick health check: config, database, schema version
|
/// Quick health check: config, database, schema version
|
||||||
|
#[command(after_help = "\x1b[1mExamples:\x1b[0m
|
||||||
|
lore health # Quick pre-flight check (exit 0 = healthy)
|
||||||
|
lore --robot health # JSON output for automation")]
|
||||||
Health,
|
Health,
|
||||||
|
|
||||||
/// Machine-readable command manifest for agent self-discovery
|
/// Machine-readable command manifest for agent self-discovery
|
||||||
@@ -242,6 +269,10 @@ pub enum Commands {
|
|||||||
Trace(TraceArgs),
|
Trace(TraceArgs),
|
||||||
|
|
||||||
/// Detect discussion divergence from original intent
|
/// Detect discussion divergence from original intent
|
||||||
|
#[command(after_help = "\x1b[1mExamples:\x1b[0m
|
||||||
|
lore drift issues 42 # Check drift on issue #42
|
||||||
|
lore drift issues 42 --threshold 0.3 # Custom similarity threshold
|
||||||
|
lore --robot drift issues 42 -p group/repo # JSON output, scoped to project")]
|
||||||
Drift {
|
Drift {
|
||||||
/// Entity type (currently only "issues" supported)
|
/// Entity type (currently only "issues" supported)
|
||||||
#[arg(value_parser = ["issues"])]
|
#[arg(value_parser = ["issues"])]
|
||||||
@@ -259,6 +290,14 @@ pub enum Commands {
|
|||||||
project: Option<String>,
|
project: Option<String>,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Manage cron-based automatic syncing
|
||||||
|
#[command(after_help = "\x1b[1mExamples:\x1b[0m
|
||||||
|
lore cron install # Install cron job (every 8 minutes)
|
||||||
|
lore cron install --interval 15 # Custom interval
|
||||||
|
lore cron status # Check if cron is installed
|
||||||
|
lore cron uninstall # Remove cron job")]
|
||||||
|
Cron(CronArgs),
|
||||||
|
|
||||||
#[command(hide = true)]
|
#[command(hide = true)]
|
||||||
List {
|
List {
|
||||||
#[arg(value_parser = ["issues", "mrs"])]
|
#[arg(value_parser = ["issues", "mrs"])]
|
||||||
@@ -344,7 +383,7 @@ pub struct IssuesArgs {
|
|||||||
pub fields: Option<Vec<String>>,
|
pub fields: Option<Vec<String>>,
|
||||||
|
|
||||||
/// Filter by state (opened, closed, all)
|
/// Filter by state (opened, closed, all)
|
||||||
#[arg(short = 's', long, help_heading = "Filters")]
|
#[arg(short = 's', long, help_heading = "Filters", value_parser = ["opened", "closed", "all"])]
|
||||||
pub state: Option<String>,
|
pub state: Option<String>,
|
||||||
|
|
||||||
/// Filter by project path
|
/// Filter by project path
|
||||||
@@ -438,7 +477,7 @@ pub struct MrsArgs {
|
|||||||
pub fields: Option<Vec<String>>,
|
pub fields: Option<Vec<String>>,
|
||||||
|
|
||||||
/// Filter by state (opened, merged, closed, locked, all)
|
/// Filter by state (opened, merged, closed, locked, all)
|
||||||
#[arg(short = 's', long, help_heading = "Filters")]
|
#[arg(short = 's', long, help_heading = "Filters", value_parser = ["opened", "merged", "closed", "locked", "all"])]
|
||||||
pub state: Option<String>,
|
pub state: Option<String>,
|
||||||
|
|
||||||
/// Filter by project path
|
/// Filter by project path
|
||||||
@@ -535,15 +574,6 @@ pub struct NotesArgs {
|
|||||||
#[arg(long, help_heading = "Output", value_delimiter = ',')]
|
#[arg(long, help_heading = "Output", value_delimiter = ',')]
|
||||||
pub fields: Option<Vec<String>>,
|
pub fields: Option<Vec<String>>,
|
||||||
|
|
||||||
/// Output format (table, json, jsonl, csv)
|
|
||||||
#[arg(
|
|
||||||
long,
|
|
||||||
default_value = "table",
|
|
||||||
value_parser = ["table", "json", "jsonl", "csv"],
|
|
||||||
help_heading = "Output"
|
|
||||||
)]
|
|
||||||
pub format: String,
|
|
||||||
|
|
||||||
/// Filter by author username
|
/// Filter by author username
|
||||||
#[arg(short = 'a', long, help_heading = "Filters")]
|
#[arg(short = 'a', long, help_heading = "Filters")]
|
||||||
pub author: Option<String>,
|
pub author: Option<String>,
|
||||||
@@ -655,6 +685,11 @@ pub struct IngestArgs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
|
#[command(after_help = "\x1b[1mExamples:\x1b[0m
|
||||||
|
lore stats # Show document and index statistics
|
||||||
|
lore stats --check # Run integrity checks
|
||||||
|
lore stats --repair --dry-run # Preview what repair would fix
|
||||||
|
lore --robot stats # JSON output for automation")]
|
||||||
pub struct StatsArgs {
|
pub struct StatsArgs {
|
||||||
/// Run integrity checks
|
/// Run integrity checks
|
||||||
#[arg(long, overrides_with = "no_check")]
|
#[arg(long, overrides_with = "no_check")]
|
||||||
@@ -743,6 +778,10 @@ pub struct SearchArgs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
|
#[command(after_help = "\x1b[1mExamples:\x1b[0m
|
||||||
|
lore generate-docs # Generate docs for dirty entities
|
||||||
|
lore generate-docs --full # Full rebuild of all documents
|
||||||
|
lore generate-docs --full -p group/repo # Full rebuild for one project")]
|
||||||
pub struct GenerateDocsArgs {
|
pub struct GenerateDocsArgs {
|
||||||
/// Full rebuild: seed all entities into dirty queue, then drain
|
/// Full rebuild: seed all entities into dirty queue, then drain
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@@ -805,9 +844,17 @@ pub struct SyncArgs {
|
|||||||
/// Show detailed timing breakdown for sync stages
|
/// Show detailed timing breakdown for sync stages
|
||||||
#[arg(short = 't', long = "timings")]
|
#[arg(short = 't', long = "timings")]
|
||||||
pub timings: bool,
|
pub timings: bool,
|
||||||
|
|
||||||
|
/// Acquire file lock before syncing (skip if another sync is running)
|
||||||
|
#[arg(long)]
|
||||||
|
pub lock: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
|
#[command(after_help = "\x1b[1mExamples:\x1b[0m
|
||||||
|
lore embed # Embed new/changed documents
|
||||||
|
lore embed --full # Re-embed all documents from scratch
|
||||||
|
lore embed --retry-failed # Retry previously failed embeddings")]
|
||||||
pub struct EmbedArgs {
|
pub struct EmbedArgs {
|
||||||
/// Re-embed all documents (clears existing embeddings first)
|
/// Re-embed all documents (clears existing embeddings first)
|
||||||
#[arg(long, overrides_with = "no_full")]
|
#[arg(long, overrides_with = "no_full")]
|
||||||
@@ -1046,6 +1093,10 @@ pub struct TraceArgs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
|
#[command(after_help = "\x1b[1mExamples:\x1b[0m
|
||||||
|
lore count issues # Total issues in local database
|
||||||
|
lore count notes --for mr # Notes on merge requests only
|
||||||
|
lore count discussions --for issue # Discussions on issues only")]
|
||||||
pub struct CountArgs {
|
pub struct CountArgs {
|
||||||
/// Entity type to count (issues, mrs, discussions, notes, events)
|
/// Entity type to count (issues, mrs, discussions, notes, events)
|
||||||
#[arg(value_parser = ["issues", "mrs", "discussions", "notes", "events"])]
|
#[arg(value_parser = ["issues", "mrs", "discussions", "notes", "events"])]
|
||||||
@@ -1055,3 +1106,25 @@ pub struct CountArgs {
|
|||||||
#[arg(short = 'f', long = "for", value_parser = ["issue", "mr"])]
|
#[arg(short = 'f', long = "for", value_parser = ["issue", "mr"])]
|
||||||
pub for_entity: Option<String>,
|
pub for_entity: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
pub struct CronArgs {
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub action: CronAction,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
pub enum CronAction {
|
||||||
|
/// Install cron job for automatic syncing
|
||||||
|
Install {
|
||||||
|
/// Sync interval in minutes (default: 8)
|
||||||
|
#[arg(long, default_value = "8")]
|
||||||
|
interval: u32,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Remove cron job
|
||||||
|
Uninstall,
|
||||||
|
|
||||||
|
/// Show current cron configuration
|
||||||
|
Status,
|
||||||
|
}
|
||||||
|
|||||||
369
src/core/cron.rs
Normal file
369
src/core/cron.rs
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
use std::fs::{self, File};
|
||||||
|
use std::io::{self, Write};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use super::error::{LoreError, Result};
|
||||||
|
use super::paths::get_data_dir;
|
||||||
|
|
||||||
|
const CRON_TAG: &str = "# lore-sync";
|
||||||
|
|
||||||
|
// ── File-based sync lock (fcntl F_SETLK) ──
|
||||||
|
|
||||||
|
/// RAII guard that holds an `fcntl` write lock on a file.
|
||||||
|
/// The lock is released when the guard is dropped.
|
||||||
|
pub struct SyncLockGuard {
|
||||||
|
_file: File,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to acquire an exclusive file lock (non-blocking).
|
||||||
|
///
|
||||||
|
/// Returns `Ok(Some(guard))` if the lock was acquired, `Ok(None)` if another
|
||||||
|
/// process holds it, or `Err` on I/O failure.
|
||||||
|
#[cfg(unix)]
|
||||||
|
pub fn acquire_sync_lock() -> Result<Option<SyncLockGuard>> {
|
||||||
|
acquire_sync_lock_at(&lock_path())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lock_path() -> PathBuf {
|
||||||
|
get_data_dir().join("sync.lock")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn acquire_sync_lock_at(path: &Path) -> Result<Option<SyncLockGuard>> {
|
||||||
|
use std::os::unix::io::AsRawFd;
|
||||||
|
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let file = File::options()
|
||||||
|
.create(true)
|
||||||
|
.truncate(false)
|
||||||
|
.write(true)
|
||||||
|
.open(path)?;
|
||||||
|
|
||||||
|
let fd = file.as_raw_fd();
|
||||||
|
|
||||||
|
// SAFETY: zeroed memory is valid for libc::flock (all-zero is a valid
|
||||||
|
// representation on every Unix platform). We then set only the fields we need.
|
||||||
|
let mut flock = unsafe { std::mem::zeroed::<libc::flock>() };
|
||||||
|
flock.l_type = libc::F_WRLCK as libc::c_short;
|
||||||
|
flock.l_whence = libc::SEEK_SET as libc::c_short;
|
||||||
|
|
||||||
|
// SAFETY: fd is a valid open file descriptor; flock is stack-allocated.
|
||||||
|
let rc = unsafe { libc::fcntl(fd, libc::F_SETLK, &mut flock) };
|
||||||
|
if rc == -1 {
|
||||||
|
let err = io::Error::last_os_error();
|
||||||
|
if err.kind() == io::ErrorKind::WouldBlock
|
||||||
|
|| err.raw_os_error() == Some(libc::EAGAIN)
|
||||||
|
|| err.raw_os_error() == Some(libc::EACCES)
|
||||||
|
{
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
return Err(LoreError::Io(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some(SyncLockGuard { _file: file }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Crontab management ──
|
||||||
|
|
||||||
|
/// The crontab entry that `lore cron install` writes.
|
||||||
|
///
|
||||||
|
/// Paths are single-quoted so spaces in binary or log paths don't break
|
||||||
|
/// the cron expression.
|
||||||
|
pub fn build_cron_entry(interval_minutes: u32) -> String {
|
||||||
|
let binary = std::env::current_exe()
|
||||||
|
.unwrap_or_else(|_| PathBuf::from("lore"))
|
||||||
|
.display()
|
||||||
|
.to_string();
|
||||||
|
let log_path = sync_log_path();
|
||||||
|
format!(
|
||||||
|
"*/{interval_minutes} * * * * '{binary}' sync -q --lock >> '{log}' 2>&1 {CRON_TAG}",
|
||||||
|
log = log_path.display(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Path where cron-triggered sync output is appended.
|
||||||
|
pub fn sync_log_path() -> PathBuf {
|
||||||
|
get_data_dir().join("sync.log")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the current user crontab. Returns empty string when no crontab exists.
|
||||||
|
fn read_crontab() -> Result<String> {
|
||||||
|
let output = Command::new("crontab").arg("-l").output()?;
|
||||||
|
if output.status.success() {
|
||||||
|
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
|
||||||
|
} else {
|
||||||
|
// exit 1 with "no crontab for <user>" is normal — treat as empty
|
||||||
|
Ok(String::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a full crontab string. Replaces the current crontab entirely.
|
||||||
|
fn write_crontab(content: &str) -> Result<()> {
|
||||||
|
let mut child = Command::new("crontab")
|
||||||
|
.arg("-")
|
||||||
|
.stdin(std::process::Stdio::piped())
|
||||||
|
.spawn()?;
|
||||||
|
if let Some(ref mut stdin) = child.stdin {
|
||||||
|
stdin.write_all(content.as_bytes())?;
|
||||||
|
}
|
||||||
|
let status = child.wait()?;
|
||||||
|
if !status.success() {
|
||||||
|
return Err(LoreError::Other(format!(
|
||||||
|
"crontab exited with status {status}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install (or update) the lore-sync crontab entry.
|
||||||
|
pub fn install_cron(interval_minutes: u32) -> Result<CronInstallResult> {
|
||||||
|
let entry = build_cron_entry(interval_minutes);
|
||||||
|
|
||||||
|
let existing = read_crontab()?;
|
||||||
|
let replaced = existing.contains(CRON_TAG);
|
||||||
|
|
||||||
|
// Strip ALL old lore-sync lines first, then append one new entry.
|
||||||
|
// This is idempotent even if the crontab somehow has duplicate tagged lines.
|
||||||
|
let mut filtered: String = existing
|
||||||
|
.lines()
|
||||||
|
.filter(|line| !line.contains(CRON_TAG))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
if !filtered.is_empty() && !filtered.ends_with('\n') {
|
||||||
|
filtered.push('\n');
|
||||||
|
}
|
||||||
|
filtered.push_str(&entry);
|
||||||
|
filtered.push('\n');
|
||||||
|
|
||||||
|
write_crontab(&filtered)?;
|
||||||
|
|
||||||
|
Ok(CronInstallResult {
|
||||||
|
entry,
|
||||||
|
interval_minutes,
|
||||||
|
log_path: sync_log_path(),
|
||||||
|
replaced,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the lore-sync crontab entry.
|
||||||
|
pub fn uninstall_cron() -> Result<CronUninstallResult> {
|
||||||
|
let existing = read_crontab()?;
|
||||||
|
if !existing.contains(CRON_TAG) {
|
||||||
|
return Ok(CronUninstallResult {
|
||||||
|
was_installed: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_crontab: String = existing
|
||||||
|
.lines()
|
||||||
|
.filter(|line| !line.contains(CRON_TAG))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
+ "\n";
|
||||||
|
|
||||||
|
// If the crontab would be empty (only whitespace), remove it entirely
|
||||||
|
if new_crontab.trim().is_empty() {
|
||||||
|
let status = Command::new("crontab").arg("-r").status()?;
|
||||||
|
if !status.success() {
|
||||||
|
return Err(LoreError::Other("crontab -r failed".to_string()));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
write_crontab(&new_crontab)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(CronUninstallResult {
|
||||||
|
was_installed: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inspect the current crontab for a lore-sync entry.
|
||||||
|
pub fn cron_status() -> Result<CronStatusResult> {
|
||||||
|
let existing = read_crontab()?;
|
||||||
|
let lore_line = existing.lines().find(|l| l.contains(CRON_TAG));
|
||||||
|
|
||||||
|
match lore_line {
|
||||||
|
Some(line) => {
|
||||||
|
let interval = parse_interval(line);
|
||||||
|
let binary_path = parse_binary_path(line);
|
||||||
|
|
||||||
|
let current_exe = std::env::current_exe()
|
||||||
|
.ok()
|
||||||
|
.map(|p| p.display().to_string());
|
||||||
|
let binary_mismatch = current_exe
|
||||||
|
.as_ref()
|
||||||
|
.zip(binary_path.as_ref())
|
||||||
|
.is_some_and(|(current, cron)| current != cron);
|
||||||
|
|
||||||
|
Ok(CronStatusResult {
|
||||||
|
installed: true,
|
||||||
|
interval_minutes: interval,
|
||||||
|
binary_path,
|
||||||
|
current_binary: current_exe,
|
||||||
|
binary_mismatch,
|
||||||
|
log_path: Some(sync_log_path()),
|
||||||
|
cron_entry: Some(line.to_string()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
None => Ok(CronStatusResult {
|
||||||
|
installed: false,
|
||||||
|
interval_minutes: None,
|
||||||
|
binary_path: None,
|
||||||
|
current_binary: std::env::current_exe()
|
||||||
|
.ok()
|
||||||
|
.map(|p| p.display().to_string()),
|
||||||
|
binary_mismatch: false,
|
||||||
|
log_path: None,
|
||||||
|
cron_entry: None,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the interval from a cron expression like `*/8 * * * * ...`
|
||||||
|
fn parse_interval(line: &str) -> Option<u32> {
|
||||||
|
let first_field = line.split_whitespace().next()?;
|
||||||
|
if let Some(n) = first_field.strip_prefix("*/") {
|
||||||
|
n.parse().ok()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the binary path from the cron entry after the 5 time fields.
|
||||||
|
///
|
||||||
|
/// Handles both quoted (`'/path with spaces/lore'`) and unquoted paths.
|
||||||
|
/// We skip the time fields manually to avoid `split_whitespace` breaking
|
||||||
|
/// on spaces inside single-quoted paths.
|
||||||
|
fn parse_binary_path(line: &str) -> Option<String> {
|
||||||
|
// Skip the 5 cron time fields (min hour dom month dow).
|
||||||
|
// These never contain spaces, so whitespace-splitting is safe here.
|
||||||
|
let mut rest = line;
|
||||||
|
for _ in 0..5 {
|
||||||
|
rest = rest.trim_start();
|
||||||
|
let end = rest.find(char::is_whitespace)?;
|
||||||
|
rest = &rest[end..];
|
||||||
|
}
|
||||||
|
rest = rest.trim_start();
|
||||||
|
|
||||||
|
// The command starts here — it may be single-quoted.
|
||||||
|
if let Some(after_quote) = rest.strip_prefix('\'') {
|
||||||
|
let end = after_quote.find('\'')?;
|
||||||
|
Some(after_quote[..end].to_string())
|
||||||
|
} else {
|
||||||
|
let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
|
||||||
|
Some(rest[..end].to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Result types ──
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct CronInstallResult {
|
||||||
|
pub entry: String,
|
||||||
|
pub interval_minutes: u32,
|
||||||
|
pub log_path: PathBuf,
|
||||||
|
pub replaced: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct CronUninstallResult {
|
||||||
|
pub was_installed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct CronStatusResult {
|
||||||
|
pub installed: bool,
|
||||||
|
pub interval_minutes: Option<u32>,
|
||||||
|
pub binary_path: Option<String>,
|
||||||
|
pub current_binary: Option<String>,
|
||||||
|
pub binary_mismatch: bool,
|
||||||
|
pub log_path: Option<PathBuf>,
|
||||||
|
pub cron_entry: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_cron_entry_formats_correctly() {
|
||||||
|
let entry = build_cron_entry(8);
|
||||||
|
assert!(entry.starts_with("*/8 * * * * "));
|
||||||
|
assert!(entry.contains("sync -q --lock"));
|
||||||
|
assert!(entry.ends_with(CRON_TAG));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_interval_extracts_number() {
|
||||||
|
assert_eq!(parse_interval("*/8 * * * * /usr/bin/lore sync"), Some(8));
|
||||||
|
assert_eq!(parse_interval("*/15 * * * * /usr/bin/lore sync"), Some(15));
|
||||||
|
assert_eq!(parse_interval("0 * * * * /usr/bin/lore sync"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_binary_path_extracts_sixth_field() {
|
||||||
|
// Unquoted path
|
||||||
|
assert_eq!(
|
||||||
|
parse_binary_path(
|
||||||
|
"*/8 * * * * /usr/local/bin/lore sync -q --lock >> /tmp/log 2>&1 # lore-sync"
|
||||||
|
),
|
||||||
|
Some("/usr/local/bin/lore".to_string())
|
||||||
|
);
|
||||||
|
// Single-quoted path without spaces
|
||||||
|
assert_eq!(
|
||||||
|
parse_binary_path(
|
||||||
|
"*/8 * * * * '/usr/local/bin/lore' sync -q --lock >> '/tmp/log' 2>&1 # lore-sync"
|
||||||
|
),
|
||||||
|
Some("/usr/local/bin/lore".to_string())
|
||||||
|
);
|
||||||
|
// Single-quoted path WITH spaces (common on macOS)
|
||||||
|
assert_eq!(
|
||||||
|
parse_binary_path(
|
||||||
|
"*/8 * * * * '/Users/Taylor Eernisse/.cargo/bin/lore' sync -q --lock >> '/tmp/log' 2>&1 # lore-sync"
|
||||||
|
),
|
||||||
|
Some("/Users/Taylor Eernisse/.cargo/bin/lore".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_lock_at_nonexistent_dir_creates_parents() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let lock_file = dir.path().join("nested").join("deep").join("sync.lock");
|
||||||
|
let guard = acquire_sync_lock_at(&lock_file).unwrap();
|
||||||
|
assert!(guard.is_some());
|
||||||
|
assert!(lock_file.exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sync_lock_is_exclusive_across_processes() {
|
||||||
|
// POSIX fcntl locks are per-process, so same-process re-lock always
|
||||||
|
// succeeds. We verify cross-process exclusion using a Python child
|
||||||
|
// that attempts the same fcntl F_SETLK.
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let lock_file = dir.path().join("sync.lock");
|
||||||
|
let _guard = acquire_sync_lock_at(&lock_file).unwrap().unwrap();
|
||||||
|
|
||||||
|
let script = r#"
|
||||||
|
import fcntl, struct, sys
|
||||||
|
fd = open(sys.argv[1], "w")
|
||||||
|
try:
|
||||||
|
fcntl.fcntl(fd, fcntl.F_SETLK, struct.pack("hhllhh", fcntl.F_WRLCK, 0, 0, 0, 0, 0))
|
||||||
|
sys.exit(0)
|
||||||
|
except (IOError, OSError):
|
||||||
|
sys.exit(1)
|
||||||
|
"#;
|
||||||
|
let status = std::process::Command::new("python3")
|
||||||
|
.args(["-c", script, &lock_file.display().to_string()])
|
||||||
|
.status()
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
!status.success(),
|
||||||
|
"child process should fail to acquire fcntl lock held by parent"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
pub mod backoff;
|
pub mod backoff;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
#[cfg(unix)]
|
||||||
|
pub mod cron;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod dependent_queue;
|
pub mod dependent_queue;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
|||||||
114
src/main.rs
114
src/main.rs
@@ -11,26 +11,29 @@ use lore::cli::autocorrect::{self, CorrectionResult};
|
|||||||
use lore::cli::commands::{
|
use lore::cli::commands::{
|
||||||
IngestDisplay, InitInputs, InitOptions, InitResult, ListFilters, MrListFilters,
|
IngestDisplay, InitInputs, InitOptions, InitResult, ListFilters, MrListFilters,
|
||||||
NoteListFilters, SearchCliFilters, SyncOptions, TimelineParams, open_issue_in_browser,
|
NoteListFilters, SearchCliFilters, SyncOptions, TimelineParams, open_issue_in_browser,
|
||||||
open_mr_in_browser, parse_trace_path, print_count, print_count_json, print_doctor_results,
|
open_mr_in_browser, parse_trace_path, print_count, print_count_json, print_cron_install,
|
||||||
print_drift_human, print_drift_json, print_dry_run_preview, print_dry_run_preview_json,
|
print_cron_install_json, print_cron_status, print_cron_status_json, print_cron_uninstall,
|
||||||
print_embed, print_embed_json, print_event_count, print_event_count_json, print_file_history,
|
print_cron_uninstall_json, print_doctor_results, print_drift_human, print_drift_json,
|
||||||
print_file_history_json, print_generate_docs, print_generate_docs_json, print_ingest_summary,
|
print_dry_run_preview, print_dry_run_preview_json, print_embed, print_embed_json,
|
||||||
print_ingest_summary_json, print_list_issues, print_list_issues_json, print_list_mrs,
|
print_event_count, print_event_count_json, print_file_history, print_file_history_json,
|
||||||
print_list_mrs_json, print_list_notes, print_list_notes_csv, print_list_notes_json,
|
print_generate_docs, print_generate_docs_json, print_ingest_summary, print_ingest_summary_json,
|
||||||
print_list_notes_jsonl, print_search_results, print_search_results_json, print_show_issue,
|
print_list_issues, print_list_issues_json, print_list_mrs, print_list_mrs_json,
|
||||||
print_show_issue_json, print_show_mr, print_show_mr_json, print_stats, print_stats_json,
|
print_list_notes, print_list_notes_json, print_search_results, print_search_results_json,
|
||||||
print_sync, print_sync_json, print_sync_status, print_sync_status_json, print_timeline,
|
print_show_issue, print_show_issue_json, print_show_mr, print_show_mr_json, print_stats,
|
||||||
print_timeline_json_with_meta, print_trace, print_trace_json, print_who_human, print_who_json,
|
print_stats_json, print_sync, print_sync_json, print_sync_status, print_sync_status_json,
|
||||||
query_notes, run_auth_test, run_count, run_count_events, run_doctor, run_drift, run_embed,
|
print_timeline, print_timeline_json_with_meta, print_trace, print_trace_json, print_who_human,
|
||||||
run_file_history, run_generate_docs, run_ingest, run_ingest_dry_run, run_init, run_list_issues,
|
print_who_json, query_notes, run_auth_test, run_count, run_count_events, run_cron_install,
|
||||||
run_list_mrs, run_search, run_show_issue, run_show_mr, run_stats, run_sync, run_sync_status,
|
run_cron_status, run_cron_uninstall, run_doctor, run_drift, run_embed, run_file_history,
|
||||||
run_timeline, run_who,
|
run_generate_docs, run_ingest, run_ingest_dry_run, run_init, run_list_issues, run_list_mrs,
|
||||||
|
run_search, run_show_issue, run_show_mr, run_stats, run_sync, run_sync_status, run_timeline,
|
||||||
|
run_who,
|
||||||
};
|
};
|
||||||
use lore::cli::render::{ColorMode, GlyphMode, Icons, LoreRenderer, Theme};
|
use lore::cli::render::{ColorMode, GlyphMode, Icons, LoreRenderer, Theme};
|
||||||
use lore::cli::robot::{RobotMeta, strip_schemas};
|
use lore::cli::robot::{RobotMeta, strip_schemas};
|
||||||
use lore::cli::{
|
use lore::cli::{
|
||||||
Cli, Commands, CountArgs, EmbedArgs, FileHistoryArgs, GenerateDocsArgs, IngestArgs, IssuesArgs,
|
Cli, Commands, CountArgs, CronAction, CronArgs, EmbedArgs, FileHistoryArgs, GenerateDocsArgs,
|
||||||
MrsArgs, NotesArgs, SearchArgs, StatsArgs, SyncArgs, TimelineArgs, TraceArgs, WhoArgs,
|
IngestArgs, IssuesArgs, MrsArgs, NotesArgs, SearchArgs, StatsArgs, SyncArgs, TimelineArgs,
|
||||||
|
TraceArgs, WhoArgs,
|
||||||
};
|
};
|
||||||
use lore::core::db::{
|
use lore::core::db::{
|
||||||
LATEST_SCHEMA_VERSION, create_connection, get_schema_version, run_migrations,
|
LATEST_SCHEMA_VERSION, create_connection, get_schema_version, run_migrations,
|
||||||
@@ -203,6 +206,7 @@ async fn main() {
|
|||||||
handle_file_history(cli.config.as_deref(), args, robot_mode)
|
handle_file_history(cli.config.as_deref(), args, robot_mode)
|
||||||
}
|
}
|
||||||
Some(Commands::Trace(args)) => handle_trace(cli.config.as_deref(), args, robot_mode),
|
Some(Commands::Trace(args)) => handle_trace(cli.config.as_deref(), args, robot_mode),
|
||||||
|
Some(Commands::Cron(args)) => handle_cron(cli.config.as_deref(), args, robot_mode),
|
||||||
Some(Commands::Drift {
|
Some(Commands::Drift {
|
||||||
entity_type,
|
entity_type,
|
||||||
iid,
|
iid,
|
||||||
@@ -922,21 +926,14 @@ fn handle_notes(
|
|||||||
|
|
||||||
let result = query_notes(&conn, &filters, &config)?;
|
let result = query_notes(&conn, &filters, &config)?;
|
||||||
|
|
||||||
let format = if robot_mode && args.format == "table" {
|
if robot_mode {
|
||||||
"json"
|
print_list_notes_json(
|
||||||
} else {
|
|
||||||
&args.format
|
|
||||||
};
|
|
||||||
|
|
||||||
match format {
|
|
||||||
"json" => print_list_notes_json(
|
|
||||||
&result,
|
&result,
|
||||||
start.elapsed().as_millis() as u64,
|
start.elapsed().as_millis() as u64,
|
||||||
args.fields.as_deref(),
|
args.fields.as_deref(),
|
||||||
),
|
);
|
||||||
"jsonl" => print_list_notes_jsonl(&result),
|
} else {
|
||||||
"csv" => print_list_notes_csv(&result),
|
print_list_notes(&result);
|
||||||
_ => print_list_notes(&result),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1642,6 +1639,7 @@ struct VersionOutput {
|
|||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct VersionData {
|
struct VersionData {
|
||||||
|
name: &'static str,
|
||||||
version: String,
|
version: String,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
git_hash: Option<String>,
|
git_hash: Option<String>,
|
||||||
@@ -1655,6 +1653,7 @@ fn handle_version(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let output = VersionOutput {
|
let output = VersionOutput {
|
||||||
ok: true,
|
ok: true,
|
||||||
data: VersionData {
|
data: VersionData {
|
||||||
|
name: "lore",
|
||||||
version,
|
version,
|
||||||
git_hash: if git_hash.is_empty() {
|
git_hash: if git_hash.is_empty() {
|
||||||
None
|
None
|
||||||
@@ -2182,6 +2181,24 @@ async fn handle_sync_cmd(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Acquire file lock if --lock was passed (used by cron to skip overlapping runs)
|
||||||
|
let _sync_lock = if args.lock {
|
||||||
|
match lore::core::cron::acquire_sync_lock() {
|
||||||
|
Ok(Some(guard)) => Some(guard),
|
||||||
|
Ok(None) => {
|
||||||
|
// Another sync is running — silently exit (expected for cron)
|
||||||
|
tracing::debug!("--lock: another sync is running, skipping");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "--lock: failed to acquire file lock, skipping sync");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
let db_path = get_db_path(config.storage.db_path.as_deref());
|
let db_path = get_db_path(config.storage.db_path.as_deref());
|
||||||
let recorder_conn = create_connection(&db_path)?;
|
let recorder_conn = create_connection(&db_path)?;
|
||||||
let run_id = uuid::Uuid::new_v4().simple().to_string();
|
let run_id = uuid::Uuid::new_v4().simple().to_string();
|
||||||
@@ -2254,6 +2271,47 @@ async fn handle_sync_cmd(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_cron(
|
||||||
|
config_override: Option<&str>,
|
||||||
|
args: CronArgs,
|
||||||
|
robot_mode: bool,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
|
match args.action {
|
||||||
|
CronAction::Install { interval } => {
|
||||||
|
let result = run_cron_install(interval)?;
|
||||||
|
let elapsed_ms = start.elapsed().as_millis() as u64;
|
||||||
|
if robot_mode {
|
||||||
|
print_cron_install_json(&result, elapsed_ms);
|
||||||
|
} else {
|
||||||
|
print_cron_install(&result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CronAction::Uninstall => {
|
||||||
|
let result = run_cron_uninstall()?;
|
||||||
|
let elapsed_ms = start.elapsed().as_millis() as u64;
|
||||||
|
if robot_mode {
|
||||||
|
print_cron_uninstall_json(&result, elapsed_ms);
|
||||||
|
} else {
|
||||||
|
print_cron_uninstall(&result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CronAction::Status => {
|
||||||
|
let config = Config::load(config_override)?;
|
||||||
|
let info = run_cron_status(&config)?;
|
||||||
|
let elapsed_ms = start.elapsed().as_millis() as u64;
|
||||||
|
if robot_mode {
|
||||||
|
print_cron_status_json(&info, elapsed_ms);
|
||||||
|
} else {
|
||||||
|
print_cron_status(&info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct HealthOutput {
|
struct HealthOutput {
|
||||||
ok: bool,
|
ok: bool,
|
||||||
|
|||||||
Reference in New Issue
Block a user