Skip to main content

max / goingson

8.6 KB · 245 lines History Blame Raw
1 //! Project tools: `list_projects` (read), `create_project` (write, idempotent
2 //! on name), and `update_project` (write, overlays the fields you pass).
3 //!
4 //! There is deliberately no `delete_project`: the repository delete is a hard
5 //! DELETE with no soft-delete behind it. `Archived` is the non-destructive
6 //! retire path, and it is what a session should reach for.
7 //!
8 //! Enums cross the wire as [`DbValue::db_value`], not `as_str`. `as_str` is the
9 //! UI's display string ("Side Project", "On Hold") and does not round-trip
10 //! through `FromStr`, so emitting it would hand a caller a value its own write
11 //! tools reject.
12
13 use std::sync::Arc;
14
15 use async_trait::async_trait;
16 use goingson_core::models::DbValue;
17 use goingson_core::repository::ProjectRepository;
18 use goingson_core::{NewProject, ProjectStatus, ProjectType, UpdateProject};
19 use kberg::{Error, Result, Tool, ToolCallResult, ToolKind};
20 use serde_json::{Value, json};
21
22 use crate::caps;
23 use crate::context::Ctx;
24 use crate::convert::{parse_enum, req_str};
25
26 /// The accepted wire words, mirroring each enum's `strum` serialize form (which
27 /// is what `FromStr` parses). Listed back to a caller that gets one wrong.
28 const PROJECT_TYPES: &[&str] = &[
29 "Job",
30 "SideProject",
31 "Company",
32 "Essay",
33 "Article",
34 "Painting",
35 "Other",
36 ];
37 const PROJECT_STATUSES: &[&str] = &["Active", "OnHold", "Completed", "Archived"];
38
39 fn fail(tool: &str, e: impl std::fmt::Display) -> Error {
40 Error::ToolFailed {
41 tool: tool.to_string(),
42 message: e.to_string(),
43 }
44 }
45
46 pub struct ListProjects(pub Arc<Ctx>);
47
48 #[async_trait]
49 impl Tool for ListProjects {
50 fn name(&self) -> &str {
51 "list_projects"
52 }
53 fn description(&self) -> &str {
54 "List all projects (id, name, type, status). Use to resolve a project name to its id before creating tasks."
55 }
56 fn kind(&self) -> ToolKind {
57 ToolKind::Read
58 }
59 fn small_model_safe(&self) -> bool {
60 true
61 }
62 fn input_schema(&self) -> Value {
63 json!({ "type": "object", "properties": {} })
64 }
65 async fn call(&self, _args: Value) -> Result<ToolCallResult> {
66 let projects = self
67 .0
68 .projects()
69 .list_all(self.0.user_id)
70 .await
71 .map_err(|e| fail(self.name(), e))?;
72 let rows: Vec<Value> = projects
73 .iter()
74 .map(|p| {
75 json!({
76 "id": p.id.to_string(),
77 "name": p.name,
78 "type": p.project_type.db_value(),
79 "status": p.status.db_value(),
80 })
81 })
82 .collect();
83 Ok(ToolCallResult::text(
84 serde_json::to_string(&json!({ "projects": rows })).unwrap(),
85 ))
86 }
87 }
88
89 pub struct CreateProject(pub Arc<Ctx>);
90
91 #[async_trait]
92 impl Tool for CreateProject {
93 fn name(&self) -> &str {
94 "create_project"
95 }
96 fn description(&self) -> &str {
97 "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)."
98 }
99 fn kind(&self) -> ToolKind {
100 ToolKind::Write(caps::project_create())
101 }
102 fn input_schema(&self) -> Value {
103 json!({
104 "type": "object",
105 "properties": {
106 "name": { "type": "string" },
107 "type": { "type": "string", "description": "Job | SideProject | Company | Essay | Article | Painting | Other" },
108 "description": { "type": "string" }
109 },
110 "required": ["name"]
111 })
112 }
113 async fn call(&self, args: Value) -> Result<ToolCallResult> {
114 let name = req_str(self.name(), &args, "name")?;
115 let repo = self.0.projects();
116
117 // Idempotency: return the existing project rather than duplicating.
118 if let Some(existing) = repo
119 .find_by_name(self.0.user_id, name)
120 .await
121 .map_err(|e| fail(self.name(), e))?
122 {
123 return Ok(ToolCallResult::text(
124 serde_json::to_string(&json!({
125 "id": existing.id.to_string(),
126 "created": false,
127 }))
128 .unwrap(),
129 ));
130 }
131
132 let project_type = match args.get("type").and_then(Value::as_str) {
133 Some(t) => parse_enum(self.name(), "type", t, PROJECT_TYPES)?,
134 None => ProjectType::SideProject,
135 };
136 let description = args
137 .get("description")
138 .and_then(Value::as_str)
139 .unwrap_or_default()
140 .to_string();
141
142 let created = repo
143 .create(
144 self.0.user_id,
145 NewProject {
146 name: name.to_string(),
147 description,
148 project_type,
149 status: ProjectStatus::default(),
150 },
151 )
152 .await
153 .map_err(|e| fail(self.name(), e))?;
154
155 Ok(ToolCallResult::text(
156 serde_json::to_string(&json!({
157 "id": created.id.to_string(),
158 "created": true,
159 }))
160 .unwrap(),
161 ))
162 }
163 }
164
165 /// Named `...Tool` to leave [`UpdateProject`] to the domain model, matching
166 /// `UpdateTaskTool` in `task.rs`.
167 pub struct UpdateProjectTool(pub Arc<Ctx>);
168
169 #[async_trait]
170 impl Tool for UpdateProjectTool {
171 fn name(&self) -> &str {
172 "update_project"
173 }
174 fn description(&self) -> &str {
175 "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."
176 }
177 fn kind(&self) -> ToolKind {
178 ToolKind::Write(caps::project_update())
179 }
180 fn input_schema(&self) -> Value {
181 json!({
182 "type": "object",
183 "properties": {
184 "project": { "type": "string", "description": "The project's current name." },
185 "status": { "type": "string", "description": "Active | OnHold | Completed | Archived" },
186 "type": { "type": "string", "description": "Job | SideProject | Company | Essay | Article | Painting | Other" },
187 "description": { "type": "string" }
188 },
189 "required": ["project"]
190 })
191 }
192 async fn call(&self, args: Value) -> Result<ToolCallResult> {
193 let name = req_str(self.name(), &args, "project")?;
194 let repo = self.0.projects();
195
196 // Resolve by name, like create_task does, so callers never handle ids.
197 let current = repo
198 .find_by_name(self.0.user_id, name)
199 .await
200 .map_err(|e| fail(self.name(), e))?
201 .ok_or_else(|| Error::ToolFailed {
202 tool: self.name().to_string(),
203 message: format!("no project named `{name}`"),
204 })?;
205
206 // Start from the current project, overlay only the provided fields.
207 // The name rides through untouched: it is the resolution key.
208 let mut patch = UpdateProject {
209 name: current.name.clone(),
210 description: current.description.clone(),
211 project_type: current.project_type.clone(),
212 status: current.status.clone(),
213 };
214 if let Some(status) = args.get("status").and_then(Value::as_str) {
215 patch.status = parse_enum(self.name(), "status", status, PROJECT_STATUSES)?;
216 }
217 if let Some(ty) = args.get("type").and_then(Value::as_str) {
218 patch.project_type = parse_enum(self.name(), "type", ty, PROJECT_TYPES)?;
219 }
220 if let Some(desc) = args.get("description").and_then(Value::as_str) {
221 patch.description = desc.to_string();
222 }
223
224 let updated = repo
225 .update(current.id, self.0.user_id, patch)
226 .await
227 .map_err(|e| fail(self.name(), e))?
228 .ok_or_else(|| Error::ToolFailed {
229 tool: self.name().to_string(),
230 message: format!("project `{name}` vanished during update"),
231 })?;
232
233 Ok(ToolCallResult::text(
234 serde_json::to_string(&json!({
235 "id": updated.id.to_string(),
236 "name": updated.name,
237 "type": updated.project_type.db_value(),
238 "status": updated.status.db_value(),
239 "description": updated.description,
240 }))
241 .unwrap(),
242 ))
243 }
244 }
245