//! Project tools: `list_projects` (read), `create_project` (write, idempotent //! on name), and `update_project` (write, overlays the fields you pass). //! //! There is deliberately no `delete_project`: the repository delete is a hard //! DELETE with no soft-delete behind it. `Archived` is the non-destructive //! retire path, and it is what a session should reach for. //! //! Enums cross the wire as [`DbValue::db_value`], not `as_str`. `as_str` is the //! UI's display string ("Side Project", "On Hold") and does not round-trip //! through `FromStr`, so emitting it would hand a caller a value its own write //! tools reject. use std::sync::Arc; use async_trait::async_trait; use goingson_core::models::DbValue; use goingson_core::repository::ProjectRepository; use goingson_core::{NewProject, ProjectStatus, ProjectType, UpdateProject}; use kberg::{Error, Result, Tool, ToolCallResult, ToolKind}; use serde_json::{Value, json}; use crate::caps; use crate::context::Ctx; use crate::convert::{parse_enum, req_str}; /// The accepted wire words, mirroring each enum's `strum` serialize form (which /// is what `FromStr` parses). Listed back to a caller that gets one wrong. const PROJECT_TYPES: &[&str] = &[ "Job", "SideProject", "Company", "Essay", "Article", "Painting", "Other", ]; const PROJECT_STATUSES: &[&str] = &["Active", "OnHold", "Completed", "Archived"]; 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.db_value(), "status": p.status.db_value(), }) }) .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, Essay, Article, Painting, Other (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 | Essay | Article | Painting | Other" }, "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 = match args.get("type").and_then(Value::as_str) { Some(t) => parse_enum(self.name(), "type", t, PROJECT_TYPES)?, None => 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(), )) } } /// Named `...Tool` to leave [`UpdateProject`] to the domain model, matching /// `UpdateTaskTool` in `task.rs`. pub struct UpdateProjectTool(pub Arc); #[async_trait] impl Tool for UpdateProjectTool { fn name(&self) -> &str { "update_project" } fn description(&self) -> &str { "Update an existing project, resolved by `project` (its name). Only the fields you pass change. `status` is Active|OnHold|Completed|Archived. Archived is the non-destructive way to retire a project. `type` is Job|SideProject|Company|Essay|Article|Painting|Other. Renaming is not offered: the name is how create_task and bulk_import_tasks resolve a project, so a rename would strand callers." } fn kind(&self) -> ToolKind { ToolKind::Write(caps::project_update()) } fn input_schema(&self) -> Value { json!({ "type": "object", "properties": { "project": { "type": "string", "description": "The project's current name." }, "status": { "type": "string", "description": "Active | OnHold | Completed | Archived" }, "type": { "type": "string", "description": "Job | SideProject | Company | Essay | Article | Painting | Other" }, "description": { "type": "string" } }, "required": ["project"] }) } async fn call(&self, args: Value) -> Result { let name = req_str(self.name(), &args, "project")?; let repo = self.0.projects(); // Resolve by name, like create_task does, so callers never handle ids. let current = repo .find_by_name(self.0.user_id, name) .await .map_err(|e| fail(self.name(), e))? .ok_or_else(|| Error::ToolFailed { tool: self.name().to_string(), message: format!("no project named `{name}`"), })?; // Start from the current project, overlay only the provided fields. // The name rides through untouched: it is the resolution key. let mut patch = UpdateProject { name: current.name.clone(), description: current.description.clone(), project_type: current.project_type.clone(), status: current.status.clone(), }; if let Some(status) = args.get("status").and_then(Value::as_str) { patch.status = parse_enum(self.name(), "status", status, PROJECT_STATUSES)?; } if let Some(ty) = args.get("type").and_then(Value::as_str) { patch.project_type = parse_enum(self.name(), "type", ty, PROJECT_TYPES)?; } if let Some(desc) = args.get("description").and_then(Value::as_str) { patch.description = desc.to_string(); } let updated = repo .update(current.id, self.0.user_id, patch) .await .map_err(|e| fail(self.name(), e))? .ok_or_else(|| Error::ToolFailed { tool: self.name().to_string(), message: format!("project `{name}` vanished during update"), })?; Ok(ToolCallResult::text( serde_json::to_string(&json!({ "id": updated.id.to_string(), "name": updated.name, "type": updated.project_type.db_value(), "status": updated.status.db_value(), "description": updated.description, })) .unwrap(), )) } }