use std::time::Duration;
use chrono::{DateTime, Utc};
use serde_json::{Value, json};
use tracing::debug;
use crate::state::{CheckpointInfo, ExecResult, SpriteInfo};
pub struct SpritesClient {
http: reqwest::Client,
api_token: String,
api_base: String,
}
impl SpritesClient {
pub fn new(api_token: String) -> Self {
Self::with_base_url(api_token, crate::SPRITES_API_BASE.to_string())
}
pub fn with_base_url(api_token: String, api_base: String) -> Self {
Self {
http: reqwest::Client::new(),
api_token,
api_base,
}
}
async fn request(
&self,
method: reqwest::Method,
path: &str,
body: Option<Value>,
) -> Result<Value, String> {
let url = format!("{}{}", self.api_base, path);
let mut req = self
.http
.request(method, &url)
.bearer_auth(&self.api_token)
.header("Content-Type", "application/json");
if let Some(b) = body {
req = req.json(&b);
}
let resp = req
.send()
.await
.map_err(|e| format!("Failed to connect to Sprites API: {e}"))?;
let status = resp.status();
let body_text = resp
.text()
.await
.map_err(|e| format!("Failed to read response: {e}"))?;
if !status.is_success() {
return Err(format!("Sprites API error ({status}): {body_text}"));
}
if body_text.is_empty() {
return Ok(json!({}));
}
serde_json::from_str(&body_text).map_err(|e| format!("Invalid JSON from Sprites: {e}"))
}
async fn request_text(
&self,
method: reqwest::Method,
path: &str,
body: Option<Value>,
) -> Result<String, String> {
let url = format!("{}{}", self.api_base, path);
let mut req = self
.http
.request(method, &url)
.bearer_auth(&self.api_token)
.header("Content-Type", "application/json");
if let Some(b) = body {
req = req.json(&b);
}
let resp = req
.send()
.await
.map_err(|e| format!("Failed to connect to Sprites API: {e}"))?;
let status = resp.status();
let body_text = resp
.text()
.await
.map_err(|e| format!("Failed to read response: {e}"))?;
if !status.is_success() {
return Err(format!("Sprites API error ({status}): {body_text}"));
}
Ok(body_text)
}
pub async fn create_sprite(&self, name: &str, body: Value) -> Result<SpriteInfo, String> {
let mut body = body;
let Some(obj) = body.as_object_mut() else {
return Err("Sprites create request must be a JSON object".to_string());
};
obj.insert("name".to_string(), json!(name));
let resp = self
.request(reqwest::Method::POST, "/sprites", Some(body))
.await?;
serde_json::from_value(resp).map_err(|e| format!("Failed to parse sprite info: {e}"))
}
pub async fn get_sprite(&self, name: &str) -> Result<SpriteInfo, String> {
let resp = self
.request(reqwest::Method::GET, &format!("/sprites/{name}"), None)
.await?;
serde_json::from_value(resp).map_err(|e| format!("Failed to parse sprite info: {e}"))
}
pub async fn list_sprites(&self) -> Result<Vec<SpriteInfo>, String> {
let resp = self.request(reqwest::Method::GET, "/sprites", None).await?;
if let Some(arr) = resp.as_array() {
let mut sprites = Vec::new();
for item in arr {
match serde_json::from_value::<SpriteInfo>(item.clone()) {
Ok(s) => sprites.push(s),
Err(e) => debug!("Skipping unparseable sprite: {e}"),
}
}
Ok(sprites)
} else if let Some(arr) = resp.get("sprites").and_then(|v| v.as_array()) {
let mut sprites = Vec::new();
for item in arr {
match serde_json::from_value::<SpriteInfo>(item.clone()) {
Ok(s) => sprites.push(s),
Err(e) => debug!("Skipping unparseable sprite: {e}"),
}
}
Ok(sprites)
} else {
Ok(vec![])
}
}
pub async fn delete_sprite(&self, name: &str) -> Result<(), String> {
self.request(reqwest::Method::DELETE, &format!("/sprites/{name}"), None)
.await?;
Ok(())
}
pub async fn exec(
&self,
name: &str,
command: &str,
timeout_ms: Option<u64>,
) -> Result<ExecResult, String> {
let timeout_ms = timeout_ms.unwrap_or(crate::EXEC_TIMEOUT_MS);
let url = format!(
"{}/sprites/{name}/exec?cmd={}&cmd={}&cmd={}",
self.api_base,
urlencoding::encode("sh"),
urlencoding::encode("-lc"),
urlencoding::encode(command)
);
let resp = self
.http
.post(&url)
.bearer_auth(&self.api_token)
.timeout(Duration::from_millis(timeout_ms))
.send()
.await
.map_err(|e| {
if e.is_timeout() {
format!("Sprites exec timed out after {timeout_ms}ms")
} else {
format!("Failed to connect to Sprites API: {e}")
}
})?;
let status = resp.status();
let bytes = resp
.bytes()
.await
.map_err(|e| format!("Failed to read exec response: {e}"))?;
if !status.is_success() {
let body_text = String::from_utf8_lossy(&bytes);
return Err(format!("Sprites API error ({status}): {body_text}"));
}
parse_exec_result(&bytes)
}
pub async fn read_file(&self, name: &str, path: &str) -> Result<Vec<u8>, String> {
let url = format!(
"{}/sprites/{name}/fs/read?path={}",
self.api_base,
urlencoding::encode(path)
);
let resp = self
.http
.get(&url)
.bearer_auth(&self.api_token)
.send()
.await
.map_err(|e| format!("Failed to connect to Sprites API: {e}"))?;
let status = resp.status();
if !status.is_success() {
let body_text = resp
.text()
.await
.map_err(|e| format!("Failed to read response: {e}"))?;
return Err(format!("Sprites API error ({status}): {body_text}"));
}
resp.bytes()
.await
.map(|b| b.to_vec())
.map_err(|e| format!("Failed to read file bytes: {e}"))
}
pub async fn write_file(&self, name: &str, path: &str, content: &[u8]) -> Result<(), String> {
let url = format!(
"{}/sprites/{name}/fs/write?path={}&mkdir=true",
self.api_base,
urlencoding::encode(path)
);
let resp = self
.http
.put(&url)
.bearer_auth(&self.api_token)
.header("Content-Type", "application/octet-stream")
.body(content.to_vec())
.send()
.await
.map_err(|e| format!("Failed to write file: {e}"))?;
let status = resp.status();
if !status.is_success() {
let body_text = resp
.text()
.await
.map_err(|e| format!("Failed to read response: {e}"))?;
return Err(format!("Sprites API error ({status}): {body_text}"));
}
Ok(())
}
pub async fn create_checkpoint(&self, name: &str) -> Result<CheckpointInfo, String> {
let stream = self
.request_text(
reqwest::Method::POST,
&format!("/sprites/{name}/checkpoint"),
Some(json!({})),
)
.await?;
ensure_ndjson_success(&stream)?;
if let Some(checkpoint) = checkpoint_from_stream(&stream) {
return Ok(checkpoint);
}
latest_non_current_checkpoint(self.list_checkpoints(name).await?).ok_or_else(|| {
"Sprites checkpoint completed but no new checkpoint id was found".to_string()
})
}
pub async fn list_checkpoints(&self, name: &str) -> Result<Vec<CheckpointInfo>, String> {
let resp = self
.request(
reqwest::Method::GET,
&format!("/sprites/{name}/checkpoints"),
None,
)
.await?;
if let Some(arr) = resp.as_array() {
let mut checkpoints = Vec::new();
for item in arr {
match serde_json::from_value::<CheckpointInfo>(item.clone()) {
Ok(c) => checkpoints.push(c),
Err(e) => debug!("Skipping unparseable checkpoint: {e}"),
}
}
Ok(checkpoints)
} else {
Ok(vec![])
}
}
pub async fn restore_checkpoint(&self, name: &str, checkpoint_id: &str) -> Result<(), String> {
let stream = self
.request_text(
reqwest::Method::POST,
&format!("/sprites/{name}/checkpoints/{checkpoint_id}/restore"),
None,
)
.await?;
ensure_ndjson_success(&stream)?;
Ok(())
}
pub async fn list_services(&self, name: &str) -> Result<Vec<Value>, String> {
let resp = self
.request(
reqwest::Method::GET,
&format!("/sprites/{name}/services"),
None,
)
.await?;
match resp.as_array() {
Some(arr) => Ok(arr.clone()),
None => Ok(vec![resp]),
}
}
}
fn parse_exec_result(bytes: &[u8]) -> Result<ExecResult, String> {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let mut exit_bytes = Vec::new();
let mut stream = None;
for &byte in bytes {
match byte {
0x01 => stream = Some(0x01),
0x02 => stream = Some(0x02),
0x03 => stream = Some(0x03),
_ => match stream {
Some(0x01) => stdout.push(byte),
Some(0x02) => stderr.push(byte),
Some(0x03) => exit_bytes.push(byte),
_ => {}
},
}
}
let exit_code = exit_bytes.first().copied().unwrap_or(0) as i32;
Ok(ExecResult {
stdout: String::from_utf8(stdout)
.map_err(|e| format!("Sprites exec stdout was not valid UTF-8: {e}"))?,
stderr: String::from_utf8(stderr)
.map_err(|e| format!("Sprites exec stderr was not valid UTF-8: {e}"))?,
exit_code,
})
}
fn ensure_ndjson_success(stream: &str) -> Result<(), String> {
for line in stream.lines().filter(|line| !line.trim().is_empty()) {
let event: Value =
serde_json::from_str(line).map_err(|e| format!("Invalid NDJSON from Sprites: {e}"))?;
if event.get("type").and_then(|v| v.as_str()) == Some("error") {
if let Some(error) = event.get("error").and_then(|v| v.as_str()) {
return Err(format!("Sprites stream error: {error}"));
}
if let Some(message) = event.get("message").and_then(|v| v.as_str()) {
return Err(format!("Sprites stream error: {message}"));
}
return Err(format!("Sprites stream error: {event}"));
}
}
Ok(())
}
fn checkpoint_from_stream(stream: &str) -> Option<CheckpointInfo> {
for line in stream.lines().filter(|line| !line.trim().is_empty()) {
let event: Value = serde_json::from_str(line).ok()?;
if event.get("type").and_then(|v| v.as_str()) != Some("complete") {
continue;
}
let data = event.get("data").and_then(|v| v.as_str())?;
let id = data
.split_whitespace()
.nth(1)
.map(|token| token.trim_matches(|ch: char| !ch.is_ascii_alphanumeric()))?;
return Some(CheckpointInfo {
id: id.to_string(),
created_at: event
.get("time")
.and_then(|v| v.as_str())
.map(ToOwned::to_owned),
comment: None,
});
}
None
}
fn latest_non_current_checkpoint(checkpoints: Vec<CheckpointInfo>) -> Option<CheckpointInfo> {
checkpoints
.into_iter()
.filter(|checkpoint| checkpoint.id != "Current")
.max_by(|left, right| {
checkpoint_timestamp(left)
.cmp(&checkpoint_timestamp(right))
.then_with(|| left.id.cmp(&right.id))
})
}
fn checkpoint_timestamp(checkpoint: &CheckpointInfo) -> Option<DateTime<Utc>> {
checkpoint
.created_at
.as_deref()
.and_then(|created_at| DateTime::parse_from_rfc3339(created_at).ok())
.map(|created_at| created_at.with_timezone(&Utc))
}
pub(crate) mod urlencoding {
pub fn encode(input: &str) -> String {
let mut result = String::with_capacity(input.len() * 2);
for byte in input.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
result.push(byte as char);
}
_ => {
result.push('%');
result.push_str(&format!("{byte:02X}"));
}
}
}
result
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
use serde_json::json;
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn test_client_create_sprite() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/sprites"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"name": "test-sprite",
"status": "running"
})))
.mount(&mock_server)
.await;
let client = SpritesClient::with_base_url("test_token".to_string(), mock_server.uri());
let result = client.create_sprite("test-sprite", json!({})).await;
assert!(result.is_ok());
let info = result.unwrap();
assert_eq!(info.name, "test-sprite");
}
#[tokio::test]
async fn test_client_get_sprite() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/sprites/my-sprite"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"name": "my-sprite",
"status": "running"
})))
.mount(&mock_server)
.await;
let client = SpritesClient::with_base_url("test_token".to_string(), mock_server.uri());
let result = client.get_sprite("my-sprite").await;
assert!(result.is_ok());
let info = result.unwrap();
assert_eq!(info.status, "running");
}
#[tokio::test]
async fn test_client_delete_sprite() {
let mock_server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/sprites/old-sprite"))
.respond_with(ResponseTemplate::new(204).set_body_string(""))
.mount(&mock_server)
.await;
let client = SpritesClient::with_base_url("test_token".to_string(), mock_server.uri());
let result = client.delete_sprite("old-sprite").await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_client_exec() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/sprites/my-sprite/exec"))
.and(query_param("cmd", "sh"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(vec![
0x01, b'h', b'e', b'l', b'l', b'o', b' ', b'w', b'o', b'r', b'l', b'd', b'\n',
0x03, 0,
]))
.mount(&mock_server)
.await;
let client = SpritesClient::with_base_url("test_token".to_string(), mock_server.uri());
let result = client.exec("my-sprite", "echo hello world", None).await;
assert!(result.is_ok());
let exec_result = result.unwrap();
assert_eq!(exec_result.exit_code, 0);
assert_eq!(exec_result.stdout, "hello world\n");
}
#[tokio::test]
async fn test_client_exec_honors_timeout() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/sprites/slow-sprite/exec"))
.and(query_param("cmd", "sh"))
.respond_with(
ResponseTemplate::new(200)
.set_delay(Duration::from_millis(200))
.set_body_bytes(vec![0x01, b'o', b'k', b'\n', 0x03, 0]),
)
.mount(&mock_server)
.await;
let client = SpritesClient::with_base_url("test_token".to_string(), mock_server.uri());
let err = client
.exec("slow-sprite", "echo ok", Some(10))
.await
.unwrap_err();
assert!(err.contains("timed out after 10ms"), "{err}");
}
#[tokio::test]
async fn test_client_create_checkpoint() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/sprites/my-sprite/checkpoint"))
.respond_with(ResponseTemplate::new(200).set_body_string(
"{\"type\":\"info\",\"data\":\"Creating checkpoint...\",\"time\":\"2026-03-23T10:05:00Z\"}\n{\"type\":\"complete\",\"data\":\"Checkpoint v1 created successfully\",\"time\":\"2026-03-23T10:05:01Z\"}\n",
))
.expect(1)
.mount(&mock_server)
.await;
let client = SpritesClient::with_base_url("test_token".to_string(), mock_server.uri());
let result = client.create_checkpoint("my-sprite").await;
assert!(result.is_ok());
assert_eq!(result.unwrap().id, "v1");
}
#[tokio::test]
async fn test_client_create_checkpoint_falls_back_to_newest_checkpoint() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/sprites/my-sprite/checkpoint"))
.respond_with(ResponseTemplate::new(200).set_body_string(
"{\"type\":\"info\",\"data\":\"Creating checkpoint...\",\"time\":\"2026-03-23T10:05:00Z\"}\n",
))
.expect(1)
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/sprites/my-sprite/checkpoints"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
{"id": "Current", "create_time": "2026-03-23T10:05:03Z"},
{"id": "cp_old", "create_time": "2026-03-23T10:05:01Z"},
{"id": "cp_new", "create_time": "2026-03-23T10:05:02Z"}
])))
.expect(1)
.mount(&mock_server)
.await;
let client = SpritesClient::with_base_url("test_token".to_string(), mock_server.uri());
let result = client.create_checkpoint("my-sprite").await.unwrap();
assert_eq!(result.id, "cp_new");
}
#[tokio::test]
async fn test_client_restore_checkpoint() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/sprites/my-sprite/checkpoints/cp_abc123/restore"))
.respond_with(ResponseTemplate::new(200).set_body_string(
"{\"type\":\"info\",\"data\":\"Restoring checkpoint...\",\"time\":\"2026-03-23T10:05:00Z\"}\n{\"type\":\"complete\",\"data\":\"Restore complete\",\"time\":\"2026-03-23T10:05:01Z\"}\n",
))
.mount(&mock_server)
.await;
let client = SpritesClient::with_base_url("test_token".to_string(), mock_server.uri());
let result = client.restore_checkpoint("my-sprite", "cp_abc123").await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_client_list_sprites() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/sprites"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
{"name": "sprite-1", "status": "running"},
{"name": "sprite-2", "status": "stopped"}
])))
.mount(&mock_server)
.await;
let client = SpritesClient::with_base_url("test_token".to_string(), mock_server.uri());
let result = client.list_sprites().await;
assert!(result.is_ok());
let sprites = result.unwrap();
assert_eq!(sprites.len(), 2);
}
#[tokio::test]
async fn test_client_404_error() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/sprites/nonexistent"))
.respond_with(
ResponseTemplate::new(404).set_body_string("{\"error\":\"Sprite not found\"}"),
)
.mount(&mock_server)
.await;
let client = SpritesClient::with_base_url("test_token".to_string(), mock_server.uri());
let result = client.get_sprite("nonexistent").await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("404"));
}
#[tokio::test]
async fn test_client_401_error() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/sprites"))
.respond_with(
ResponseTemplate::new(401).set_body_string("{\"error\":\"Unauthorized\"}"),
)
.mount(&mock_server)
.await;
let client = SpritesClient::with_base_url("bad_token".to_string(), mock_server.uri());
let result = client.create_sprite("test", json!({})).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("401"));
}
#[tokio::test]
async fn test_client_read_file() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/sprites/my-sprite/fs/read"))
.and(query_param("path", "/home/sprite/main.py"))
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"print('hello')\n".to_vec()))
.mount(&mock_server)
.await;
let client = SpritesClient::with_base_url("test_token".to_string(), mock_server.uri());
let result = client.read_file("my-sprite", "/home/sprite/main.py").await;
assert!(result.is_ok());
assert_eq!(
String::from_utf8_lossy(&result.unwrap()),
"print('hello')\n"
);
}
#[tokio::test]
async fn test_client_write_file() {
let mock_server = MockServer::start().await;
Mock::given(method("PUT"))
.and(path("/sprites/my-sprite/fs/write"))
.and(query_param("path", "/home/sprite/test.py"))
.and(query_param("mkdir", "true"))
.respond_with(ResponseTemplate::new(200).set_body_string(""))
.mount(&mock_server)
.await;
let client = SpritesClient::with_base_url("test_token".to_string(), mock_server.uri());
let result = client
.write_file("my-sprite", "/home/sprite/test.py", b"print('test')")
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_client_list_services() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/sprites/my-sprite/services"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
{"name": "web", "port": 8080, "url": "https://my-sprite.fly.dev"}
])))
.mount(&mock_server)
.await;
let client = SpritesClient::with_base_url("test_token".to_string(), mock_server.uri());
let result = client.list_services("my-sprite").await;
assert!(result.is_ok());
let services = result.unwrap();
assert_eq!(services.len(), 1);
}
#[tokio::test]
async fn test_client_list_checkpoints() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/sprites/my-sprite/checkpoints"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
{"id": "Current", "create_time": "2026-03-23T10:05:01Z"},
{"id": "cp_1", "create_time": "2026-03-23T10:04:01Z"},
{"id": "cp_2", "create_time": "2026-03-23T10:03:01Z"}
])))
.mount(&mock_server)
.await;
let client = SpritesClient::with_base_url("test_token".to_string(), mock_server.uri());
let result = client.list_checkpoints("my-sprite").await;
assert!(result.is_ok());
let checkpoints = result.unwrap();
assert_eq!(checkpoints.len(), 3);
assert_eq!(checkpoints[1].id, "cp_1");
}
#[tokio::test]
async fn test_client_empty_response() {
let mock_server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/sprites/my-sprite"))
.respond_with(ResponseTemplate::new(200).set_body_string(""))
.mount(&mock_server)
.await;
let client = SpritesClient::with_base_url("test_token".to_string(), mock_server.uri());
let result = client.delete_sprite("my-sprite").await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_client_malformed_response() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/sprites/bad"))
.respond_with(ResponseTemplate::new(200).set_body_string("not valid json"))
.mount(&mock_server)
.await;
let client = SpritesClient::with_base_url("test_token".to_string(), mock_server.uri());
let result = client.get_sprite("bad").await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("Invalid JSON"));
}
#[tokio::test]
async fn test_client_sends_bearer_auth() {
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/sprites/auth-test"))
.and(wiremock::matchers::header(
"Authorization",
"Bearer secret_token_123",
))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"name": "auth-test",
"status": "running"
})))
.expect(1)
.mount(&mock_server)
.await;
let client =
SpritesClient::with_base_url("secret_token_123".to_string(), mock_server.uri());
let info = client.get_sprite("auth-test").await.unwrap();
assert_eq!(info.name, "auth-test");
}
#[test]
fn test_parse_exec_stdout_and_exit_code() {
let result = parse_exec_result(&[0x01, b'h', b'i', b'\n', 0x03, 0]).unwrap();
assert_eq!(result.stdout, "hi\n");
assert_eq!(result.stderr, "");
assert_eq!(result.exit_code, 0);
}
#[test]
fn test_parse_exec_stderr_and_nonzero_exit() {
let result = parse_exec_result(&[0x02, b'e', b'r', b'r', 0x03, 42]).unwrap();
assert_eq!(result.stdout, "");
assert_eq!(result.stderr, "err");
assert_eq!(result.exit_code, 42);
}
#[test]
fn test_parse_exec_mixed_streams() {
let result =
parse_exec_result(&[0x02, b'e', b'r', b'r', 0x01, b'o', b'u', b't', 0x03, 7]).unwrap();
assert_eq!(result.stdout, "out");
assert_eq!(result.stderr, "err");
assert_eq!(result.exit_code, 7);
}
#[test]
fn test_checkpoint_from_stream_prefers_complete_event() {
let checkpoint = checkpoint_from_stream(
"{\"type\":\"info\",\"data\":\"Creating checkpoint...\",\"time\":\"2026-03-23T10:05:00Z\"}\n{\"type\":\"complete\",\"data\":\"Checkpoint v9 created successfully\",\"time\":\"2026-03-23T10:05:01Z\"}\n",
)
.unwrap();
assert_eq!(checkpoint.id, "v9");
assert_eq!(
checkpoint.created_at.as_deref(),
Some("2026-03-23T10:05:01Z")
);
}
#[test]
fn test_latest_non_current_checkpoint_prefers_newest_timestamp() {
let checkpoint = latest_non_current_checkpoint(vec![
CheckpointInfo {
id: "Current".to_string(),
created_at: Some("2026-03-23T10:05:03Z".to_string()),
comment: None,
},
CheckpointInfo {
id: "cp_old".to_string(),
created_at: Some("2026-03-23T10:05:01Z".to_string()),
comment: None,
},
CheckpointInfo {
id: "cp_new".to_string(),
created_at: Some("2026-03-23T10:05:02Z".to_string()),
comment: None,
},
])
.unwrap();
assert_eq!(checkpoint.id, "cp_new");
}
}