use std::sync::Arc;
use axum::{
Json,
extract::{Path as AxumPath, State},
http::StatusCode,
response::IntoResponse,
};
use serde::{Deserialize, Serialize};
use tracing::warn;
use crate::daemon::state::DaemonState;
use crate::project::{Project, ProjectStoreError};
#[derive(Debug, Deserialize)]
pub struct RegisterProjectBody {
pub name: String,
pub repo_url: String,
#[serde(default)]
pub default_branch: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub tags: Option<Vec<String>>,
#[serde(default)]
pub stack_hint: Option<String>,
#[serde(default)]
pub gh_user: Option<String>,
#[serde(default)]
pub gh_account: Option<String>,
#[serde(default)]
pub worktree: Option<bool>,
}
#[derive(Debug, Serialize)]
pub struct ProjectsListResponse {
pub projects: Vec<Project>,
pub count: usize,
}
pub async fn list_projects_registry_route(
State(state): State<Arc<DaemonState>>,
) -> impl IntoResponse {
let registry = state.project_registry().await;
match registry.list().await {
Ok(projects) => {
let count = projects.len();
Json(ProjectsListResponse { projects, count }).into_response()
}
Err(e) => {
warn!(error = %e, "list_projects_registry_route: registry read failed");
(
StatusCode::INTERNAL_SERVER_ERROR,
"project registry read failed".to_string(),
)
.into_response()
}
}
}
pub async fn register_project_registry_route(
State(state): State<Arc<DaemonState>>,
Json(body): Json<RegisterProjectBody>,
) -> impl IntoResponse {
if body.name.trim().is_empty() {
return (StatusCode::BAD_REQUEST, "project name must not be empty").into_response();
}
if body.repo_url.trim().is_empty() {
return (StatusCode::BAD_REQUEST, "repo_url must not be empty").into_response();
}
let registry = state.project_registry().await;
let existing = registry.get(&body.name).await.ok();
let project = Project {
name: body.name,
repo_url: body.repo_url,
default_branch: body.default_branch.unwrap_or_else(|| "main".to_string()),
stack_hint: body
.stack_hint
.or_else(|| existing.as_ref().and_then(|p| p.stack_hint.clone())),
tags: body
.tags
.or_else(|| existing.as_ref().map(|p| p.tags.clone()))
.unwrap_or_default(),
description: body
.description
.or_else(|| existing.as_ref().and_then(|p| p.description.clone())),
gh_user: body
.gh_user
.or_else(|| existing.as_ref().and_then(|p| p.gh_user.clone())),
gh_account: body
.gh_account
.or_else(|| existing.as_ref().and_then(|p| p.gh_account.clone())),
github: existing.as_ref().and_then(|p| p.github.clone()),
commit_name: existing.as_ref().and_then(|p| p.commit_name.clone()),
commit_email: existing.as_ref().and_then(|p| p.commit_email.clone()),
worktree: body
.worktree
.or_else(|| existing.as_ref().and_then(|p| p.worktree)),
};
if let Err(e) = registry.register(project.clone()).await {
warn!(error = %e, project = %project.name, "register_project_registry_route: register failed");
return (
StatusCode::INTERNAL_SERVER_ERROR,
"project registry write failed".to_string(),
)
.into_response();
}
(StatusCode::CREATED, Json(project)).into_response()
}
pub async fn get_project_registry_route(
State(state): State<Arc<DaemonState>>,
AxumPath(name): AxumPath<String>,
) -> impl IntoResponse {
let registry = state.project_registry().await;
match registry.get(&name).await {
Ok(project) => Json(project).into_response(),
Err(ProjectStoreError::NotFound(_)) => {
(StatusCode::NOT_FOUND, format!("project {name} not found")).into_response()
}
Err(e) => {
warn!(error = %e, project = %name, "get_project_registry_route: registry read failed");
(
StatusCode::INTERNAL_SERVER_ERROR,
"project registry read failed".to_string(),
)
.into_response()
}
}
}
fn deserialize_double_option<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
where
D: serde::Deserializer<'de>,
T: Deserialize<'de>,
{
Ok(Some(Option::deserialize(deserializer)?))
}
#[derive(Debug, Default, Deserialize)]
pub struct PatchProjectBody {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub repo_url: Option<String>,
#[serde(default)]
pub default_branch: Option<String>,
#[serde(default, deserialize_with = "deserialize_double_option")]
pub description: Option<Option<String>>,
#[serde(default, deserialize_with = "deserialize_double_option")]
pub stack_hint: Option<Option<String>>,
#[serde(default, deserialize_with = "deserialize_double_option")]
pub gh_user: Option<Option<String>>,
#[serde(default, deserialize_with = "deserialize_double_option")]
pub gh_account: Option<Option<String>>,
#[serde(default)]
pub worktree: Option<bool>,
#[serde(default)]
pub tags_add: Option<Vec<String>>,
#[serde(default)]
pub tags_remove: Option<Vec<String>>,
}
pub async fn patch_project_registry_route(
State(state): State<Arc<DaemonState>>,
AxumPath(name): AxumPath<String>,
Json(body): Json<PatchProjectBody>,
) -> impl IntoResponse {
if let Some(new_name) = &body.name
&& new_name.trim() != name
{
return (
StatusCode::BAD_REQUEST,
"project name is the identity key and cannot be changed via PATCH",
)
.into_response();
}
if let Some(repo_url) = &body.repo_url
&& repo_url.trim().is_empty()
{
return (StatusCode::BAD_REQUEST, "repo_url must not be empty").into_response();
}
if let Some(default_branch) = &body.default_branch
&& default_branch.trim().is_empty()
{
return (StatusCode::BAD_REQUEST, "default_branch must not be empty").into_response();
}
let tags_add = match body
.tags_add
.map(|tags| trim_and_reject_blank_tags(tags, "tags_add"))
{
Some(Ok(tags)) => Some(tags),
Some(Err(msg)) => return (StatusCode::BAD_REQUEST, msg).into_response(),
None => None,
};
let tags_remove = match body
.tags_remove
.map(|tags| trim_and_reject_blank_tags(tags, "tags_remove"))
{
Some(Ok(tags)) => Some(tags),
Some(Err(msg)) => return (StatusCode::BAD_REQUEST, msg).into_response(),
None => None,
};
let repo_url = body.repo_url;
let default_branch = body.default_branch;
let description = body.description;
let stack_hint = body.stack_hint;
let gh_user = body.gh_user;
let gh_account = body.gh_account;
let worktree = body.worktree;
let registry = state.project_registry().await;
let result = registry
.update_with(&name, move |mut project| {
if let Some(repo_url) = repo_url {
project.repo_url = repo_url;
}
if let Some(default_branch) = default_branch {
project.default_branch = default_branch;
}
if let Some(description) = description {
project.description = description;
}
if let Some(stack_hint) = stack_hint {
project.stack_hint = stack_hint;
}
if let Some(gh_user) = gh_user {
project.gh_user = gh_user;
}
if let Some(gh_account) = gh_account {
project.gh_account = gh_account;
}
if let Some(worktree) = worktree {
project.worktree = Some(worktree);
}
if let Some(add) = tags_add {
for tag in add {
if !project.tags.contains(&tag) {
project.tags.push(tag);
}
}
}
if let Some(remove) = tags_remove {
project.tags.retain(|t| !remove.contains(t));
}
project
})
.await;
match result {
Ok(project) => Json(project).into_response(),
Err(ProjectStoreError::NotFound(_)) => {
(StatusCode::NOT_FOUND, format!("project {name} not found")).into_response()
}
Err(e) => {
warn!(error = %e, project = %name, "patch_project_registry_route: registry write failed");
(
StatusCode::INTERNAL_SERVER_ERROR,
"project registry write failed".to_string(),
)
.into_response()
}
}
}
fn trim_and_reject_blank_tags(tags: Vec<String>, field: &str) -> Result<Vec<String>, String> {
let mut trimmed = Vec::with_capacity(tags.len());
for tag in tags {
let tag = tag.trim().to_string();
if tag.is_empty() {
return Err(format!(
"{field} must not contain blank/whitespace-only tags"
));
}
trimmed.push(tag);
}
Ok(trimmed)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn register_body_deserializes() {
let json = serde_json::json!({
"name": "widget",
"repo_url": "https://github.com/acme/widget",
"default_branch": "develop",
"description": "the widget",
"tags": ["backend", "oss"],
"stack_hint": "rust",
"gh_user": "acme-bot",
"gh_account": "acme-bot"
});
let body: RegisterProjectBody = serde_json::from_value(json).unwrap();
assert_eq!(body.name, "widget");
assert_eq!(body.repo_url, "https://github.com/acme/widget");
assert_eq!(body.default_branch.as_deref(), Some("develop"));
assert_eq!(
body.tags.as_deref(),
Some(&["backend".to_string(), "oss".to_string()][..])
);
assert_eq!(body.gh_user.as_deref(), Some("acme-bot"));
assert_eq!(body.gh_account.as_deref(), Some("acme-bot"));
}
#[test]
fn register_body_minimal() {
let json = serde_json::json!({
"name": "minimal",
"repo_url": "https://github.com/acme/minimal"
});
let body: RegisterProjectBody = serde_json::from_value(json).unwrap();
assert_eq!(body.name, "minimal");
assert!(body.default_branch.is_none());
assert!(body.tags.is_none());
assert!(body.stack_hint.is_none());
assert!(body.gh_user.is_none());
assert!(body.gh_account.is_none());
}
#[test]
fn patch_body_distinguishes_absent_null_and_value() {
let absent: PatchProjectBody = serde_json::from_value(serde_json::json!({})).unwrap();
assert_eq!(absent.description, None);
assert_eq!(absent.stack_hint, None);
assert_eq!(absent.gh_user, None);
assert_eq!(absent.gh_account, None);
let cleared: PatchProjectBody = serde_json::from_value(serde_json::json!({
"description": null,
"stack_hint": null,
"gh_user": null,
"gh_account": null
}))
.unwrap();
assert_eq!(cleared.description, Some(None));
assert_eq!(cleared.stack_hint, Some(None));
assert_eq!(cleared.gh_user, Some(None));
assert_eq!(cleared.gh_account, Some(None));
let set: PatchProjectBody = serde_json::from_value(serde_json::json!({
"description": "new desc",
"stack_hint": "python",
"gh_user": "acme-bot",
"gh_account": "acme-bot"
}))
.unwrap();
assert_eq!(set.description, Some(Some("new desc".to_string())));
assert_eq!(set.stack_hint, Some(Some("python".to_string())));
assert_eq!(set.gh_user, Some(Some("acme-bot".to_string())));
assert_eq!(set.gh_account, Some(Some("acme-bot".to_string())));
}
#[test]
fn patch_body_minimal_defaults_to_all_absent() {
let body: PatchProjectBody = serde_json::from_value(serde_json::json!({})).unwrap();
assert!(body.name.is_none());
assert!(body.repo_url.is_none());
assert!(body.default_branch.is_none());
assert!(body.description.is_none());
assert!(body.stack_hint.is_none());
assert!(body.gh_user.is_none());
assert!(body.gh_account.is_none());
assert!(body.tags_add.is_none());
assert!(body.tags_remove.is_none());
}
}