Skip to main content

things_mcp/tools/
projects.rs

1//! Read tools that surface a single project.
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6use crate::core::reader::queries::get_project;
7use crate::core::types::MaybeProject;
8use crate::state::AppState;
9
10#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
11pub struct GetProjectArgs {
12    /// The project's UUID (`TMTask.uuid` where `type = 1`).
13    pub id: String,
14}
15
16pub async fn things_get_project(
17    state: AppState,
18    args: GetProjectArgs,
19) -> anyhow::Result<MaybeProject> {
20    let project = get_project(&state.pool, args.id).await?;
21    Ok(MaybeProject { project })
22}
23
24use std::time::{SystemTime, UNIX_EPOCH};
25
26use crate::core::writer::operation::{AddProjectSpec, Operation, UpdateProjectSpec};
27use crate::core::writer::outcome::WriteOutcome;
28use crate::core::writer::verify::VerifyPredicate;
29
30#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, Default)]
31pub struct AddProjectArgs {
32    /// Project title. Required, non-empty.
33    pub title: String,
34    #[serde(default)]
35    pub notes: Option<String>,
36    #[serde(default)]
37    pub when: Option<String>,
38    #[serde(default)]
39    pub deadline: Option<String>,
40    #[serde(default)]
41    pub tags: Vec<String>,
42    /// Parent area UUID.
43    #[serde(default)]
44    pub area_id: Option<String>,
45    /// Initial heading titles. Order preserved.
46    #[serde(default)]
47    pub headings: Vec<String>,
48}
49
50pub async fn things_add_project(
51    state: AppState,
52    args: AddProjectArgs,
53) -> anyhow::Result<WriteOutcome> {
54    if args.title.trim().is_empty() {
55        return Err(crate::core::error::ThingsError::InvalidInput {
56            field: "title".into(),
57            reason: "title must be non-empty".into(),
58        }
59        .into());
60    }
61    let since_unix = SystemTime::now()
62        .duration_since(UNIX_EPOCH)
63        .map(|d| d.as_secs_f64())
64        .unwrap_or(0.0);
65    let op = Operation::AddProject(AddProjectSpec {
66        title: args.title.clone(),
67        notes: args.notes,
68        when: args.when,
69        deadline: args.deadline,
70        tags: args.tags,
71        area_id: args.area_id,
72        todos: Vec::new(),
73        headings: args.headings,
74    });
75    let predicate = VerifyPredicate::CreateByTitle {
76        title: args.title,
77        since_unix,
78        kind: crate::core::types::TaskKind::Project,
79    };
80    let outcome = state.writer.fire(op, Some(predicate)).await?;
81    Ok(outcome)
82}
83
84#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, Default)]
85pub struct UpdateProjectArgs {
86    /// UUID of the project to update.
87    pub id: String,
88    #[serde(default)]
89    pub title: Option<String>,
90    #[serde(default)]
91    pub notes: Option<String>,
92    #[serde(default)]
93    pub when: Option<String>,
94    #[serde(default)]
95    pub deadline: Option<String>,
96    /// `None` = leave tags unchanged. `Some(vec![])` = clear all tags.
97    #[serde(default)]
98    pub tags: Option<Vec<String>>,
99    #[serde(default)]
100    pub area_id: Option<String>,
101    #[serde(default)]
102    pub completed: Option<bool>,
103    #[serde(default)]
104    pub canceled: Option<bool>,
105}
106
107pub async fn things_update_project(
108    state: AppState,
109    args: UpdateProjectArgs,
110) -> anyhow::Result<WriteOutcome> {
111    if args.id.trim().is_empty() {
112        return Err(crate::core::error::ThingsError::InvalidInput {
113            field: "id".into(),
114            reason: "id must be non-empty".into(),
115        }
116        .into());
117    }
118    let op = Operation::UpdateProject(UpdateProjectSpec {
119        id: args.id.clone(),
120        title: args.title.clone(),
121        notes: args.notes.clone(),
122        when: args.when,
123        deadline: args.deadline,
124        tags: args.tags,
125        area_id: args.area_id,
126        completed: args.completed,
127        canceled: args.canceled,
128    });
129    let predicate = VerifyPredicate::UpdateById {
130        id: args.id,
131        expected_title: args.title,
132        expected_notes: args.notes,
133    };
134    let outcome = state.writer.fire(op, Some(predicate)).await?;
135    Ok(outcome)
136}