pub mod diff;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
pub const CURRENT_SCHEMA_VERSION: i32 = 3;
pub const EXPORT_VERSION: &str = "1.0.0";
pub const EXPORTED_TABLES: &[&str] = &[
"tasks",
"dependencies",
"attachments",
"task_tags",
"task_needed_tags",
"task_wanted_tags",
"task_sequence",
];
pub const EXCLUDED_TABLES: &[&str] = &[
"workers",
"file_locks",
"claim_sequence",
];
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Snapshot {
pub schema_version: i32,
pub export_version: String,
pub exported_at: String,
pub exported_by: String,
pub tables: BTreeMap<String, Vec<Value>>,
}
impl Snapshot {
pub fn new() -> Self {
Self {
schema_version: CURRENT_SCHEMA_VERSION,
export_version: EXPORT_VERSION.to_string(),
exported_at: chrono::Utc::now().to_rfc3339(),
exported_by: format!("task-graph-mcp v{}", env!("CARGO_PKG_VERSION")),
tables: BTreeMap::new(),
}
}
pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(json)
}
pub fn from_file(path: &std::path::Path) -> anyhow::Result<Self> {
use std::fs::File;
use std::io::{BufReader, Read};
let file = File::open(path)?;
let mut reader = BufReader::new(file);
let mut magic = [0u8; 2];
reader.read_exact(&mut magic)?;
drop(reader);
let file = File::open(path)?;
let reader = BufReader::new(file);
if magic == [0x1f, 0x8b] {
let decoder = flate2::read::GzDecoder::new(reader);
let snapshot: Snapshot = serde_json::from_reader(decoder)?;
Ok(snapshot)
} else {
let snapshot: Snapshot = serde_json::from_reader(reader)?;
Ok(snapshot)
}
}
pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
pub fn get_table(&self, name: &str) -> Option<&Vec<Value>> {
self.tables.get(name)
}
pub fn is_schema_compatible(&self) -> bool {
self.schema_version == CURRENT_SCHEMA_VERSION
}
pub fn table_names(&self) -> Vec<&str> {
self.tables.keys().map(|s| s.as_str()).collect()
}
}
impl Default for Snapshot {
fn default() -> Self {
Self::new()
}
}
pub fn get_table_ordering(table: &str) -> &'static str {
match table {
"tasks" => "ORDER BY id",
"dependencies" => "ORDER BY from_task_id, to_task_id, dep_type",
"attachments" => "ORDER BY task_id, attachment_type, sequence",
"task_tags" => "ORDER BY task_id, tag",
"task_needed_tags" => "ORDER BY task_id, tag",
"task_wanted_tags" => "ORDER BY task_id, tag",
"task_sequence" => "ORDER BY task_id, id",
_ => "ORDER BY rowid",
}
}
pub fn get_table_primary_key(table: &str) -> &'static [&'static str] {
match table {
"tasks" => &["id"],
"dependencies" => &["from_task_id", "to_task_id", "dep_type"],
"attachments" => &["task_id", "attachment_type", "sequence"],
"task_tags" => &["task_id", "tag"],
"task_needed_tags" => &["task_id", "tag"],
"task_wanted_tags" => &["task_id", "tag"],
"task_sequence" => &["id"],
_ => &["rowid"],
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_snapshot_new() {
let snapshot = Snapshot::new();
assert_eq!(snapshot.schema_version, CURRENT_SCHEMA_VERSION);
assert_eq!(snapshot.export_version, EXPORT_VERSION);
assert!(snapshot.tables.is_empty());
}
#[test]
fn test_snapshot_json_roundtrip() {
let mut snapshot = Snapshot::new();
snapshot.tables.insert(
"tasks".to_string(),
vec![serde_json::json!({
"id": "test-1",
"title": "Test Task"
})],
);
let json = snapshot.to_json_pretty().unwrap();
let loaded = Snapshot::from_json(&json).unwrap();
assert_eq!(loaded.schema_version, snapshot.schema_version);
assert_eq!(loaded.tables.len(), 1);
}
#[test]
fn test_table_ordering() {
assert_eq!(get_table_ordering("tasks"), "ORDER BY id");
assert_eq!(
get_table_ordering("dependencies"),
"ORDER BY from_task_id, to_task_id, dep_type"
);
}
#[test]
fn test_table_primary_key() {
assert_eq!(get_table_primary_key("tasks"), &["id"]);
assert_eq!(
get_table_primary_key("dependencies"),
&["from_task_id", "to_task_id", "dep_type"]
);
assert_eq!(
get_table_primary_key("attachments"),
&["task_id", "attachment_type", "sequence"]
);
}
}