mod common;
use beads_rust::model::{DependencyType, Issue, Priority, Status};
use beads_rust::storage::SqliteStorage;
use beads_rust::sync::{
ExportConfig, ImportConfig, export_to_jsonl, finalize_export, import_from_jsonl,
read_issues_from_jsonl,
};
use chrono::{Duration, Utc};
use common::fixtures;
use std::fs;
use tempfile::TempDir;
fn issue_with_id(id: &str, title: &str) -> Issue {
let mut issue = fixtures::issue(title);
issue.id = id.to_string();
issue
}
#[test]
fn export_import_roundtrip_preserves_relationships() {
let mut storage = SqliteStorage::open_memory().unwrap();
let mut alpha = fixtures::issue("Alpha");
alpha.created_at = Utc::now() - Duration::hours(1);
alpha.updated_at = alpha.created_at;
let mut beta = fixtures::issue("Beta");
beta.created_at = alpha.created_at;
beta.updated_at = alpha.created_at;
alpha.priority = Priority::HIGH;
alpha.external_ref = Some("ext-1".to_string());
beta.status = Status::InProgress;
storage.create_issue(&alpha, "tester").unwrap();
storage.create_issue(&beta, "tester").unwrap();
storage
.add_dependency(
&beta.id,
&alpha.id,
DependencyType::Blocks.as_str(),
"tester",
)
.unwrap();
storage.add_label(&alpha.id, "alpha", "tester").unwrap();
storage
.add_comment(&alpha.id, "tester", "first comment")
.unwrap();
let temp = TempDir::new().unwrap();
let path = temp.path().join("issues.jsonl");
let export = export_to_jsonl(&storage, &path, &ExportConfig::default()).unwrap();
assert_eq!(export.exported_count, 2);
let mut imported = SqliteStorage::open_memory().unwrap();
let import = import_from_jsonl(
&mut imported,
&path,
&ImportConfig::default(),
Some("test-"),
)
.unwrap();
assert_eq!(import.imported_count, 2);
let imported_alpha = imported.get_issue(&alpha.id).unwrap().unwrap();
assert_eq!(imported_alpha.title, alpha.title);
assert_eq!(imported_alpha.external_ref, Some("ext-1".to_string()));
let labels = imported.get_labels(&alpha.id).unwrap();
assert_eq!(labels, vec!["alpha".to_string()]);
let deps = imported.get_dependencies(&beta.id).unwrap();
assert_eq!(deps, vec![alpha.id.clone()]);
let comments = imported.get_comments(&alpha.id).unwrap();
assert_eq!(comments.len(), 1);
assert_eq!(comments[0].body, "first comment");
}
#[test]
fn import_reads_multiple_jsonl_lines_without_buffer_accumulation() {
let temp = TempDir::new().unwrap();
let path = temp.path().join("issues.jsonl");
let issue_a = issue_with_id("test-a", "First");
let issue_b = issue_with_id("test-b", "Second");
let json_a = serde_json::to_string(&issue_a).unwrap();
let json_b = serde_json::to_string(&issue_b).unwrap();
fs::write(&path, format!("{json_a}\n{json_b}\n")).unwrap();
let mut storage = SqliteStorage::open_memory().unwrap();
let import =
import_from_jsonl(&mut storage, &path, &ImportConfig::default(), Some("test-")).unwrap();
assert_eq!(import.imported_count, 2);
assert!(storage.get_issue("test-a").unwrap().is_some());
assert!(storage.get_issue("test-b").unwrap().is_some());
}
#[test]
fn export_sorts_by_id() {
let mut storage = SqliteStorage::open_memory().unwrap();
let issue_b = issue_with_id("test-b", "B");
let issue_a = issue_with_id("test-a", "A");
storage.create_issue(&issue_b, "tester").unwrap();
storage.create_issue(&issue_a, "tester").unwrap();
let temp = TempDir::new().unwrap();
let path = temp.path().join("issues.jsonl");
export_to_jsonl(&storage, &path, &ExportConfig::default()).unwrap();
let issues = read_issues_from_jsonl(&path).unwrap();
let ids: Vec<&str> = issues.iter().map(|issue| issue.id.as_str()).collect();
assert_eq!(ids, vec!["test-a", "test-b"]);
}
#[test]
fn import_rejects_malformed_json() {
let temp = TempDir::new().unwrap();
let path = temp.path().join("issues.jsonl");
fs::write(&path, "not json\n").unwrap();
let mut storage = SqliteStorage::open_memory().unwrap();
let err = import_from_jsonl(&mut storage, &path, &ImportConfig::default(), Some("test-"))
.unwrap_err();
assert!(err.to_string().contains("Invalid JSON"));
}
#[test]
fn import_rejects_prefix_mismatch() {
let temp = TempDir::new().unwrap();
let path = temp.path().join("issues.jsonl");
let issue = issue_with_id("xx-001", "Mismatch");
let json = serde_json::to_string(&issue).unwrap();
fs::write(&path, format!("{json}\n")).unwrap();
let mut storage = SqliteStorage::open_memory().unwrap();
let err = import_from_jsonl(&mut storage, &path, &ImportConfig::default(), Some("test-"))
.unwrap_err();
assert!(err.to_string().contains("Prefix mismatch"));
}
#[test]
fn import_sets_closed_at_when_missing() {
let temp = TempDir::new().unwrap();
let path = temp.path().join("issues.jsonl");
let mut issue = issue_with_id("test-closed", "Closed");
issue.status = Status::Closed;
issue.created_at = Utc::now() - Duration::hours(2);
issue.updated_at = Utc::now() - Duration::hours(1);
issue.closed_at = None;
let json = serde_json::to_string(&issue).unwrap();
fs::write(&path, format!("{json}\n")).unwrap();
let mut storage = SqliteStorage::open_memory().unwrap();
import_from_jsonl(&mut storage, &path, &ImportConfig::default(), Some("test-")).unwrap();
let imported = storage.get_issue(&issue.id).unwrap().unwrap();
assert_eq!(imported.closed_at, Some(issue.updated_at));
}
#[test]
fn export_import_roundtrip_keeps_optional_text_fields_integrity_safe() {
let mut storage = SqliteStorage::open_memory().unwrap();
let issue = issue_with_id("test-opttext", "Optional text fields");
storage.create_issue(&issue, "tester").unwrap();
let temp = TempDir::new().unwrap();
let path = temp.path().join("issues.jsonl");
export_to_jsonl(&storage, &path, &ExportConfig::default()).unwrap();
let db_path = temp.path().join("import.db");
let mut imported = SqliteStorage::open(&db_path).unwrap();
import_from_jsonl(
&mut imported,
&path,
&ImportConfig::default(),
Some("test-"),
)
.unwrap();
let imported_issue = imported.get_issue(&issue.id).unwrap().unwrap();
assert_eq!(imported_issue.design, None);
assert_eq!(imported_issue.acceptance_criteria, None);
}
#[test]
fn import_rejects_conflict_markers() {
let temp = TempDir::new().unwrap();
let path = temp.path().join("issues.jsonl");
fs::write(&path, "<<<<<<< HEAD\n").unwrap();
let mut storage = SqliteStorage::open_memory().unwrap();
let err = import_from_jsonl(&mut storage, &path, &ImportConfig::default(), Some("test-"))
.unwrap_err();
assert!(err.to_string().contains("Merge conflict markers detected"));
}
#[test]
fn export_empty_db_guard_blocks_overwrite() {
let storage = SqliteStorage::open_memory().unwrap();
let temp = TempDir::new().unwrap();
let path = temp.path().join("issues.jsonl");
let existing = issue_with_id("test-existing", "Existing issue");
let json = serde_json::to_string(&existing).unwrap();
fs::write(&path, format!("{json}\n")).unwrap();
let config = ExportConfig {
force: false,
..Default::default()
};
let result = export_to_jsonl(&storage, &path, &config);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("empty database"),
"Expected 'empty database' error, got: {err}"
);
}
#[test]
fn export_empty_db_guard_bypassed_with_force() {
let storage = SqliteStorage::open_memory().unwrap();
let temp = TempDir::new().unwrap();
let path = temp.path().join("issues.jsonl");
let existing = issue_with_id("test-existing", "Existing issue");
let json = serde_json::to_string(&existing).unwrap();
fs::write(&path, format!("{json}\n")).unwrap();
let config = ExportConfig {
force: true,
..Default::default()
};
let result = export_to_jsonl(&storage, &path, &config);
assert!(result.is_ok());
let export = result.unwrap();
assert_eq!(export.exported_count, 0);
}
#[test]
fn import_tombstone_protection_prevents_resurrection() {
let mut storage = SqliteStorage::open_memory().unwrap();
let temp = TempDir::new().unwrap();
let path = temp.path().join("issues.jsonl");
let mut tombstone = issue_with_id("test-tomb", "Tombstone issue");
tombstone.status = Status::Tombstone;
tombstone.deleted_at = Some(Utc::now());
storage.create_issue(&tombstone, "tester").unwrap();
let mut incoming = issue_with_id("test-tomb", "Resurrected issue");
incoming.status = Status::Open;
incoming.updated_at = Utc::now() + Duration::hours(1);
let json = serde_json::to_string(&incoming).unwrap();
fs::write(&path, format!("{json}\n")).unwrap();
let result =
import_from_jsonl(&mut storage, &path, &ImportConfig::default(), Some("test-")).unwrap();
assert_eq!(result.tombstone_skipped, 1);
let still_tombstone = storage.get_issue("test-tomb").unwrap().unwrap();
assert_eq!(still_tombstone.status, Status::Tombstone);
}
#[test]
fn import_collision_by_id_updates_when_newer() {
let mut storage = SqliteStorage::open_memory().unwrap();
let temp = TempDir::new().unwrap();
let path = temp.path().join("issues.jsonl");
let mut existing = issue_with_id("test-001", "Old title");
existing.updated_at = Utc::now() - Duration::hours(1);
storage.create_issue(&existing, "tester").unwrap();
let mut incoming = issue_with_id("test-001", "New title");
incoming.updated_at = Utc::now();
let json = serde_json::to_string(&incoming).unwrap();
fs::write(&path, format!("{json}\n")).unwrap();
let result =
import_from_jsonl(&mut storage, &path, &ImportConfig::default(), Some("test-")).unwrap();
assert_eq!(result.imported_count, 1);
let updated = storage.get_issue("test-001").unwrap().unwrap();
assert_eq!(updated.title, "New title");
}
#[test]
fn import_collision_by_id_skips_when_older() {
let mut storage = SqliteStorage::open_memory().unwrap();
let temp = TempDir::new().unwrap();
let path = temp.path().join("issues.jsonl");
let mut existing = issue_with_id("test-001", "Newer title");
existing.created_at = Utc::now() - Duration::hours(2);
existing.updated_at = Utc::now();
storage.create_issue(&existing, "tester").unwrap();
let mut incoming = issue_with_id("test-001", "Older title");
incoming.created_at = existing.created_at;
incoming.updated_at = Utc::now() - Duration::hours(1);
let json = serde_json::to_string(&incoming).unwrap();
fs::write(&path, format!("{json}\n")).unwrap();
let result =
import_from_jsonl(&mut storage, &path, &ImportConfig::default(), Some("test-")).unwrap();
assert_eq!(result.skipped_count, 1);
let unchanged = storage.get_issue("test-001").unwrap().unwrap();
assert_eq!(unchanged.title, "Newer title");
}
#[test]
fn import_collision_by_external_ref() {
let mut storage = SqliteStorage::open_memory().unwrap();
let temp = TempDir::new().unwrap();
let path = temp.path().join("issues.jsonl");
let mut existing = issue_with_id("test-001", "Existing");
existing.external_ref = Some("JIRA-123".to_string());
existing.updated_at = Utc::now() - Duration::hours(1);
storage.create_issue(&existing, "tester").unwrap();
let mut incoming = issue_with_id("test-001", "Incoming updated");
incoming.external_ref = Some("JIRA-123".to_string());
incoming.updated_at = Utc::now();
let json = serde_json::to_string(&incoming).unwrap();
fs::write(&path, format!("{json}\n")).unwrap();
let result =
import_from_jsonl(&mut storage, &path, &ImportConfig::default(), Some("test-")).unwrap();
assert_eq!(result.imported_count, 1);
let updated = storage.get_issue("test-001").unwrap().unwrap();
assert_eq!(updated.title, "Incoming updated");
}
#[test]
fn import_skips_ephemeral_issues() {
let mut storage = SqliteStorage::open_memory().unwrap();
let temp = TempDir::new().unwrap();
let path = temp.path().join("issues.jsonl");
let mut ephemeral = issue_with_id("test-eph", "Ephemeral issue");
ephemeral.ephemeral = true;
let json = serde_json::to_string(&ephemeral).unwrap();
fs::write(&path, format!("{json}\n")).unwrap();
let result =
import_from_jsonl(&mut storage, &path, &ImportConfig::default(), Some("test-")).unwrap();
assert_eq!(result.skipped_count, 1);
assert_eq!(result.imported_count, 0);
assert!(storage.get_issue("test-eph").unwrap().is_none());
}
#[test]
fn import_skip_prefix_validation_allows_mismatch() {
let mut storage = SqliteStorage::open_memory().unwrap();
let temp = TempDir::new().unwrap();
let path = temp.path().join("issues.jsonl");
let issue = issue_with_id("other-001", "Different prefix");
let json = serde_json::to_string(&issue).unwrap();
fs::write(&path, format!("{json}\n")).unwrap();
let config = ImportConfig {
skip_prefix_validation: true,
..Default::default()
};
let result = import_from_jsonl(&mut storage, &path, &config, Some("test-")).unwrap();
assert_eq!(result.imported_count, 1);
}
#[test]
fn export_produces_deterministic_content_hash() {
let mut storage = SqliteStorage::open_memory().unwrap();
let temp = TempDir::new().unwrap();
let issue = issue_with_id("test-det", "Deterministic test");
storage.create_issue(&issue, "tester").unwrap();
let config = ExportConfig::default();
let path1 = temp.path().join("export1.jsonl");
let path2 = temp.path().join("export2.jsonl");
let result1 = export_to_jsonl(&storage, &path1, &config).unwrap();
let result2 = export_to_jsonl(&storage, &path2, &config).unwrap();
assert_eq!(result1.content_hash, result2.content_hash);
assert!(!result1.content_hash.is_empty());
}
#[test]
fn import_handles_empty_lines_gracefully() {
let mut storage = SqliteStorage::open_memory().unwrap();
let temp = TempDir::new().unwrap();
let path = temp.path().join("issues.jsonl");
let issue = issue_with_id("test-001", "Valid issue");
let json = serde_json::to_string(&issue).unwrap();
let content = format!("\n\n{json}\n\n\n");
fs::write(&path, content).unwrap();
let result =
import_from_jsonl(&mut storage, &path, &ImportConfig::default(), Some("test-")).unwrap();
assert_eq!(result.imported_count, 1);
}
#[test]
fn import_creates_new_issues() {
let mut storage = SqliteStorage::open_memory().unwrap();
let temp = TempDir::new().unwrap();
let path = temp.path().join("issues.jsonl");
let issue = issue_with_id("test-new", "Brand new issue");
let json = serde_json::to_string(&issue).unwrap();
fs::write(&path, format!("{json}\n")).unwrap();
let result =
import_from_jsonl(&mut storage, &path, &ImportConfig::default(), Some("test-")).unwrap();
assert_eq!(result.imported_count, 1);
assert_eq!(result.skipped_count, 0);
let created = storage.get_issue("test-new").unwrap().unwrap();
assert_eq!(created.title, "Brand new issue");
}
#[test]
fn import_repopulates_export_hashes() {
let mut storage = SqliteStorage::open_memory().unwrap();
let temp = TempDir::new().unwrap();
let path = temp.path().join("issues.jsonl");
let issue = issue_with_id("test-hash", "Hash Test");
storage.create_issue(&issue, "tester").unwrap();
let export_result = export_to_jsonl(&storage, &path, &ExportConfig::default()).unwrap();
finalize_export(
&mut storage,
&export_result,
Some(&export_result.issue_hashes),
&path,
)
.unwrap();
let original_hash = export_result.issue_hashes[0].1.clone();
assert_eq!(
storage.get_export_hash("test-hash").unwrap().unwrap().0,
original_hash
);
storage.clear_all_export_hashes().unwrap();
assert!(storage.get_export_hash("test-hash").unwrap().is_none());
import_from_jsonl(&mut storage, &path, &ImportConfig::default(), Some("test-")).unwrap();
let (restored_hash, _) = storage.get_export_hash("test-hash").unwrap().unwrap();
assert_eq!(restored_hash, original_hash);
}
#[test]
fn import_deduplicates_export_hash_rebuild_when_multiple_records_target_same_issue() {
let mut storage = SqliteStorage::open_memory().unwrap();
let temp = TempDir::new().unwrap();
let path = temp.path().join("issues.jsonl");
let base_time = Utc::now() - Duration::hours(2);
let mut existing = issue_with_id("test-existing", "Existing");
existing.created_at = base_time;
existing.updated_at = base_time;
existing.external_ref = Some("EXT-1".to_string());
storage.create_issue(&existing, "tester").unwrap();
storage
.set_export_hashes(&[("test-existing".to_string(), "stale-hash".to_string())])
.unwrap();
let mut by_external_ref = issue_with_id("test-remap", "Intermediate update");
by_external_ref.created_at = base_time + Duration::minutes(5);
by_external_ref.updated_at = base_time + Duration::minutes(10);
by_external_ref.external_ref = Some("EXT-1".to_string());
let mut by_id = issue_with_id("test-existing", "Final update");
by_id.created_at = base_time + Duration::minutes(15);
by_id.updated_at = base_time + Duration::minutes(20);
let json = format!(
"{}\n{}\n",
serde_json::to_string(&by_external_ref).unwrap(),
serde_json::to_string(&by_id).unwrap()
);
fs::write(&path, json).unwrap();
import_from_jsonl(&mut storage, &path, &ImportConfig::default(), Some("test-")).unwrap();
assert!(
storage.get_issue("test-remap").unwrap().is_none(),
"collision-matched issue should be merged into the existing record"
);
let imported = storage.get_issue("test-existing").unwrap().unwrap();
assert_eq!(imported.title, "Final update");
let (stored_hash, _) = storage.get_export_hash("test-existing").unwrap().unwrap();
assert_eq!(Some(stored_hash.as_str()), imported.content_hash.as_deref());
}
#[test]
fn import_rejects_invalid_id_format() {
let temp = TempDir::new().unwrap();
let path = temp.path().join("issues.jsonl");
let issue = issue_with_id("test-INVALID", "Invalid ID");
let json = serde_json::to_string(&issue).unwrap();
fs::write(&path, format!("{json}\n")).unwrap();
let mut storage = SqliteStorage::open_memory().unwrap();
let result = import_from_jsonl(&mut storage, &path, &ImportConfig::default(), Some("test-"));
assert!(result.is_err(), "Import should fail for invalid IDs");
let err = result.unwrap_err().to_string();
assert!(
err.contains("Validation failed"),
"Expected validation error, got: {err}"
);
}