use anyhow::{Context, Result, bail};
use base64::Engine;
use base64::engine::general_purpose::STANDARD as B64;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::infrastructure::model::{Priority, Status};
pub const BLOB_PREFIX: &str = "sara-task-v1:";
pub const BUNDLE_FORMAT: &str = "sara-task";
pub const BUNDLE_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Bundle {
pub format: String,
pub version: u32,
pub exported_at: DateTime<Utc>,
pub root: Uuid,
pub tasks: Vec<TaskEnvelope>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskEnvelope {
pub uuid: Uuid,
pub description: String,
pub project: String,
pub status: Status,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub priority: Option<Priority>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub due: Option<DateTime<Utc>>,
pub entry: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub estimate_mins: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recur: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub blocked_by: Vec<Uuid>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub annotations: Vec<AnnotationDto>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub checklist: Vec<ChecklistDto>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub links: Vec<LinkDto>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub files: Vec<FileDto>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnnotationDto {
pub text: String,
pub kind: String,
pub author: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChecklistDto {
pub text: String,
pub done: bool,
pub kind: String,
pub source: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub intent: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verify_cmd: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub result: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub done_commit: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LinkDto {
pub url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileDto {
pub path: String,
pub source: String,
}
impl Bundle {
pub fn encode(&self) -> Result<String> {
let json = serde_json::to_vec(self).context("serializing task bundle")?;
Ok(format!("{BLOB_PREFIX}{}", B64.encode(json)))
}
pub fn decode(blob: &str) -> Result<Bundle> {
let trimmed = blob.trim();
let body = trimmed.strip_prefix(BLOB_PREFIX).unwrap_or(trimmed);
let compact: String = body.chars().filter(|c| !c.is_whitespace()).collect();
if compact.is_empty() {
bail!("empty task blob");
}
let json = B64.decode(compact.as_bytes()).context(
"this does not look like a sara task blob (base64 decode failed) — \
paste the whole `sara-task-v1:…` token",
)?;
let bundle: Bundle = serde_json::from_slice(&json).context("parsing task bundle JSON")?;
if bundle.format != BUNDLE_FORMAT {
bail!(
"unexpected bundle format '{}' (expected '{BUNDLE_FORMAT}')",
bundle.format
);
}
if bundle.version > BUNDLE_VERSION {
bail!(
"task blob is version {} but this sara only understands up to {BUNDLE_VERSION} — upgrade sara",
bundle.version
);
}
if bundle.tasks.is_empty() {
bail!("task blob contains no tasks");
}
Ok(bundle)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> Bundle {
let root = Uuid::new_v4();
let dep = Uuid::new_v4();
Bundle {
format: BUNDLE_FORMAT.into(),
version: BUNDLE_VERSION,
exported_at: Utc::now(),
root,
tasks: vec![
TaskEnvelope {
uuid: dep,
description: "blocker".into(),
project: "web".into(),
status: Status::Pending,
priority: Some(Priority::L),
due: None,
entry: Utc::now(),
tags: vec!["infra".into()],
estimate_mins: None,
recur: None,
blocked_by: vec![],
annotations: vec![],
checklist: vec![],
links: vec![],
files: vec![],
},
TaskEnvelope {
uuid: root,
description: "root task".into(),
project: "web".into(),
status: Status::Pending,
priority: Some(Priority::H),
due: None,
entry: Utc::now(),
tags: vec![],
estimate_mins: Some(30),
recur: None,
blocked_by: vec![dep],
annotations: vec![AnnotationDto {
text: "note".into(),
kind: "comment".into(),
author: "human".into(),
target_kind: None,
target_id: None,
}],
checklist: vec![ChecklistDto {
text: "do the thing".into(),
done: true,
kind: "step".into(),
source: "human".into(),
intent: None,
verify_cmd: Some("cargo test".into()),
result: Some("ok".into()),
done_commit: Some("abc123".into()),
}],
links: vec![LinkDto {
url: "https://example.com/pr/1".into(),
label: Some("PR 1".into()),
}],
files: vec![FileDto {
path: "src/main.rs".into(),
source: "manual".into(),
}],
},
],
}
}
#[test]
fn round_trips_through_blob() {
let b = sample();
let blob = b.encode().unwrap();
assert!(blob.starts_with(BLOB_PREFIX));
let back = Bundle::decode(&blob).unwrap();
assert_eq!(back.root, b.root);
assert_eq!(back.tasks.len(), 2);
assert_eq!(back.tasks[1].blocked_by, vec![b.tasks[0].uuid]);
assert_eq!(
back.tasks[1].checklist[0].verify_cmd.as_deref(),
Some("cargo test")
);
}
#[test]
fn decode_tolerates_whitespace_and_missing_prefix() {
let blob = sample().encode().unwrap();
let raw = blob.strip_prefix(BLOB_PREFIX).unwrap();
let wrapped: String = raw
.as_bytes()
.chunks(20)
.map(|c| format!(" {}\n", std::str::from_utf8(c).unwrap()))
.collect();
let back = Bundle::decode(&wrapped).unwrap();
assert_eq!(back.tasks.len(), 2);
}
#[test]
fn rejects_non_bundle_base64() {
let junk = format!("{BLOB_PREFIX}{}", B64.encode(b"{\"hello\":1}"));
assert!(Bundle::decode(&junk).is_err());
}
#[test]
fn rejects_future_version() {
let mut b = sample();
b.version = BUNDLE_VERSION + 1;
let blob = b.encode().unwrap();
let err = Bundle::decode(&blob).unwrap_err().to_string();
assert!(err.contains("upgrade sara"), "{err}");
}
}