Skip to main content

task_graph_mcp/export/
mod.rs

1//! Export/Import module for task-graph databases.
2//!
3//! This module provides structured export functionality enabling:
4//! - Version control of project task data
5//! - Database reconstruction from exports
6//! - Migration between schema versions
7//! - Human-readable diffs in git
8
9pub mod diff;
10
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13use std::collections::BTreeMap;
14
15/// Schema version of the current database.
16/// This should be updated when the database schema changes.
17pub const CURRENT_SCHEMA_VERSION: i32 = 3;
18
19/// Export format version (semver).
20pub const EXPORT_VERSION: &str = "1.0.0";
21
22/// Tables that are exported (project data).
23pub const EXPORTED_TABLES: &[&str] = &[
24    "tasks",
25    "dependencies",
26    "attachments",
27    "task_tags",
28    "task_needed_tags",
29    "task_wanted_tags",
30    "task_sequence",
31];
32
33/// Tables excluded from export (ephemeral/runtime).
34pub const EXCLUDED_TABLES: &[&str] = &[
35    "workers",
36    "file_locks",
37    "claim_sequence",
38    // FTS virtual tables are also excluded (they end with _fts*)
39];
40
41/// A structured export snapshot of the task-graph database.
42///
43/// This is a flexible format that can load exports created by either
44/// the Database::export_tables (strongly-typed) or any JSON conforming
45/// to the export spec. The `tables` field uses generic JSON values
46/// to support comparison across schema versions.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct Snapshot {
49    /// Database schema version (from task-graph internals)
50    /// Uses i32 for compatibility with existing exports.
51    pub schema_version: i32,
52
53    /// Export format version (semver)
54    pub export_version: String,
55
56    /// ISO 8601 timestamp of export
57    pub exported_at: String,
58
59    /// Tool name and version that created this export
60    pub exported_by: String,
61
62    /// Table data, keyed by table name.
63    /// Each table is an array of row objects with column names as keys.
64    pub tables: BTreeMap<String, Vec<Value>>,
65}
66
67impl Snapshot {
68    /// Create a new empty snapshot with current metadata.
69    pub fn new() -> Self {
70        Self {
71            schema_version: CURRENT_SCHEMA_VERSION,
72            export_version: EXPORT_VERSION.to_string(),
73            exported_at: chrono::Utc::now().to_rfc3339(),
74            exported_by: format!("task-graph-mcp v{}", env!("CARGO_PKG_VERSION")),
75            tables: BTreeMap::new(),
76        }
77    }
78
79    /// Load a snapshot from JSON data.
80    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
81        serde_json::from_str(json)
82    }
83
84    /// Load a snapshot from a file (supports both plain JSON and gzip).
85    pub fn from_file(path: &std::path::Path) -> anyhow::Result<Self> {
86        use std::fs::File;
87        use std::io::{BufReader, Read};
88
89        let file = File::open(path)?;
90        let mut reader = BufReader::new(file);
91
92        // Check for gzip magic bytes
93        let mut magic = [0u8; 2];
94        reader.read_exact(&mut magic)?;
95
96        // Reset to start
97        drop(reader);
98        let file = File::open(path)?;
99        let reader = BufReader::new(file);
100
101        if magic == [0x1f, 0x8b] {
102            // Gzip compressed
103            let decoder = flate2::read::GzDecoder::new(reader);
104            let snapshot: Snapshot = serde_json::from_reader(decoder)?;
105            Ok(snapshot)
106        } else {
107            // Plain JSON
108            let snapshot: Snapshot = serde_json::from_reader(reader)?;
109            Ok(snapshot)
110        }
111    }
112
113    /// Serialize to JSON with pretty formatting.
114    pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
115        serde_json::to_string_pretty(self)
116    }
117
118    /// Get rows for a specific table.
119    pub fn get_table(&self, name: &str) -> Option<&Vec<Value>> {
120        self.tables.get(name)
121    }
122
123    /// Check if this snapshot's schema is compatible with the current version.
124    pub fn is_schema_compatible(&self) -> bool {
125        self.schema_version == CURRENT_SCHEMA_VERSION
126    }
127
128    /// Get the list of tables present in this snapshot.
129    pub fn table_names(&self) -> Vec<&str> {
130        self.tables.keys().map(|s| s.as_str()).collect()
131    }
132}
133
134impl Default for Snapshot {
135    fn default() -> Self {
136        Self::new()
137    }
138}
139
140/// Row ordering specifications for each exported table.
141/// These ensure deterministic ordering for git diffs.
142pub fn get_table_ordering(table: &str) -> &'static str {
143    match table {
144        "tasks" => "ORDER BY id",
145        "dependencies" => "ORDER BY from_task_id, to_task_id, dep_type",
146        "attachments" => "ORDER BY task_id, attachment_type, sequence",
147        "task_tags" => "ORDER BY task_id, tag",
148        "task_needed_tags" => "ORDER BY task_id, tag",
149        "task_wanted_tags" => "ORDER BY task_id, tag",
150        "task_sequence" => "ORDER BY task_id, id",
151        _ => "ORDER BY rowid",
152    }
153}
154
155/// Get the primary key column(s) for a table.
156/// Used for identifying records during diff operations.
157pub fn get_table_primary_key(table: &str) -> &'static [&'static str] {
158    match table {
159        "tasks" => &["id"],
160        "dependencies" => &["from_task_id", "to_task_id", "dep_type"],
161        "attachments" => &["task_id", "attachment_type", "sequence"],
162        "task_tags" => &["task_id", "tag"],
163        "task_needed_tags" => &["task_id", "tag"],
164        "task_wanted_tags" => &["task_id", "tag"],
165        "task_sequence" => &["id"],
166        _ => &["rowid"],
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn test_snapshot_new() {
176        let snapshot = Snapshot::new();
177        assert_eq!(snapshot.schema_version, CURRENT_SCHEMA_VERSION);
178        assert_eq!(snapshot.export_version, EXPORT_VERSION);
179        assert!(snapshot.tables.is_empty());
180    }
181
182    #[test]
183    fn test_snapshot_json_roundtrip() {
184        let mut snapshot = Snapshot::new();
185        snapshot.tables.insert(
186            "tasks".to_string(),
187            vec![serde_json::json!({
188                "id": "test-1",
189                "title": "Test Task"
190            })],
191        );
192
193        let json = snapshot.to_json_pretty().unwrap();
194        let loaded = Snapshot::from_json(&json).unwrap();
195
196        assert_eq!(loaded.schema_version, snapshot.schema_version);
197        assert_eq!(loaded.tables.len(), 1);
198    }
199
200    #[test]
201    fn test_table_ordering() {
202        assert_eq!(get_table_ordering("tasks"), "ORDER BY id");
203        assert_eq!(
204            get_table_ordering("dependencies"),
205            "ORDER BY from_task_id, to_task_id, dep_type"
206        );
207    }
208
209    #[test]
210    fn test_table_primary_key() {
211        assert_eq!(get_table_primary_key("tasks"), &["id"]);
212        assert_eq!(
213            get_table_primary_key("dependencies"),
214            &["from_task_id", "to_task_id", "dep_type"]
215        );
216        assert_eq!(
217            get_table_primary_key("attachments"),
218            &["task_id", "attachment_type", "sequence"]
219        );
220    }
221}