feat(core): Implement infrastructure layer for CLI operations
Establishes foundational modules that all other components depend on. src/core/config.rs - Configuration management: - JSON-based config file with Zod-like validation via serde - GitLab settings: base URL, token environment variable - Project list with paths to track - Sync settings: backfill days, stale lock timeout, cursor rewind - Storage settings: database path, payload compression toggle - XDG-compliant config path resolution via dirs crate - Loads GITLAB_TOKEN from configured environment variable src/core/db.rs - Database connection and migrations: - Opens or creates SQLite database with WAL mode for concurrency - Embeds migration SQL as const strings (001-005) - Runs migrations idempotently with checksum verification - Provides thread-safe connection management src/core/error.rs - Unified error handling: - GiError enum with variants for all failure modes - Config, Database, GitLab, Ingestion, Lock, IO, Parse errors - thiserror derive for automatic Display/Error impls - Result type alias for ergonomic error propagation src/core/lock.rs - Distributed sync locking: - File-based locks to prevent concurrent syncs - Stale lock detection with configurable timeout - Force override for recovery scenarios - Lock file contains PID and timestamp for debugging src/core/paths.rs - Path resolution: - XDG Base Directory Specification compliance - Config: ~/.config/gi/config.json - Data: ~/.local/share/gi/gi.db - Creates parent directories on first access src/core/payloads.rs - Raw payload storage: - Optional gzip compression for storage efficiency - SHA-256 content addressing for deduplication - Type-prefixed keys (issue:, discussion:, note:) - Batch insert with UPSERT for idempotent ingestion src/core/time.rs - Timestamp utilities: - Relative time parsing (7d, 2w, 1m) for --since flag - ISO 8601 date parsing for absolute dates - Human-friendly relative time formatting Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
203
src/core/config.rs
Normal file
203
src/core/config.rs
Normal file
@@ -0,0 +1,203 @@
|
||||
//! Configuration loading and validation.
|
||||
//!
|
||||
//! Config schema mirrors the TypeScript version with serde for deserialization.
|
||||
|
||||
use serde::Deserialize;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use super::error::{GiError, Result};
|
||||
use super::paths::get_config_path;
|
||||
|
||||
/// GitLab connection settings.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct GitLabConfig {
|
||||
#[serde(rename = "baseUrl")]
|
||||
pub base_url: String,
|
||||
|
||||
#[serde(rename = "tokenEnvVar", default = "default_token_env_var")]
|
||||
pub token_env_var: String,
|
||||
}
|
||||
|
||||
fn default_token_env_var() -> String {
|
||||
"GITLAB_TOKEN".to_string()
|
||||
}
|
||||
|
||||
/// Project to sync.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ProjectConfig {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// Sync behavior settings.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct SyncConfig {
|
||||
#[serde(rename = "backfillDays")]
|
||||
pub backfill_days: u32,
|
||||
|
||||
#[serde(rename = "staleLockMinutes")]
|
||||
pub stale_lock_minutes: u32,
|
||||
|
||||
#[serde(rename = "heartbeatIntervalSeconds")]
|
||||
pub heartbeat_interval_seconds: u32,
|
||||
|
||||
#[serde(rename = "cursorRewindSeconds")]
|
||||
pub cursor_rewind_seconds: u32,
|
||||
|
||||
#[serde(rename = "primaryConcurrency")]
|
||||
pub primary_concurrency: u32,
|
||||
|
||||
#[serde(rename = "dependentConcurrency")]
|
||||
pub dependent_concurrency: u32,
|
||||
}
|
||||
|
||||
impl Default for SyncConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
backfill_days: 14,
|
||||
stale_lock_minutes: 10,
|
||||
heartbeat_interval_seconds: 30,
|
||||
cursor_rewind_seconds: 2,
|
||||
primary_concurrency: 4,
|
||||
dependent_concurrency: 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Storage settings.
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct StorageConfig {
|
||||
#[serde(rename = "dbPath")]
|
||||
pub db_path: Option<String>,
|
||||
|
||||
#[serde(rename = "backupDir")]
|
||||
pub backup_dir: Option<String>,
|
||||
|
||||
#[serde(
|
||||
rename = "compressRawPayloads",
|
||||
default = "default_compress_raw_payloads"
|
||||
)]
|
||||
pub compress_raw_payloads: bool,
|
||||
}
|
||||
|
||||
fn default_compress_raw_payloads() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Embedding provider settings.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct EmbeddingConfig {
|
||||
pub provider: String,
|
||||
pub model: String,
|
||||
#[serde(rename = "baseUrl")]
|
||||
pub base_url: String,
|
||||
pub concurrency: u32,
|
||||
}
|
||||
|
||||
impl Default for EmbeddingConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
provider: "ollama".to_string(),
|
||||
model: "nomic-embed-text".to_string(),
|
||||
base_url: "http://localhost:11434".to_string(),
|
||||
concurrency: 4,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Main configuration structure.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Config {
|
||||
pub gitlab: GitLabConfig,
|
||||
pub projects: Vec<ProjectConfig>,
|
||||
|
||||
#[serde(default)]
|
||||
pub sync: SyncConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub storage: StorageConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub embedding: EmbeddingConfig,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Load and validate configuration from file.
|
||||
pub fn load(cli_override: Option<&str>) -> Result<Self> {
|
||||
let config_path = get_config_path(cli_override);
|
||||
|
||||
if !config_path.exists() {
|
||||
return Err(GiError::ConfigNotFound {
|
||||
path: config_path.display().to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Self::load_from_path(&config_path)
|
||||
}
|
||||
|
||||
/// Load configuration from a specific path.
|
||||
pub fn load_from_path(path: &Path) -> Result<Self> {
|
||||
let content = fs::read_to_string(path).map_err(|e| GiError::ConfigInvalid {
|
||||
details: format!("Failed to read config file: {e}"),
|
||||
})?;
|
||||
|
||||
let config: Config =
|
||||
serde_json::from_str(&content).map_err(|e| GiError::ConfigInvalid {
|
||||
details: format!("Invalid JSON: {e}"),
|
||||
})?;
|
||||
|
||||
// Validate required fields
|
||||
if config.projects.is_empty() {
|
||||
return Err(GiError::ConfigInvalid {
|
||||
details: "At least one project is required".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
for project in &config.projects {
|
||||
if project.path.is_empty() {
|
||||
return Err(GiError::ConfigInvalid {
|
||||
details: "Project path cannot be empty".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Validate URL format
|
||||
if url::Url::parse(&config.gitlab.base_url).is_err() {
|
||||
return Err(GiError::ConfigInvalid {
|
||||
details: format!("Invalid GitLab URL: {}", config.gitlab.base_url),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal config for writing during init (relies on defaults when loaded).
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct MinimalConfig {
|
||||
pub gitlab: MinimalGitLabConfig,
|
||||
pub projects: Vec<ProjectConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct MinimalGitLabConfig {
|
||||
#[serde(rename = "baseUrl")]
|
||||
pub base_url: String,
|
||||
#[serde(rename = "tokenEnvVar")]
|
||||
pub token_env_var: String,
|
||||
}
|
||||
|
||||
impl serde::Serialize for ProjectConfig {
|
||||
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
use serde::ser::SerializeStruct;
|
||||
let mut state = serializer.serialize_struct("ProjectConfig", 1)?;
|
||||
state.serialize_field("path", &self.path)?;
|
||||
state.end()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user