use std::path::PathBuf;
use std::sync::Arc;
use axum::{
Json,
extract::{Path as AxumPath, State},
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::{Deserialize, Serialize};
use tracing::warn;
use crate::core::gh_account::{GH_DOCTOR_TIMEOUT, GhAuthProbe};
use crate::core::gh_login_probe::probe_gh_login;
use crate::core::trusty_tools_config::GithubConfig;
use crate::daemon::error::DaemonError;
use crate::daemon::project_adoption::{RegisterProjectResponse, adopt_pre_existing_worktrees};
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 gh_config_dir: Option<PathBuf>,
#[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 {
match list_projects_registry_op(&state).await {
Ok(body) => Json(body).into_response(),
Err(e) => plain(e),
}
}
fn plain(e: DaemonError) -> Response {
(e.status(), e.detail()).into_response()
}
pub async fn list_projects_registry_op(
state: &Arc<DaemonState>,
) -> Result<ProjectsListResponse, DaemonError> {
let registry = state.project_registry().await;
match registry.list().await {
Ok(projects) => {
let count = projects.len();
Ok(ProjectsListResponse { projects, count })
}
Err(e) => {
warn!(error = %e, "list_projects_registry_route: registry read failed");
Err(DaemonError::Internal(
"project registry read failed".to_string(),
))
}
}
}
pub async fn register_project_registry_route(
State(state): State<Arc<DaemonState>>,
Json(body): Json<RegisterProjectBody>,
) -> impl IntoResponse {
match register_project_registry_op(&state, body).await {
Ok(response) => (StatusCode::CREATED, Json(response)).into_response(),
Err(e) => plain(e),
}
}
pub async fn register_project_registry_op(
state: &Arc<DaemonState>,
body: RegisterProjectBody,
) -> Result<RegisterProjectResponse, DaemonError> {
if body.name.trim().is_empty() {
return Err(DaemonError::InvalidRequest(
"project name must not be empty".to_string(),
));
}
if body.repo_url.trim().is_empty() {
return Err(DaemonError::InvalidRequest(
"repo_url must not be empty".to_string(),
));
}
let registry = state.project_registry().await;
let existing = registry.get(&body.name).await.ok();
let existing_github = existing.as_ref().and_then(|p| p.github.clone());
let github = merge_gh_config_dir(
existing_github,
body.gh_config_dir.clone(),
body.gh_account.as_deref(),
);
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,
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 Err(DaemonError::Internal(
"project registry write failed".to_string(),
));
}
let adoption = adopt_pre_existing_worktrees(state, &project);
Ok(RegisterProjectResponse { project, adoption })
}
fn merge_gh_config_dir(
existing: Option<GithubConfig>,
gh_config_dir: Option<PathBuf>,
gh_account: Option<&str>,
) -> Option<GithubConfig> {
let Some(dir) = gh_config_dir else {
return existing;
};
let mut cfg = existing.unwrap_or_default();
cfg.config_dir = Some(dir);
if cfg
.account
.as_deref()
.map(str::trim)
.unwrap_or("")
.is_empty()
&& let Some(account) = gh_account.map(str::trim).filter(|s| !s.is_empty())
{
cfg.account = Some(account.to_string());
}
Some(cfg)
}
pub async fn get_project_registry_route(
State(state): State<Arc<DaemonState>>,
AxumPath(name): AxumPath<String>,
) -> impl IntoResponse {
match get_project_registry_op(&state, &name).await {
Ok(project) => Json(project).into_response(),
Err(e) => plain(e),
}
}
pub async fn get_project_registry_op(
state: &Arc<DaemonState>,
name: &str,
) -> Result<Project, DaemonError> {
let registry = state.project_registry().await;
match registry.get(name).await {
Ok(project) => Ok(project),
Err(ProjectStoreError::NotFound(_)) => {
Err(DaemonError::NotFound(format!("project {name} not found")))
}
Err(e) => {
warn!(error = %e, project = %name, "get_project_registry_route: registry read failed");
Err(DaemonError::Internal(
"project registry read failed".to_string(),
))
}
}
}
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 {
match patch_project_registry_op(&state, &name, body).await {
Ok(project) => Json(project).into_response(),
Err(e) => plain(e),
}
}
pub async fn patch_project_registry_op(
state: &Arc<DaemonState>,
name: &str,
body: PatchProjectBody,
) -> Result<Project, DaemonError> {
let probe = match body.gh_user.as_ref().and_then(|v| v.as_deref()) {
Some(login) if !login.trim().is_empty() => {
let record = state.project_registry().await.get(name).await.ok();
let origin = record.as_ref().map(|p| p.repo_url.clone());
let pinned = record
.as_ref()
.and_then(|p| p.github.as_ref())
.and_then(crate::core::gh_account_registry::selected_config_dir);
let login = login.trim().to_string();
Some(
tokio::task::spawn_blocking(move || {
probe_gh_login(&login, origin.as_deref(), pinned, GH_DOCTOR_TIMEOUT)
})
.await
.unwrap_or_else(|e| {
GhAuthProbe::Inconclusive(format!(
"could not run gh: the `gh auth status` probe failed: {e}"
))
}),
)
}
_ => None,
};
patch_project_registry_with_probe(Arc::clone(state), name.to_string(), body, probe).await
}
pub(crate) async fn patch_project_registry_with_probe(
state: Arc<DaemonState>,
name: String,
body: PatchProjectBody,
probe: Option<GhAuthProbe>,
) -> Result<Project, DaemonError> {
if let Some(new_name) = &body.name
&& new_name.trim() != name
{
return Err(DaemonError::InvalidRequest(
"project name is the identity key and cannot be changed via PATCH".to_string(),
));
}
if let Some(repo_url) = &body.repo_url
&& repo_url.trim().is_empty()
{
return Err(DaemonError::InvalidRequest(
"repo_url must not be empty".to_string(),
));
}
if let Some(default_branch) = &body.default_branch
&& default_branch.trim().is_empty()
{
return Err(DaemonError::InvalidRequest(
"default_branch must not be empty".to_string(),
));
}
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 Err(DaemonError::InvalidRequest(msg)),
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 Err(DaemonError::InvalidRequest(msg)),
None => None,
};
let gh_user = match body.gh_user {
Some(Some(login)) => Some(Some(validate_gh_user_login(&login, probe.as_ref())?)),
other => other,
};
let repo_url = body.repo_url;
let default_branch = body.default_branch;
let description = body.description;
let stack_hint = body.stack_hint;
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) => Ok(project),
Err(ProjectStoreError::NotFound(_)) => {
Err(DaemonError::NotFound(format!("project {name} not found")))
}
Err(e) => {
warn!(error = %e, project = %name, "patch_project_registry_route: registry write failed");
Err(DaemonError::Internal(
"project registry write failed".to_string(),
))
}
}
}
fn validate_gh_user_login(login: &str, probe: Option<&GhAuthProbe>) -> Result<String, DaemonError> {
let login = login.trim();
if login.is_empty() {
return Err(DaemonError::InvalidRequest(
"gh_user must not be blank — send null to clear it".to_string(),
));
}
match probe {
Some(GhAuthProbe::Answered(status)) => match status.canonical_logged_in_login(login) {
Some(canonical) => Ok(canonical),
None if status.logged_in.is_empty() => Err(DaemonError::InvalidRequest(format!(
"gh_user must name an account `gh auth status` reports as logged in, but gh \
reports no logged-in account — run `gh auth login` before setting gh_user \
to '{login}'"
))),
None => Err(DaemonError::InvalidRequest(format!(
"gh_user must name an account `gh auth status` reports as logged in; \
'{login}' is not one of: {}",
status.logged_in.join(", ")
))),
},
Some(GhAuthProbe::Inconclusive(why)) => Err(DaemonError::ServiceUnavailable(format!(
"gh_user '{login}' could not be verified: {why}"
))),
None => Err(DaemonError::ServiceUnavailable(format!(
"gh_user '{login}' could not be verified: no `gh auth status` answer was \
available for this request"
))),
}
}
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 std::path::Path;
use super::*;
use crate::core::gh_account::GhAccountStatus;
fn two_accounts() -> GhAuthProbe {
GhAuthProbe::Answered(GhAccountStatus {
active: Some("bob-duetto".to_string()),
logged_in: vec!["bob-duetto".to_string(), "bobmatnyc".to_string()],
})
}
#[test]
fn validate_gh_user_accepts_a_logged_in_login() {
assert_eq!(
validate_gh_user_login("BobMatNyc", Some(&two_accounts())).unwrap(),
"bobmatnyc"
);
assert_eq!(
validate_gh_user_login(" bob-duetto ", Some(&two_accounts())).unwrap(),
"bob-duetto"
);
}
#[test]
fn validate_gh_user_rejects_an_unknown_login() {
let error = validate_gh_user_login("typo-bot", Some(&two_accounts())).unwrap_err();
let (status, message) = (error.status(), error.detail());
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(message.contains("gh_user"), "{message}");
assert!(message.contains("typo-bot"), "{message}");
assert!(message.contains("bobmatnyc"), "{message}");
}
#[test]
fn validate_gh_user_rejects_a_blank_login() {
for blank in ["", " "] {
let error = validate_gh_user_login(blank, Some(&two_accounts())).unwrap_err();
let (status, message) = (error.status(), error.detail());
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(message.contains("null"), "{message}");
}
}
#[test]
fn validate_gh_user_rejects_a_host_with_no_accounts() {
let probe = GhAuthProbe::Answered(GhAccountStatus::default());
let error = validate_gh_user_login("bobmatnyc", Some(&probe)).unwrap_err();
let (status, message) = (error.status(), error.detail());
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(message.contains("gh auth login"), "{message}");
assert!(
message.contains("gh reports no logged-in account"),
"{message}"
);
assert!(!message.contains("could not run gh"), "{message}");
}
#[test]
fn validate_gh_user_is_503_when_gh_could_not_answer() {
let probe =
crate::core::gh_login_probe::classify_auth_status(Err("no such file".to_string()));
let error = validate_gh_user_login("bobmatnyc", Some(&probe)).unwrap_err();
let (status, message) = (error.status(), error.detail());
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
assert!(message.contains("could not be verified"), "{message}");
assert!(
message.contains("could not run gh: no such file"),
"{message}"
);
assert!(
!message.contains("reports no logged-in account"),
"{message}"
);
let error = validate_gh_user_login("bobmatnyc", None).unwrap_err();
assert_eq!(error.status(), StatusCode::SERVICE_UNAVAILABLE);
}
async fn state_with_widget() -> (Arc<DaemonState>, tempfile::TempDir) {
let tmp = tempfile::TempDir::new().expect("tempdir");
let state = Arc::new(DaemonState::with_root_isolated_managed(tmp.path().to_owned()).await);
state
.project_registry()
.await
.register(Project {
name: "widget".into(),
repo_url: "https://github.com/acme/widget".into(),
default_branch: "main".into(),
stack_hint: None,
tags: vec![],
description: None,
gh_user: Some("bob-duetto".into()),
gh_account: None,
github: None,
commit_name: None,
commit_email: None,
worktree: None,
})
.await
.expect("seed register");
(state, tmp)
}
#[tokio::test]
async fn patch_persists_a_gh_user_gh_reports_as_logged_in() {
let (state, _tmp) = state_with_widget().await;
let resp = patch_project_registry_with_probe(
Arc::clone(&state),
"widget".into(),
PatchProjectBody {
gh_user: Some(Some("BobMatNyc".into())),
..Default::default()
},
Some(two_accounts()),
)
.await;
assert!(resp.is_ok(), "{:?}", resp.err().map(|e| e.to_string()));
let stored = state
.project_registry()
.await
.get("widget")
.await
.expect("stored");
assert_eq!(stored.gh_user.as_deref(), Some("bobmatnyc"));
}
#[tokio::test]
async fn patch_rejects_an_unknown_gh_user_and_leaves_the_store_untouched() {
let (state, _tmp) = state_with_widget().await;
let before = state
.project_registry()
.await
.get("widget")
.await
.expect("seeded");
let resp = patch_project_registry_with_probe(
Arc::clone(&state),
"widget".into(),
PatchProjectBody {
description: Some(Some("should not persist".into())),
gh_user: Some(Some("typo-bot".into())),
..Default::default()
},
Some(two_accounts()),
)
.await;
assert_eq!(
resp.expect_err("the request must be refused").status(),
StatusCode::BAD_REQUEST
);
let after = state
.project_registry()
.await
.get("widget")
.await
.expect("still there");
assert_eq!(after, before);
}
#[tokio::test]
async fn patch_clears_gh_user_without_a_probe() {
let (state, _tmp) = state_with_widget().await;
let resp = patch_project_registry_with_probe(
Arc::clone(&state),
"widget".into(),
PatchProjectBody {
gh_user: Some(None),
..Default::default()
},
None,
)
.await;
assert!(resp.is_ok(), "{:?}", resp.err().map(|e| e.to_string()));
assert_eq!(
state
.project_registry()
.await
.get("widget")
.await
.expect("stored")
.gh_user,
None
);
}
#[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",
"gh_config_dir": "/home/bob/.config/gh-acme"
});
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"));
assert_eq!(
body.gh_config_dir.as_deref(),
Some(Path::new("/home/bob/.config/gh-acme"))
);
}
#[test]
fn merge_gh_config_dir_absent_preserves() {
let existing = GithubConfig {
config_dir: Some(PathBuf::from("/keep/me")),
account: Some("keeper".to_string()),
..GithubConfig::default()
};
let merged = merge_gh_config_dir(Some(existing.clone()), None, Some("other"));
assert_eq!(merged, Some(existing));
assert_eq!(merge_gh_config_dir(None, None, Some("other")), None);
}
#[test]
fn merge_gh_config_dir_sets_both_keys() {
let merged = merge_gh_config_dir(None, Some(PathBuf::from("/gh/acme")), Some("acme-bot"))
.expect("some");
assert_eq!(merged.config_dir.as_deref(), Some(Path::new("/gh/acme")));
assert_eq!(merged.account.as_deref(), Some("acme-bot"));
}
#[test]
fn merge_gh_config_dir_keeps_existing_account() {
let existing = GithubConfig {
account: Some("configured".to_string()),
..GithubConfig::default()
};
let merged = merge_gh_config_dir(
Some(existing),
Some(PathBuf::from("/gh/acme")),
Some("flag-account"),
)
.expect("some");
assert_eq!(merged.account.as_deref(), Some("configured"));
assert_eq!(merged.config_dir.as_deref(), Some(Path::new("/gh/acme")));
}
#[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());
}
}