rusty-beads 0.1.0

Git-backed graph issue tracker for AI coding agents - a Rust implementation with context store, dependency tracking, and semantic compaction
Documentation
//! JSONL import/export for Beads.
//!
//! Provides bidirectional synchronization between SQLite and JSONL files
//! for git-based version control of issues.

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};

/// Export statistics.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ExportStats {
    /// Number of issues exported.
    pub issues: usize,
    /// Number of dependencies exported.
    pub dependencies: usize,
    /// Errors encountered.
    pub errors: Vec<String>,
}

/// Import statistics.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ImportStats {
    /// Number of issues imported.
    pub issues_imported: usize,
    /// Number of issues updated.
    pub issues_updated: usize,
    /// Number of issues skipped.
    pub issues_skipped: usize,
    /// Errors encountered.
    pub errors: Vec<String>,
}

/// A JSONL record for an issue with embedded relationships.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IssueRecord {
    /// The issue data.
    #[serde(flatten)]
    pub issue: Issue,
    /// Dependencies (issues this one depends on).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub dependencies: Vec<DependencyRecord>,
}

/// A dependency record for JSONL.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DependencyRecord {
    /// The issue being depended upon.
    pub depends_on_id: String,
    /// The type of dependency.
    #[serde(rename = "type")]
    pub dep_type: String,
    /// Optional metadata.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub metadata: Option<String>,
}

/// Export issues to a JSONL file.
pub fn export_to_jsonl(storage: &SqliteStorage, path: impl AsRef<Path>) -> Result<ExportStats> {
    let path = path.as_ref();
    let mut stats = ExportStats::default();

    // Get all issues including tombstones for sync
    let filter = crate::types::IssueFilter {
        include_tombstones: true,
        ..Default::default()
    };
    let issues = storage.search_issues(&filter)?;

    // Create temp file for atomic write
    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 {
        // Get dependencies for this issue
        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);

    // Atomic rename
    std::fs::rename(&temp_path, path)
        .with_context(|| format!("Failed to rename temp file to: {:?}", path))?;

    // Set file permissions
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
    }

    // Clear dirty flags
    let dirty_ids = storage.get_dirty_issues()?;
    if !dirty_ids.is_empty() {
        storage.clear_dirty(&dirty_ids)?;
    }

    Ok(stats)
}

/// Import issues from a JSONL file.
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);

    // First pass: collect all records
    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));
            }
        }
    }

    // Build content hash map for detecting renames
    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());
        }
    }

    // Second pass: import issues
    for record in records {
        let issue_id = record.issue.id.clone();

        // Check if issue exists
        match storage.get_issue(&issue_id) {
            Ok(Some(existing)) => {
                // Check if update is newer
                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) => {
                // Check for rename via content hash
                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 {
                            // This is a rename - skip for now (complex)
                            found_rename = true;
                            stats.issues_skipped += 1;
                        }
                    }
                }

                if !found_rename {
                    // New issue
                    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;
            }
        }

        // Import dependencies
        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,
            };

            // Ignore errors for dependencies (may reference not-yet-imported issues)
            let _ = storage.add_dependency(&dep);
        }
    }

    Ok(stats)
}

/// Sync JSONL file with the database.
///
/// Imports if JSONL is newer, exports if database has dirty issues.
pub fn sync_jsonl(storage: &SqliteStorage, path: impl AsRef<Path>) -> Result<()> {
    let path = path.as_ref();

    // Check if we have dirty issues
    let dirty = storage.get_dirty_issues()?;

    if path.exists() && dirty.is_empty() {
        // Import from JSONL
        import_from_jsonl(storage, path)?;
    } else if !dirty.is_empty() {
        // Export to JSONL
        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");

        // Create storage and add issues
        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();

        // Add dependency
        let dep = Dependency::blocks("bd-c3d4", "bd-a1b2");
        storage.add_dependency(&dep).unwrap();

        // Export
        let export_stats = export_to_jsonl(&storage, &jsonl_path).unwrap();
        assert_eq!(export_stats.issues, 2);
        assert_eq!(export_stats.dependencies, 1);

        // Create new storage and import
        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);

        // Verify issues
        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");

        // Verify dependency
        let deps = storage2.get_dependencies("bd-c3d4").unwrap();
        assert_eq!(deps.len(), 1);
        assert_eq!(deps[0].depends_on_id, "bd-a1b2");
    }
}