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

137
src/core/error.rs Normal file
View File

@@ -0,0 +1,137 @@
//! Custom error types for gitlab-inbox.
//!
//! Uses thiserror for ergonomic error definitions with structured error codes.
use thiserror::Error;
/// Error codes for programmatic error handling.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrorCode {
ConfigNotFound,
ConfigInvalid,
GitLabAuthFailed,
GitLabNotFound,
GitLabRateLimited,
GitLabNetworkError,
DatabaseLocked,
DatabaseError,
MigrationFailed,
TokenNotSet,
TransformError,
IoError,
InternalError,
}
impl std::fmt::Display for ErrorCode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let code = match self {
Self::ConfigNotFound => "CONFIG_NOT_FOUND",
Self::ConfigInvalid => "CONFIG_INVALID",
Self::GitLabAuthFailed => "GITLAB_AUTH_FAILED",
Self::GitLabNotFound => "GITLAB_NOT_FOUND",
Self::GitLabRateLimited => "GITLAB_RATE_LIMITED",
Self::GitLabNetworkError => "GITLAB_NETWORK_ERROR",
Self::DatabaseLocked => "DB_LOCKED",
Self::DatabaseError => "DB_ERROR",
Self::MigrationFailed => "MIGRATION_FAILED",
Self::TokenNotSet => "TOKEN_NOT_SET",
Self::TransformError => "TRANSFORM_ERROR",
Self::IoError => "IO_ERROR",
Self::InternalError => "INTERNAL_ERROR",
};
write!(f, "{code}")
}
}
/// Main error type for gitlab-inbox.
#[derive(Error, Debug)]
pub enum GiError {
#[error("Config file not found at {path}. Run \"gi init\" first.")]
ConfigNotFound { path: String },
#[error("Invalid config: {details}")]
ConfigInvalid { details: String },
#[error("GitLab authentication failed. Check your token has read_api scope.")]
GitLabAuthFailed,
#[error("GitLab resource not found: {resource}")]
GitLabNotFound { resource: String },
#[error("Rate limited. Retry after {retry_after}s")]
GitLabRateLimited { retry_after: u64 },
#[error("Cannot connect to GitLab at {base_url}")]
GitLabNetworkError {
base_url: String,
#[source]
source: Option<reqwest::Error>,
},
#[error(
"Another sync is running (owner: {owner}, started: {started_at}). Use --force to override if stale."
)]
DatabaseLocked { owner: String, started_at: String },
#[error("Migration {version} failed: {message}")]
MigrationFailed {
version: i32,
message: String,
#[source]
source: Option<rusqlite::Error>,
},
#[error("GitLab token not set. Export {env_var} environment variable.")]
TokenNotSet { env_var: String },
#[error("Database error: {0}")]
Database(#[from] rusqlite::Error),
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Transform error: {0}")]
Transform(#[from] crate::gitlab::transformers::issue::TransformError),
#[error("Not found: {0}")]
NotFound(String),
#[error("Ambiguous: {0}")]
Ambiguous(String),
#[error("{0}")]
Other(String),
}
impl GiError {
/// Get the error code for programmatic handling.
pub fn code(&self) -> ErrorCode {
match self {
Self::ConfigNotFound { .. } => ErrorCode::ConfigNotFound,
Self::ConfigInvalid { .. } => ErrorCode::ConfigInvalid,
Self::GitLabAuthFailed => ErrorCode::GitLabAuthFailed,
Self::GitLabNotFound { .. } => ErrorCode::GitLabNotFound,
Self::GitLabRateLimited { .. } => ErrorCode::GitLabRateLimited,
Self::GitLabNetworkError { .. } => ErrorCode::GitLabNetworkError,
Self::DatabaseLocked { .. } => ErrorCode::DatabaseLocked,
Self::MigrationFailed { .. } => ErrorCode::MigrationFailed,
Self::TokenNotSet { .. } => ErrorCode::TokenNotSet,
Self::Database(_) => ErrorCode::DatabaseError,
Self::Http(_) => ErrorCode::GitLabNetworkError,
Self::Json(_) => ErrorCode::InternalError,
Self::Io(_) => ErrorCode::IoError,
Self::Transform(_) => ErrorCode::TransformError,
Self::NotFound(_) => ErrorCode::GitLabNotFound,
Self::Ambiguous(_) => ErrorCode::InternalError,
Self::Other(_) => ErrorCode::InternalError,
}
}
}
pub type Result<T> = std::result::Result<T, GiError>;