use std::sync::Arc;
use serde_json::{Value, json};
use crate::daemon::state::DaemonState;
use crate::project::record::Project;
use crate::project::resolver;
pub async fn project_list(state: &Arc<DaemonState>) -> Result<Value, String> {
let registry = state.project_registry().await;
let projects = registry
.list()
.await
.map_err(|e| format!("project_list: registry error: {e}"))?;
let values = projects
.into_iter()
.map(|p| serde_json::to_value(p).map_err(|e| format!("project_list: serialize error: {e}")))
.collect::<Result<Vec<Value>, String>>()?;
let count = values.len();
Ok(json!({ "projects": values, "count": count }))
}
#[allow(clippy::too_many_arguments)]
pub async fn project_register(
state: &Arc<DaemonState>,
name: &str,
repo_url: &str,
default_branch: Option<&str>,
stack_hint: Option<&str>,
tags: Option<Vec<String>>,
description: Option<&str>,
gh_user: Option<&str>,
gh_account: Option<&str>,
) -> Result<Value, String> {
let registry = state.project_registry().await;
let existing = registry.get(name).await.ok();
let project = Project {
name: name.to_string(),
repo_url: repo_url.to_string(),
default_branch: default_branch.unwrap_or("main").to_string(),
stack_hint: stack_hint
.map(str::to_string)
.or_else(|| existing.as_ref().and_then(|p| p.stack_hint.clone())),
tags: tags
.or_else(|| existing.as_ref().map(|p| p.tags.clone()))
.unwrap_or_default(),
description: description
.map(str::to_string)
.or_else(|| existing.as_ref().and_then(|p| p.description.clone())),
gh_user: gh_user
.map(str::to_string)
.or_else(|| existing.as_ref().and_then(|p| p.gh_user.clone())),
gh_account: gh_account
.map(str::to_string)
.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: existing.as_ref().and_then(|p| p.worktree),
};
registry
.register(project.clone())
.await
.map_err(|e| format!("project_register: registry error: {e}"))?;
serde_json::to_value(&project).map_err(|e| e.to_string())
}
pub async fn project_get(state: &Arc<DaemonState>, name: &str) -> Result<Value, String> {
let registry = state.project_registry().await;
let project = registry
.get(name)
.await
.map_err(|e| format!("project `{name}` not found: {e}"))?;
serde_json::to_value(&project).map_err(|e| e.to_string())
}
pub async fn project_resolve(state: &Arc<DaemonState>, query: &str) -> Result<Value, String> {
let registry = state.project_registry().await;
let projects = registry
.list()
.await
.map_err(|e| format!("project_resolve: registry error: {e}"))?;
match resolver::resolve_project(query, &projects) {
Ok(resolution) => {
let primary_val = resolution.primary.as_ref().map(|m| {
json!({
"project": serde_json::to_value(&m.project)
.unwrap_or(Value::Null),
"confidence": m.confidence,
"reason": m.reason.label(),
})
});
let all_matches: Vec<Value> = resolution
.matches
.iter()
.map(|m| {
json!({
"project": serde_json::to_value(&m.project)
.unwrap_or(Value::Null),
"confidence": m.confidence,
"reason": m.reason.label(),
})
})
.collect();
Ok(json!({
"primary": primary_val,
"needs_disambiguation": resolution.needs_disambiguation(),
"matches": all_matches,
}))
}
Err(resolver::ResolverError::NoMatch { query: q }) => Ok(json!({
"primary": null,
"needs_disambiguation": false,
"matches": [],
"error": format!("no project matched query: {q:?}"),
})),
Err(resolver::ResolverError::EmptyRegistry) => Ok(json!({
"primary": null,
"needs_disambiguation": false,
"matches": [],
"error": "project registry is empty; register at least one project first",
})),
}
}
#[cfg(test)]
mod tests {
use super::*;
async fn isolated_state() -> Arc<DaemonState> {
let root = tempfile::tempdir().expect("tempdir").keep();
Arc::new(DaemonState::with_root_isolated_managed(root).await)
}
#[tokio::test]
async fn project_register_preserves_unspecified_optional_fields() {
let state = isolated_state().await;
project_register(
&state,
"widget",
"https://github.com/acme/widget",
None,
Some("rust"),
Some(vec!["backend".to_string()]),
Some("the widget project"),
Some("bobmatnyc"),
None,
)
.await
.expect("initial register");
let result = project_register(
&state,
"widget",
"https://github.com/acme/widget",
None,
None,
None,
None,
None,
Some("bob-work"),
)
.await
.expect("gh_account-only re-register");
assert_eq!(result["gh_account"], "bob-work");
assert_eq!(result["stack_hint"], "rust");
assert_eq!(result["description"], "the widget project");
assert_eq!(result["gh_user"], "bobmatnyc");
assert_eq!(result["tags"][0], "backend");
}
#[tokio::test]
async fn project_register_explicit_fields_still_override_existing() {
let state = isolated_state().await;
project_register(
&state,
"widget",
"https://github.com/acme/widget",
None,
Some("rust"),
None,
None,
None,
Some("bobmatnyc"),
)
.await
.expect("initial register");
let result = project_register(
&state,
"widget",
"https://github.com/acme/widget",
None,
Some("python"),
None,
None,
None,
Some("bob-work"),
)
.await
.expect("overriding re-register");
assert_eq!(result["stack_hint"], "python");
assert_eq!(result["gh_account"], "bob-work");
}
}