use anyhow::{anyhow, Context, Result};
use chrono::{DateTime, Utc};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use crate::format::markdown_to_issue;
use crate::types::Issue;
#[derive(Debug, Clone)]
pub struct MarkdownIssue {
pub issue: Issue,
pub mtime: SystemTime,
#[allow(dead_code)]
pub path: PathBuf,
}
#[derive(Debug, Clone)]
pub struct JsonlIssue {
pub issue: Issue,
pub updated_at: DateTime<Utc>,
}
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IssueAction {
MarkdownOnly,
JsonlOnly,
MarkdownNewer,
JsonlNewer,
NoChange,
Conflict,
}
#[derive(Debug, Default)]
pub struct SyncPlan {
pub markdown_only: Vec<String>,
pub jsonl_only: Vec<String>,
pub markdown_newer: Vec<String>,
pub jsonl_newer: Vec<String>,
pub no_change: Vec<String>,
pub conflicts: Vec<String>,
}
impl SyncPlan {
pub fn is_empty(&self) -> bool {
self.markdown_only.is_empty()
&& self.jsonl_only.is_empty()
&& self.markdown_newer.is_empty()
&& self.jsonl_newer.is_empty()
&& self.conflicts.is_empty()
}
#[allow(dead_code)]
pub fn total_changes(&self) -> usize {
self.markdown_only.len()
+ self.jsonl_only.len()
+ self.markdown_newer.len()
+ self.jsonl_newer.len()
}
}
#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
pub struct SyncReport {
pub created_in_jsonl: usize,
pub created_in_markdown: usize,
pub updated_jsonl: usize,
pub updated_markdown: usize,
pub skipped_conflicts: usize,
pub errors: Vec<String>,
}
impl SyncReport {
pub fn total_changes(&self) -> usize {
self.created_in_jsonl
+ self.created_in_markdown
+ self.updated_jsonl
+ self.updated_markdown
}
}
pub fn load_markdown_issues(beads_dir: &Path) -> Result<HashMap<String, MarkdownIssue>> {
let issues_dir = beads_dir.join("issues");
if !issues_dir.exists() {
return Ok(HashMap::new());
}
let mut result = HashMap::new();
for entry in fs::read_dir(&issues_dir).context("Failed to read issues directory")? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let metadata = fs::metadata(&path)
.with_context(|| format!("Failed to get metadata for {}", path.display()))?;
let mtime = metadata
.modified()
.with_context(|| format!("Failed to get mtime for {}", path.display()))?;
let issue_id = path
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| anyhow!("Invalid filename: {}", path.display()))?;
let content = fs::read_to_string(&path)
.with_context(|| format!("Failed to read {}", path.display()))?;
let issue = markdown_to_issue(issue_id, &content)
.with_context(|| format!("Failed to parse {}", path.display()))?;
result.insert(issue.id.clone(), MarkdownIssue { issue, mtime, path });
}
Ok(result)
}
pub fn load_jsonl_issues(jsonl_path: &Path) -> Result<HashMap<String, JsonlIssue>> {
if !jsonl_path.exists() {
return Ok(HashMap::new());
}
let content = fs::read_to_string(jsonl_path)
.with_context(|| format!("Failed to read {}", jsonl_path.display()))?;
let mut result = HashMap::new();
for (line_num, line) in content.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let issue: Issue = serde_json::from_str(line).with_context(|| {
format!(
"Failed to parse line {} in {}",
line_num + 1,
jsonl_path.display()
)
})?;
result.insert(
issue.id.clone(),
JsonlIssue {
updated_at: issue.updated_at,
issue,
},
);
}
Ok(result)
}
pub struct SyncEngine {
tolerance_ms: u64,
}
impl SyncEngine {
pub fn new() -> Self {
Self { tolerance_ms: 1000 }
}
#[allow(dead_code)]
pub fn with_tolerance_ms(tolerance_ms: u64) -> Self {
Self { tolerance_ms }
}
fn compare_timestamps(
&self,
mtime: SystemTime,
jsonl_time: DateTime<Utc>,
) -> std::cmp::Ordering {
let jsonl_systime: SystemTime = jsonl_time.into();
let diff = match mtime.duration_since(jsonl_systime) {
Ok(d) => d.as_millis() as i64,
Err(e) => -(e.duration().as_millis() as i64),
};
let tolerance = self.tolerance_ms as i64;
if diff > tolerance {
std::cmp::Ordering::Greater } else if diff < -tolerance {
std::cmp::Ordering::Less } else {
std::cmp::Ordering::Equal }
}
pub fn analyze(
&self,
markdown_issues: HashMap<String, MarkdownIssue>,
jsonl_issues: HashMap<String, JsonlIssue>,
) -> Result<SyncPlan> {
let mut plan = SyncPlan::default();
let all_ids: std::collections::HashSet<String> = markdown_issues
.keys()
.chain(jsonl_issues.keys())
.cloned()
.collect();
for id in all_ids {
let md = markdown_issues.get(&id);
let json = jsonl_issues.get(&id);
match (md, json) {
(Some(_), None) => {
plan.markdown_only.push(id.clone());
}
(None, Some(_)) => {
plan.jsonl_only.push(id.clone());
}
(Some(md_issue), Some(json_issue)) => {
match self.compare_timestamps(md_issue.mtime, json_issue.updated_at) {
std::cmp::Ordering::Greater => {
plan.markdown_newer.push(id.clone());
}
std::cmp::Ordering::Less => {
plan.jsonl_newer.push(id.clone());
}
std::cmp::Ordering::Equal => {
plan.no_change.push(id.clone());
}
}
}
(None, None) => unreachable!("ID came from one of the maps"),
}
}
Ok(plan)
}
pub fn apply(
&self,
plan: &SyncPlan,
markdown_issues: &HashMap<String, MarkdownIssue>,
jsonl_issues: &HashMap<String, JsonlIssue>,
beads_dir: &Path,
dry_run: bool,
) -> Result<SyncReport> {
let mut report = SyncReport::default();
let issues_dir = beads_dir.join("issues");
let jsonl_path = beads_dir.join("issues.jsonl");
if !dry_run && !issues_dir.exists() {
fs::create_dir_all(&issues_dir).context("Failed to create issues directory")?;
}
for id in &plan.jsonl_only {
if let Some(json_issue) = jsonl_issues.get(id) {
if dry_run {
println!("[DRY RUN] Would create markdown: {}.md", id);
} else {
match self.write_markdown_issue(
&issues_dir,
&json_issue.issue,
json_issue.updated_at,
) {
Ok(_) => report.created_in_markdown += 1,
Err(e) => report
.errors
.push(format!("Failed to create {}.md: {}", id, e)),
}
}
}
}
for id in &plan.jsonl_newer {
if let Some(json_issue) = jsonl_issues.get(id) {
if dry_run {
println!(
"[DRY RUN] Would update markdown: {}.md (JSONL is newer)",
id
);
} else {
match self.write_markdown_issue(
&issues_dir,
&json_issue.issue,
json_issue.updated_at,
) {
Ok(_) => report.updated_markdown += 1,
Err(e) => report
.errors
.push(format!("Failed to update {}.md: {}", id, e)),
}
}
}
}
for id in &plan.markdown_only {
if let Some(md_issue) = markdown_issues.get(id) {
if dry_run {
println!("[DRY RUN] Would create JSONL entry: {}", id);
} else {
match self.append_jsonl_issue(&jsonl_path, &md_issue.issue) {
Ok(_) => report.created_in_jsonl += 1,
Err(e) => report
.errors
.push(format!("Failed to create JSONL entry {}: {}", id, e)),
}
}
}
}
for id in &plan.markdown_newer {
if let Some(md_issue) = markdown_issues.get(id) {
if dry_run {
println!(
"[DRY RUN] Would update JSONL entry: {} (markdown is newer)",
id
);
} else {
match self.update_jsonl_issue(&jsonl_path, &md_issue.issue) {
Ok(_) => report.updated_jsonl += 1,
Err(e) => report
.errors
.push(format!("Failed to update JSONL entry {}: {}", id, e)),
}
}
}
}
for id in &plan.conflicts {
report.skipped_conflicts += 1;
if dry_run {
println!("[DRY RUN] Would skip conflict: {}", id);
} else {
report.errors.push(format!("Conflict skipped: {}", id));
}
}
Ok(report)
}
fn write_markdown_issue(
&self,
issues_dir: &Path,
issue: &Issue,
timestamp: DateTime<Utc>,
) -> Result<()> {
use crate::format::issue_to_markdown;
let path = issues_dir.join(format!("{}.md", issue.id));
let content = issue_to_markdown(issue)?;
fs::write(&path, content).with_context(|| format!("Failed to write {}", path.display()))?;
let systime: SystemTime = timestamp.into();
filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(systime))
.with_context(|| format!("Failed to set mtime for {}", path.display()))?;
Ok(())
}
fn append_jsonl_issue(&self, jsonl_path: &Path, issue: &Issue) -> Result<()> {
let json = serde_json::to_string(issue).context("Failed to serialize issue to JSON")?;
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(jsonl_path)
.with_context(|| format!("Failed to open {}", jsonl_path.display()))?;
use std::io::Write;
writeln!(file, "{}", json).context("Failed to write to JSONL file")?;
Ok(())
}
fn update_jsonl_issue(&self, jsonl_path: &Path, issue: &Issue) -> Result<()> {
let mut all_issues = if jsonl_path.exists() {
load_jsonl_issues(jsonl_path)?
} else {
HashMap::new()
};
all_issues.insert(
issue.id.clone(),
JsonlIssue {
issue: issue.clone(),
updated_at: issue.updated_at,
},
);
let mut lines: Vec<String> = all_issues
.values()
.map(|json_issue| serde_json::to_string(&json_issue.issue))
.collect::<Result<Vec<_>, _>>()
.context("Failed to serialize issues")?;
lines.sort(); let content = lines.join("\n") + "\n";
fs::write(jsonl_path, content)
.with_context(|| format!("Failed to write {}", jsonl_path.display()))?;
Ok(())
}
}
impl Default for SyncEngine {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
#[test]
fn test_compare_timestamps_equal() {
let engine = SyncEngine::new();
let now = Utc::now();
let systime: SystemTime = now.into();
assert_eq!(
engine.compare_timestamps(systime, now),
std::cmp::Ordering::Equal
);
}
#[test]
fn test_compare_timestamps_markdown_newer() {
let engine = SyncEngine::new();
let now = Utc::now();
let future = now + Duration::seconds(10);
let systime: SystemTime = future.into();
assert_eq!(
engine.compare_timestamps(systime, now),
std::cmp::Ordering::Greater
);
}
#[test]
fn test_compare_timestamps_jsonl_newer() {
let engine = SyncEngine::new();
let now = Utc::now();
let past = now - Duration::seconds(10);
let systime: SystemTime = past.into();
assert_eq!(
engine.compare_timestamps(systime, now),
std::cmp::Ordering::Less
);
}
#[test]
fn test_compare_timestamps_within_tolerance() {
let engine = SyncEngine::with_tolerance_ms(1000);
let now = Utc::now();
let slightly_future = now + Duration::milliseconds(500);
let systime: SystemTime = slightly_future.into();
assert_eq!(
engine.compare_timestamps(systime, now),
std::cmp::Ordering::Equal
);
}
}