use anyhow::{anyhow, Context, Result};
use std::fs::{self, File};
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use crate::event::Event;
use crate::migration;
pub struct SpoolContext {
pub root: PathBuf,
pub events_dir: PathBuf,
pub archive_dir: PathBuf,
}
impl SpoolContext {
pub fn new(root: PathBuf) -> Self {
Self {
events_dir: root.join("events"),
archive_dir: root.join("archive"),
root,
}
}
pub fn discover() -> Result<Self> {
let mut current = std::env::current_dir()?;
loop {
let spool_dir = current.join(".spool");
if spool_dir.is_dir() {
let ctx = Self::new(spool_dir);
migration::check_and_migrate(&ctx)?;
return Ok(ctx);
}
if !current.pop() {
return Err(anyhow!(
"Not in a spool directory. Run 'spool init' to create one."
));
}
}
}
pub fn index_path(&self) -> PathBuf {
self.root.join(".index.json")
}
pub fn state_path(&self) -> PathBuf {
self.root.join(".state.json")
}
pub fn get_event_files(&self) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
if self.events_dir.is_dir() {
for entry in fs::read_dir(&self.events_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "jsonl") {
files.push(path);
}
}
}
files.sort();
Ok(files)
}
pub fn get_archive_files(&self) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
if self.archive_dir.is_dir() {
for entry in fs::read_dir(&self.archive_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "jsonl") {
files.push(path);
}
}
}
files.sort();
Ok(files)
}
pub fn parse_events_from_file(&self, path: &Path) -> Result<Vec<Event>> {
let file = File::open(path).with_context(|| format!("Failed to open {:?}", path))?;
let reader = BufReader::new(file);
let mut events = Vec::new();
for (line_num, line) in reader.lines().enumerate() {
let line = line?;
if line.trim().is_empty() {
continue;
}
let event: Event = serde_json::from_str(&line)
.with_context(|| format!("Failed to parse line {} in {:?}", line_num + 1, path))?;
events.push(event);
}
Ok(events)
}
}
pub fn init() -> Result<()> {
let spool_dir = PathBuf::from(".spool");
if spool_dir.exists() {
return Err(anyhow!(".spool directory already exists"));
}
fs::create_dir_all(spool_dir.join("events"))?;
fs::create_dir_all(spool_dir.join("archive"))?;
let gitignore = r#"# Derived files - rebuilt from events on checkout/merge
# These are caches for fast queries, not source of truth
# Task index: maps task_id -> status, date range, file locations
.index.json
# Materialized state: current snapshot of all tasks
.state.json
# Any temporary files from tooling
*.tmp
*.bak
"#;
fs::write(spool_dir.join(".gitignore"), gitignore)?;
let version = migration::VersionInfo::default();
let version_json = serde_json::to_string_pretty(&version)?;
fs::write(spool_dir.join("version.json"), version_json)?;
println!("Created .spool/");
println!(" .spool/events/ - Daily event logs");
println!(" .spool/archive/ - Monthly rollups");
println!(" .spool/.gitignore - Ignores derived files");
println!(" .spool/version.json - Format version tracking");
Ok(())
}