//! Project tools: `list_projects` (read) and `create_project` (write, //! idempotent on name). use std::sync::Arc; use async_trait::async_trait; use goingson_core::repository::ProjectRepository; use goingson_core::models::ParseableEnum; use goingson_core::{NewProject, ProjectStatus, ProjectType}; use kberg::{Error, Result, Tool, ToolCallResult, ToolKind}; use serde_json::{Value, json}; use crate::caps; use crate::context::Ctx; use crate::convert::req_str; fn fail(tool: &str, e: impl std::fmt::Display) -> Error { Error::ToolFailed { tool: tool.to_string(), message: e.to_string(), } } pub struct ListProjects(pub Arc); #[async_trait] impl Tool for ListProjects { fn name(&self) -> &str { "list_projects" } fn description(&self) -> &str { "List all projects (id, name, type, status). Use to resolve a project name to its id before creating tasks." } fn kind(&self) -> ToolKind { ToolKind::Read } fn small_model_safe(&self) -> bool { true } fn input_schema(&self) -> Value { json!({ "type": "object", "properties": {} }) } async fn call(&self, _args: Value) -> Result { let projects = self .0 .projects() .list_all(self.0.user_id) .await .map_err(|e| fail(self.name(), e))?; let rows: Vec = projects .iter() .map(|p| { json!({ "id": p.id.to_string(), "name": p.name, "type": p.project_type.as_str(), "status": p.status.as_str(), }) }) .collect(); Ok(ToolCallResult::text( serde_json::to_string(&json!({ "projects": rows })).unwrap(), )) } } pub struct CreateProject(pub Arc); #[async_trait] impl Tool for CreateProject { fn name(&self) -> &str { "create_project" } fn description(&self) -> &str { "Create a project. Idempotent on name: if a project with the same name exists, its id is returned instead of creating a duplicate. `type` is one of Job, SideProject, Company, Writing (defaults to SideProject)." } fn kind(&self) -> ToolKind { ToolKind::Write(caps::project_create()) } fn input_schema(&self) -> Value { json!({ "type": "object", "properties": { "name": { "type": "string" }, "type": { "type": "string", "description": "Job | SideProject | Company | Writing" }, "description": { "type": "string" } }, "required": ["name"] }) } async fn call(&self, args: Value) -> Result { let name = req_str(self.name(), &args, "name")?; let repo = self.0.projects(); // Idempotency: return the existing project rather than duplicating. if let Some(existing) = repo .find_by_name(self.0.user_id, name) .await .map_err(|e| fail(self.name(), e))? { return Ok(ToolCallResult::text( serde_json::to_string(&json!({ "id": existing.id.to_string(), "created": false, })) .unwrap(), )); } let project_type = args .get("type") .and_then(Value::as_str) .map(ProjectType::from_str_or_default) .unwrap_or(ProjectType::SideProject); let description = args .get("description") .and_then(Value::as_str) .unwrap_or_default() .to_string(); let created = repo .create( self.0.user_id, NewProject { name: name.to_string(), description, project_type, status: ProjectStatus::default(), }, ) .await .map_err(|e| fail(self.name(), e))?; Ok(ToolCallResult::text( serde_json::to_string(&json!({ "id": created.id.to_string(), "created": true, })) .unwrap(), )) } }