use std::collections::HashMap;
use std::fs::File;
use std::io::{BufRead, BufReader, BufWriter, Write};
use std::path::Path;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use super::{SqliteStorage, Storage};
use crate::types::{Dependency, Issue};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ExportStats {
pub issues: usize,
pub dependencies: usize,
pub errors: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ImportStats {
pub issues_imported: usize,
pub issues_updated: usize,
pub issues_skipped: usize,
pub errors: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IssueRecord {
#[serde(flatten)]
pub issue: Issue,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dependencies: Vec<DependencyRecord>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DependencyRecord {
pub depends_on_id: String,
#[serde(rename = "type")]
pub dep_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<String>,
}
pub fn export_to_jsonl(storage: &SqliteStorage, path: impl AsRef<Path>) -> Result<ExportStats> {
let path = path.as_ref();
let mut stats = ExportStats::default();
let filter = crate::types::IssueFilter {
include_tombstones: true,
..Default::default()
};
let issues = storage.search_issues(&filter)?;
let temp_path = path.with_extension("jsonl.tmp");
let file = File::create(&temp_path)
.with_context(|| format!("Failed to create temp file: {:?}", temp_path))?;
let mut writer = BufWriter::new(file);
for issue in issues {
let deps = match storage.get_dependencies(&issue.id) {
Ok(deps) => deps,
Err(e) => {
stats.errors.push(format!("{}: {}", issue.id, e));
continue;
}
};
let record = IssueRecord {
issue: issue.clone(),
dependencies: deps.iter().map(|d| DependencyRecord {
depends_on_id: d.depends_on_id.clone(),
dep_type: d.dep_type.as_str().to_string(),
metadata: d.metadata.clone(),
}).collect(),
};
let json = serde_json::to_string(&record)
.with_context(|| format!("Failed to serialize issue: {}", issue.id))?;
writeln!(writer, "{}", json)?;
stats.issues += 1;
stats.dependencies += deps.len();
}
writer.flush()?;
drop(writer);
std::fs::rename(&temp_path, path)
.with_context(|| format!("Failed to rename temp file to: {:?}", path))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
}
let dirty_ids = storage.get_dirty_issues()?;
if !dirty_ids.is_empty() {
storage.clear_dirty(&dirty_ids)?;
}
Ok(stats)
}
pub fn import_from_jsonl(storage: &SqliteStorage, path: impl AsRef<Path>) -> Result<ImportStats> {
let path = path.as_ref();
let mut stats = ImportStats::default();
if !path.exists() {
return Ok(stats);
}
let file = File::open(path)
.with_context(|| format!("Failed to open JSONL file: {:?}", path))?;
let reader = BufReader::new(file);
let mut records: Vec<IssueRecord> = Vec::new();
for (line_num, line) in reader.lines().enumerate() {
let line = line.with_context(|| format!("Failed to read line {}", line_num + 1))?;
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<IssueRecord>(&line) {
Ok(record) => records.push(record),
Err(e) => {
stats.errors.push(format!("Line {}: {}", line_num + 1, e));
}
}
}
let mut hash_to_id: HashMap<String, String> = HashMap::new();
for record in &records {
if let Some(ref hash) = record.issue.content_hash {
hash_to_id.insert(hash.clone(), record.issue.id.clone());
}
}
for record in records {
let issue_id = record.issue.id.clone();
match storage.get_issue(&issue_id) {
Ok(Some(existing)) => {
if record.issue.updated_at > existing.updated_at {
match storage.update_issue(&record.issue) {
Ok(()) => stats.issues_updated += 1,
Err(e) => stats.errors.push(format!("{}: {}", issue_id, e)),
}
} else {
stats.issues_skipped += 1;
}
}
Ok(None) => {
let mut found_rename = false;
if let Some(ref hash) = record.issue.content_hash {
if let Some(old_id) = hash_to_id.get(hash) {
if old_id != &issue_id {
found_rename = true;
stats.issues_skipped += 1;
}
}
}
if !found_rename {
match storage.create_issue(&record.issue) {
Ok(()) => stats.issues_imported += 1,
Err(e) => stats.errors.push(format!("{}: {}", issue_id, e)),
}
}
}
Err(e) => {
stats.errors.push(format!("{}: {}", issue_id, e));
continue;
}
}
for dep_record in &record.dependencies {
let dep = Dependency {
issue_id: issue_id.clone(),
depends_on_id: dep_record.depends_on_id.clone(),
dep_type: dep_record.dep_type.parse().unwrap_or_default(),
created_at: chrono::Utc::now(),
created_by: None,
metadata: dep_record.metadata.clone(),
thread_id: None,
};
let _ = storage.add_dependency(&dep);
}
}
Ok(stats)
}
pub fn sync_jsonl(storage: &SqliteStorage, path: impl AsRef<Path>) -> Result<()> {
let path = path.as_ref();
let dirty = storage.get_dirty_issues()?;
if path.exists() && dirty.is_empty() {
import_from_jsonl(storage, path)?;
} else if !dirty.is_empty() {
export_to_jsonl(storage, path)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_export_import_roundtrip() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("test.db");
let jsonl_path = temp.path().join("issues.jsonl");
let storage = SqliteStorage::open(&db_path).unwrap();
let issue1 = Issue::new("bd-a1b2", "Task 1", "alice");
storage.create_issue(&issue1).unwrap();
let issue2 = Issue::new("bd-c3d4", "Task 2", "bob");
storage.create_issue(&issue2).unwrap();
let dep = Dependency::blocks("bd-c3d4", "bd-a1b2");
storage.add_dependency(&dep).unwrap();
let export_stats = export_to_jsonl(&storage, &jsonl_path).unwrap();
assert_eq!(export_stats.issues, 2);
assert_eq!(export_stats.dependencies, 1);
let db_path2 = temp.path().join("test2.db");
let storage2 = SqliteStorage::open(&db_path2).unwrap();
let import_stats = import_from_jsonl(&storage2, &jsonl_path).unwrap();
assert_eq!(import_stats.issues_imported, 2);
let imported1 = storage2.get_issue("bd-a1b2").unwrap().unwrap();
assert_eq!(imported1.title, "Task 1");
let imported2 = storage2.get_issue("bd-c3d4").unwrap().unwrap();
assert_eq!(imported2.title, "Task 2");
let deps = storage2.get_dependencies("bd-c3d4").unwrap();
assert_eq!(deps.len(), 1);
assert_eq!(deps[0].depends_on_id, "bd-a1b2");
}
}