use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ActiveCommand {
pub project_id: String,
pub root: String,
pub operation: String,
pub started_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RemoteChange {
pub id: String,
pub project: String,
pub completed_tasks: u32,
pub total_tasks: u32,
pub last_modified: String,
pub status: String,
pub iteration_number: Option<u32>,
#[serde(default = "default_selected")]
pub selected: bool,
}
fn default_selected() -> bool {
true
}
fn default_remote_sync_state() -> String {
"unknown".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RemoteProject {
pub id: String,
pub name: String,
pub repo: String,
pub branch: String,
pub status: String,
pub is_busy: bool,
pub error: Option<String>,
#[serde(default = "default_remote_sync_state")]
pub sync_state: String,
#[serde(default)]
pub ahead_count: u32,
#[serde(default)]
pub behind_count: u32,
#[serde(default)]
pub sync_required: bool,
#[serde(default)]
pub local_sha: Option<String>,
#[serde(default)]
pub remote_sha: Option<String>,
#[serde(default)]
pub last_remote_check_at: Option<String>,
#[serde(default)]
pub remote_check_error: Option<String>,
pub changes: Vec<RemoteChange>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RemoteLogEntry {
pub message: String,
pub level: String,
pub change_id: Option<String>,
pub timestamp: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub project_id: Option<String>,
pub operation: Option<String>,
pub iteration: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ProjectEntry {
pub id: String,
pub remote_url: String,
pub branch: String,
pub status: String,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RemoteWorktreeInfo {
pub path: String,
pub label: String,
pub head: String,
pub branch: String,
pub is_detached: bool,
pub is_main: bool,
pub is_merging: bool,
pub has_commits_ahead: bool,
pub merge_conflict: Option<RemoteWorktreeMergeConflict>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RemoteWorktreeMergeConflict {
pub conflict_files: Vec<String>,
}
impl From<crate::tui::types::WorktreeInfo> for RemoteWorktreeInfo {
fn from(wt: crate::tui::types::WorktreeInfo) -> Self {
let label = if wt.branch.is_empty() {
format!("detached@{}", &wt.head[..7.min(wt.head.len())])
} else {
wt.branch.clone()
};
Self {
path: wt.path.to_string_lossy().to_string(),
label,
head: wt.head,
branch: wt.branch,
is_detached: wt.is_detached,
is_main: wt.is_main,
is_merging: wt.is_merging,
has_commits_ahead: wt.has_commits_ahead,
merge_conflict: wt.merge_conflict.map(|mc| RemoteWorktreeMergeConflict {
conflict_files: mc.conflict_files,
}),
}
}
}
fn default_orchestration_status() -> String {
"idle".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum RemoteStateUpdate {
FullState {
projects: Vec<RemoteProject>,
#[serde(default, skip_serializing_if = "Option::is_none")]
worktrees: Option<std::collections::HashMap<String, Vec<RemoteWorktreeInfo>>>,
#[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
ui_state: std::collections::HashMap<String, String>,
#[serde(default)]
sync_available: bool,
#[serde(default = "default_orchestration_status")]
orchestration_status: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
active_commands: Vec<ActiveCommand>,
},
ChangeUpdate { change: RemoteChange },
ChangeRemoved { id: String, project: String },
Log { entry: RemoteLogEntry },
Ping,
}
#[cfg(test)]
mod tests {
use super::super::test_helpers::{
change_update_json, full_state_json, make_remote_change, make_remote_log_entry,
make_remote_project, remote_change_json,
};
use super::*;
#[test]
fn test_remote_change_deserialization() {
let json = remote_change_json("add-feature-x", "my-project", 3, 5, "applying", Some(2));
let change: RemoteChange = serde_json::from_str(&json).unwrap();
assert_eq!(change.id, "add-feature-x");
assert_eq!(change.project, "my-project");
assert_eq!(change.completed_tasks, 3);
assert_eq!(change.total_tasks, 5);
assert_eq!(change.status, "applying");
assert_eq!(change.iteration_number, Some(2));
}
#[test]
fn test_remote_state_update_full_state_deserialization() {
let json = full_state_json("proj-1", "Project 1", &[]);
let update: RemoteStateUpdate = serde_json::from_str(&json).unwrap();
match update {
RemoteStateUpdate::FullState { projects, .. } => {
assert_eq!(projects.len(), 1);
assert_eq!(projects[0].id, "proj-1");
}
_ => panic!("Expected FullState"),
}
}
#[test]
fn test_remote_state_update_change_update_deserialization() {
let change = remote_change_json("my-change", "proj-1", 1, 3, "queued", None);
let json = change_update_json(&change);
let update: RemoteStateUpdate = serde_json::from_str(&json).unwrap();
match update {
RemoteStateUpdate::ChangeUpdate { change } => {
assert_eq!(change.id, "my-change");
assert_eq!(change.iteration_number, None);
}
_ => panic!("Expected ChangeUpdate"),
}
}
#[test]
fn test_remote_state_update_ping_deserialization() {
let json = r#"{"type": "ping"}"#;
let update: RemoteStateUpdate = serde_json::from_str(json).unwrap();
assert!(matches!(update, RemoteStateUpdate::Ping));
}
#[test]
fn test_make_remote_change_defaults() {
let change = make_remote_change("test-id", "test-project");
assert_eq!(change.id, "test-id");
assert_eq!(change.project, "test-project");
assert_eq!(change.status, "queued");
assert_eq!(change.iteration_number, None);
}
#[test]
fn test_make_remote_project() {
let changes = vec![
make_remote_change("change-1", "proj-a"),
make_remote_change("change-2", "proj-a"),
];
let project = make_remote_project("proj-a", "Project A", changes);
assert_eq!(project.id, "proj-a");
assert_eq!(project.name, "Project A");
assert_eq!(project.changes.len(), 2);
}
#[test]
fn test_make_remote_log_entry_defaults() {
let entry = make_remote_log_entry("test message", "info");
assert_eq!(entry.message, "test message");
assert_eq!(entry.level, "info");
assert_eq!(entry.change_id, None);
assert_eq!(entry.operation, None);
}
#[test]
fn test_remote_log_entry_round_trip() {
let entry = RemoteLogEntry {
message: "Running apply for change add-feature-x".to_string(),
level: "info".to_string(),
change_id: Some("add-feature-x".to_string()),
timestamp: "2024-01-01T00:00:00Z".to_string(),
project_id: Some("proj-abc123".to_string()),
operation: Some("apply".to_string()),
iteration: Some(1),
};
let json = serde_json::to_string(&entry).unwrap();
let decoded: RemoteLogEntry = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.message, entry.message);
assert_eq!(decoded.level, entry.level);
assert_eq!(decoded.change_id, entry.change_id);
assert_eq!(decoded.timestamp, entry.timestamp);
assert_eq!(decoded.project_id, entry.project_id);
assert_eq!(decoded.operation, entry.operation);
assert_eq!(decoded.iteration, entry.iteration);
}
#[test]
fn test_remote_state_update_log_round_trip() {
let entry = RemoteLogEntry {
message: "stderr: Build failed".to_string(),
level: "warn".to_string(),
change_id: None,
timestamp: "2024-06-01T12:00:00Z".to_string(),
project_id: Some("proj-xyz789".to_string()),
operation: None,
iteration: None,
};
let update = RemoteStateUpdate::Log {
entry: entry.clone(),
};
let json = serde_json::to_string(&update).unwrap();
assert!(json.contains(r#""type":"log""#));
assert!(
json.contains(r#""operation":null"#),
"operation key must be present as null, got: {json}"
);
assert!(
json.contains(r#""iteration":null"#),
"iteration key must be present as null, got: {json}"
);
let decoded: RemoteStateUpdate = serde_json::from_str(&json).unwrap();
match decoded {
RemoteStateUpdate::Log {
entry: decoded_entry,
} => {
assert_eq!(decoded_entry.message, entry.message);
assert_eq!(decoded_entry.level, entry.level);
assert_eq!(decoded_entry.change_id, entry.change_id);
assert_eq!(decoded_entry.timestamp, entry.timestamp);
}
_ => panic!("Expected Log variant"),
}
}
#[test]
fn test_remote_worktree_info_from_worktree_info() {
let wt = crate::tui::types::WorktreeInfo {
path: std::path::PathBuf::from("/repo/wt1"),
head: "abc123def456".to_string(),
branch: "feature-1".to_string(),
is_detached: false,
is_main: false,
merge_conflict: None,
has_commits_ahead: true,
is_merging: false,
};
let remote: RemoteWorktreeInfo = wt.into();
assert_eq!(remote.path, "/repo/wt1");
assert_eq!(remote.label, "feature-1");
assert_eq!(remote.head, "abc123def456");
assert_eq!(remote.branch, "feature-1");
assert!(!remote.is_detached);
assert!(!remote.is_main);
assert!(remote.has_commits_ahead);
assert!(remote.merge_conflict.is_none());
}
#[test]
fn test_remote_worktree_info_detached_head() {
let wt = crate::tui::types::WorktreeInfo {
path: std::path::PathBuf::from("/repo"),
head: "abc1234".to_string(),
branch: "".to_string(),
is_detached: true,
is_main: false,
merge_conflict: None,
has_commits_ahead: false,
is_merging: false,
};
let remote: RemoteWorktreeInfo = wt.into();
assert_eq!(remote.label, "detached@abc1234");
assert!(remote.is_detached);
}
#[test]
fn test_remote_worktree_info_with_conflicts() {
let wt = crate::tui::types::WorktreeInfo {
path: std::path::PathBuf::from("/repo/wt1"),
head: "abc123".to_string(),
branch: "feature-1".to_string(),
is_detached: false,
is_main: false,
merge_conflict: Some(crate::tui::types::MergeConflictInfo {
conflict_files: vec!["file1.rs".to_string(), "file2.rs".to_string()],
}),
has_commits_ahead: true,
is_merging: false,
};
let remote: RemoteWorktreeInfo = wt.into();
assert!(remote.merge_conflict.is_some());
let conflict = remote.merge_conflict.unwrap();
assert_eq!(conflict.conflict_files.len(), 2);
assert_eq!(conflict.conflict_files[0], "file1.rs");
}
#[test]
fn test_remote_worktree_info_serialization_round_trip() {
let info = RemoteWorktreeInfo {
path: "/repo/wt1".to_string(),
label: "feature-1".to_string(),
head: "abc123".to_string(),
branch: "feature-1".to_string(),
is_detached: false,
is_main: false,
is_merging: false,
has_commits_ahead: true,
merge_conflict: None,
};
let json = serde_json::to_string(&info).unwrap();
let decoded: RemoteWorktreeInfo = serde_json::from_str(&json).unwrap();
assert_eq!(decoded, info);
}
#[test]
fn test_full_state_with_worktrees() {
let mut worktrees_map = std::collections::HashMap::new();
worktrees_map.insert(
"proj-1".to_string(),
vec![RemoteWorktreeInfo {
path: "/repo/wt1".to_string(),
label: "main".to_string(),
head: "abc123".to_string(),
branch: "main".to_string(),
is_detached: false,
is_main: true,
is_merging: false,
has_commits_ahead: false,
merge_conflict: None,
}],
);
let update = RemoteStateUpdate::FullState {
projects: vec![],
worktrees: Some(worktrees_map),
ui_state: std::collections::HashMap::new(),
sync_available: true,
orchestration_status: "idle".to_string(),
active_commands: vec![],
};
let json = serde_json::to_string(&update).unwrap();
assert!(json.contains("worktrees"));
let decoded: RemoteStateUpdate = serde_json::from_str(&json).unwrap();
match decoded {
RemoteStateUpdate::FullState { worktrees, .. } => {
assert!(worktrees.is_some());
let wts = worktrees.unwrap();
assert_eq!(wts.len(), 1);
assert!(wts.contains_key("proj-1"));
}
_ => panic!("Expected FullState"),
}
}
#[test]
fn test_full_state_without_worktrees_backward_compatible() {
let json = r#"{
"type": "full_state",
"projects": []
}"#;
let update: RemoteStateUpdate = serde_json::from_str(json).unwrap();
match update {
RemoteStateUpdate::FullState { worktrees, .. } => {
assert!(worktrees.is_none());
}
_ => panic!("Expected FullState"),
}
}
#[test]
fn test_remote_state_update_log_deserialization_from_json() {
let json = r#"{
"type": "log",
"entry": {
"message": "Build succeeded",
"level": "success",
"change_id": "feat-x",
"timestamp": "2024-01-02T09:00:00Z"
}
}"#;
let update: RemoteStateUpdate = serde_json::from_str(json).unwrap();
match update {
RemoteStateUpdate::Log { entry } => {
assert_eq!(entry.message, "Build succeeded");
assert_eq!(entry.level, "success");
assert_eq!(entry.change_id, Some("feat-x".to_string()));
assert_eq!(entry.timestamp, "2024-01-02T09:00:00Z");
}
_ => panic!("Expected Log variant"),
}
}
}