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:
136
src/core/time.rs
Normal file
136
src/core/time.rs
Normal file
@@ -0,0 +1,136 @@
|
||||
//! Time utilities for consistent timestamp handling.
|
||||
//!
|
||||
//! All database *_at columns use milliseconds since epoch for consistency.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
/// Convert GitLab API ISO 8601 timestamp to milliseconds since epoch.
|
||||
pub fn iso_to_ms(iso_string: &str) -> Option<i64> {
|
||||
DateTime::parse_from_rfc3339(iso_string)
|
||||
.ok()
|
||||
.map(|dt| dt.timestamp_millis())
|
||||
}
|
||||
|
||||
/// Convert milliseconds since epoch to ISO 8601 string.
|
||||
pub fn ms_to_iso(ms: i64) -> String {
|
||||
DateTime::from_timestamp_millis(ms)
|
||||
.map(|dt| dt.to_rfc3339())
|
||||
.unwrap_or_else(|| "Invalid timestamp".to_string())
|
||||
}
|
||||
|
||||
/// Get current time in milliseconds since epoch.
|
||||
pub fn now_ms() -> i64 {
|
||||
Utc::now().timestamp_millis()
|
||||
}
|
||||
|
||||
/// Parse a relative time string (7d, 2w, 1m) or ISO date into ms epoch.
|
||||
///
|
||||
/// Returns the timestamp as of which to filter (cutoff point).
|
||||
/// - `7d` = 7 days ago
|
||||
/// - `2w` = 2 weeks ago
|
||||
/// - `1m` = 1 month ago (30 days)
|
||||
/// - `2024-01-15` = midnight UTC on that date
|
||||
pub fn parse_since(input: &str) -> Option<i64> {
|
||||
let input = input.trim();
|
||||
|
||||
// Try relative format: Nd, Nw, Nm
|
||||
if let Some(num_str) = input.strip_suffix('d') {
|
||||
let days: i64 = num_str.parse().ok()?;
|
||||
return Some(now_ms() - (days * 24 * 60 * 60 * 1000));
|
||||
}
|
||||
|
||||
if let Some(num_str) = input.strip_suffix('w') {
|
||||
let weeks: i64 = num_str.parse().ok()?;
|
||||
return Some(now_ms() - (weeks * 7 * 24 * 60 * 60 * 1000));
|
||||
}
|
||||
|
||||
if let Some(num_str) = input.strip_suffix('m') {
|
||||
let months: i64 = num_str.parse().ok()?;
|
||||
return Some(now_ms() - (months * 30 * 24 * 60 * 60 * 1000));
|
||||
}
|
||||
|
||||
// Try ISO date: YYYY-MM-DD
|
||||
if input.len() == 10 && input.chars().filter(|&c| c == '-').count() == 2 {
|
||||
let iso_full = format!("{input}T00:00:00Z");
|
||||
return iso_to_ms(&iso_full);
|
||||
}
|
||||
|
||||
// Try full ISO 8601
|
||||
iso_to_ms(input)
|
||||
}
|
||||
|
||||
/// Format milliseconds epoch to human-readable full datetime.
|
||||
pub fn format_full_datetime(ms: i64) -> String {
|
||||
DateTime::from_timestamp_millis(ms)
|
||||
.map(|dt| dt.format("%Y-%m-%d %H:%M UTC").to_string())
|
||||
.unwrap_or_else(|| "Unknown".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_iso_to_ms() {
|
||||
let ms = iso_to_ms("2024-01-15T10:30:00Z").unwrap();
|
||||
assert!(ms > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ms_to_iso() {
|
||||
let iso = ms_to_iso(1705315800000);
|
||||
assert!(iso.contains("2024-01-15"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_now_ms() {
|
||||
let now = now_ms();
|
||||
assert!(now > 1700000000000); // After 2023
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_since_days() {
|
||||
let now = now_ms();
|
||||
let seven_days = parse_since("7d").unwrap();
|
||||
let expected = now - (7 * 24 * 60 * 60 * 1000);
|
||||
assert!((seven_days - expected).abs() < 1000); // Within 1 second
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_since_weeks() {
|
||||
let now = now_ms();
|
||||
let two_weeks = parse_since("2w").unwrap();
|
||||
let expected = now - (14 * 24 * 60 * 60 * 1000);
|
||||
assert!((two_weeks - expected).abs() < 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_since_months() {
|
||||
let now = now_ms();
|
||||
let one_month = parse_since("1m").unwrap();
|
||||
let expected = now - (30 * 24 * 60 * 60 * 1000);
|
||||
assert!((one_month - expected).abs() < 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_since_iso_date() {
|
||||
let ms = parse_since("2024-01-15").unwrap();
|
||||
assert!(ms > 0);
|
||||
// Should be midnight UTC on that date
|
||||
let expected = iso_to_ms("2024-01-15T00:00:00Z").unwrap();
|
||||
assert_eq!(ms, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_since_invalid() {
|
||||
assert!(parse_since("invalid").is_none());
|
||||
assert!(parse_since("").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_full_datetime() {
|
||||
let dt = format_full_datetime(1705315800000);
|
||||
assert!(dt.contains("2024-01-15"));
|
||||
assert!(dt.contains("UTC"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user