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:
Taylor Eernisse
2026-01-26 11:28:07 -05:00
parent d15f457a58
commit 7aaa51f645
8 changed files with 1244 additions and 0 deletions

203
src/core/config.rs Normal file
View 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()
}
}