use anyhow::Context;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use super::DaemonClient;
use super::error::response_or_body_error;
use crate::project::Project;
#[derive(Debug, Clone, Serialize)]
pub struct RegisterProjectArgs {
pub name: String,
pub repo_url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_branch: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stack_hint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gh_user: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gh_account: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gh_config_dir: Option<std::path::PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub worktree: Option<bool>,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct PatchProjectArgs {
#[serde(skip_serializing_if = "Option::is_none")]
pub repo_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_branch: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stack_hint: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gh_user: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gh_account: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub worktree: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tags_add: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tags_remove: Option<Vec<String>>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct SessionStateCountsWire {
#[serde(default)]
pub provisioning: usize,
#[serde(default)]
pub active: usize,
#[serde(default)]
pub stopped: usize,
#[serde(default)]
pub errored: usize,
#[serde(default)]
pub decommissioned: usize,
#[serde(default)]
pub total: usize,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct ProjectConfigFlagsWire {
#[serde(default)]
pub gh_user_set: bool,
#[serde(default)]
pub github_binding_set: bool,
#[serde(default)]
pub gh_account_set: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ProjectStatusWire {
#[serde(default)]
pub project_name: String,
#[serde(default)]
pub repo_url: String,
#[serde(default)]
pub sessions: SessionStateCountsWire,
#[serde(default)]
pub last_activity_at: Option<DateTime<Utc>>,
#[serde(default)]
pub config: ProjectConfigFlagsWire,
}
#[derive(Debug, Deserialize)]
struct ProjectsListWire {
#[serde(default)]
projects: Vec<Project>,
}
impl DaemonClient {
pub async fn registry_list_projects(&self, tag: Option<&str>) -> anyhow::Result<Vec<Project>> {
let url = "/api/v1/projects".to_string();
let resp = self.get(&url).send().await?;
let body: ProjectsListWire = response_or_body_error(resp)
.await?
.json()
.await
.context("deserialize project list")?;
let mut projects = body.projects;
if let Some(tag) = tag {
projects.retain(|p| p.tags.iter().any(|t| t == tag));
}
Ok(projects)
}
pub async fn registry_register_project(
&self,
args: &RegisterProjectArgs,
) -> anyhow::Result<Project> {
let url = "/api/v1/projects".to_string();
let resp = self.post(&url).json(args).send().await?;
let project: Project = response_or_body_error(resp)
.await?
.json()
.await
.context("deserialize registered project")?;
Ok(project)
}
pub async fn registry_get_project(&self, name: &str) -> anyhow::Result<Project> {
let url = format!("/api/v1/projects/{name}");
let resp = self.get(&url).send().await?;
let project: Project = response_or_body_error(resp)
.await?
.json()
.await
.context("deserialize project")?;
Ok(project)
}
pub async fn registry_patch_project(
&self,
name: &str,
args: &PatchProjectArgs,
) -> anyhow::Result<Project> {
let url = format!("/api/v1/projects/{name}");
let resp = self.patch(&url).json(args).send().await?;
let project: Project = response_or_body_error(resp)
.await?
.json()
.await
.context("deserialize patched project")?;
Ok(project)
}
pub async fn project_status(&self, name: &str) -> anyhow::Result<ProjectStatusWire> {
let url = format!("/api/v1/projects/{name}/status");
let resp = self.get(&url).send().await?;
let status: ProjectStatusWire = response_or_body_error(resp)
.await?
.json()
.await
.context("deserialize project status")?;
Ok(status)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn project_status_wire_tolerates_additive_fields() {
let json = serde_json::json!({
"project_name": "widget",
"repo_url": "https://github.com/acme/widget",
"sessions": { "provisioning": 1, "active": 2, "stopped": 0,
"errored": 0, "decommissioned": 1, "total": 4 },
"last_activity_at": "2026-07-10T12:00:00Z",
"config": { "gh_user_set": true, "github_binding_set": false },
"deliverables": { "proposed": 3, "complete": 1 },
"milestones": { "in-progress": 1 }
});
let status: ProjectStatusWire = serde_json::from_value(json).unwrap();
assert_eq!(status.project_name, "widget");
assert_eq!(status.sessions.active, 2);
assert_eq!(status.sessions.total, 4);
assert!(status.config.gh_user_set);
assert!(status.last_activity_at.is_some());
}
#[test]
fn project_status_wire_tolerates_missing_fields() {
let json = serde_json::json!({
"project_name": "widget",
"repo_url": "https://github.com/acme/widget",
"sessions": { "total": 0 }
});
let status: ProjectStatusWire = serde_json::from_value(json).unwrap();
assert_eq!(status.sessions.total, 0);
assert!(status.last_activity_at.is_none());
assert!(!status.config.gh_user_set);
}
#[test]
fn register_args_serializes_to_wire_shape() {
let args = RegisterProjectArgs {
name: "widget".into(),
repo_url: "https://github.com/acme/widget".into(),
default_branch: Some("develop".into()),
description: None,
tags: Some(vec!["backend".into()]),
stack_hint: None,
gh_user: None,
gh_account: None,
gh_config_dir: None,
worktree: None,
};
let v = serde_json::to_value(&args).unwrap();
assert_eq!(v["name"], "widget");
assert_eq!(v["repo_url"], "https://github.com/acme/widget");
assert_eq!(v["default_branch"], "develop");
assert_eq!(v["tags"][0], "backend");
assert!(v.get("description").is_none());
assert!(v.get("stack_hint").is_none());
assert!(v.get("gh_user").is_none());
assert!(v.get("gh_account").is_none());
assert!(v.get("gh_config_dir").is_none());
}
#[test]
fn register_args_serializes_gh_config_dir() {
let args = RegisterProjectArgs {
name: "widget".into(),
repo_url: "https://github.com/acme/widget".into(),
default_branch: None,
description: None,
tags: None,
stack_hint: None,
gh_user: None,
gh_account: Some("acme-bot".into()),
gh_config_dir: Some("/home/bob/.config/gh-acme".into()),
worktree: None,
};
let v = serde_json::to_value(&args).unwrap();
assert_eq!(v["gh_account"], "acme-bot");
assert_eq!(v["gh_config_dir"], "/home/bob/.config/gh-acme");
}
#[test]
fn projects_list_deserializes() {
let json = serde_json::json!({
"projects": [{
"name": "widget",
"repo_url": "https://github.com/acme/widget",
"default_branch": "main"
}],
"count": 1
});
let body: ProjectsListWire = serde_json::from_value(json).unwrap();
assert_eq!(body.projects.len(), 1);
assert_eq!(body.projects[0].name, "widget");
}
#[test]
fn patch_args_serializes_double_option_semantics() {
let args = PatchProjectArgs {
description: Some(None),
stack_hint: Some(Some("rust".into())),
..Default::default()
};
let v = serde_json::to_value(&args).unwrap();
assert!(v["description"].is_null(), "{v}");
assert_eq!(v["stack_hint"], "rust");
}
#[test]
fn patch_args_serializes_gh_account_double_option() {
let cleared = PatchProjectArgs {
gh_account: Some(None),
..Default::default()
};
assert!(serde_json::to_value(&cleared).unwrap()["gh_account"].is_null());
let set = PatchProjectArgs {
gh_account: Some(Some("bobmatnyc".into())),
..Default::default()
};
assert_eq!(
serde_json::to_value(&set).unwrap()["gh_account"],
"bobmatnyc"
);
}
#[test]
fn patch_args_serializes_worktree_plain_option() {
let args = PatchProjectArgs {
worktree: Some(false),
..Default::default()
};
let v = serde_json::to_value(&args).unwrap();
assert_eq!(v["worktree"], false);
let unchanged = PatchProjectArgs::default();
assert!(
serde_json::to_value(&unchanged)
.unwrap()
.get("worktree")
.is_none()
);
}
#[test]
fn patch_args_omits_absent_fields() {
let args = PatchProjectArgs::default();
let v = serde_json::to_value(&args).unwrap();
assert_eq!(v, serde_json::json!({}));
}
#[test]
fn patch_args_serializes_tags_and_required_fields() {
let args = PatchProjectArgs {
repo_url: Some("https://github.com/acme/widget2".into()),
default_branch: Some("release".into()),
tags_add: Some(vec!["ml".into()]),
tags_remove: Some(vec!["oss".into()]),
..Default::default()
};
let v = serde_json::to_value(&args).unwrap();
assert_eq!(v["repo_url"], "https://github.com/acme/widget2");
assert_eq!(v["default_branch"], "release");
assert_eq!(v["tags_add"][0], "ml");
assert_eq!(v["tags_remove"][0], "oss");
}
}