handoff-mcp-server 0.8.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,196 @@
1
+ //! Project-level calendar, label, and project-start tools.
2
+ //! - handoff_update_calendar: patch the `[calendar]` section.
3
+ //! - handoff_update_labels: set the top-level `labels` array.
4
+ //! - handoff_start_project: set `started_at` and (optionally) shift all task
5
+ //! dates so the earliest start aligns to the project start date.
6
+
7
+ use std::path::Path;
8
+
9
+ use anyhow::Result;
10
+ use chrono::{Duration, NaiveDate, Utc};
11
+ use serde_json::Value;
12
+
13
+ use super::config_crud::{
14
+ config_path, ensure_table, load_doc, save_doc, set_f64_map, set_mixed_array, set_opt_f64,
15
+ set_opt_str, set_string_array,
16
+ };
17
+
18
+ /// handoff_update_calendar — patch the `[calendar]` section. Only provided
19
+ /// fields are changed; passing JSON null clears a field.
20
+ pub fn handle_update_calendar(arguments: &Value) -> Result<String> {
21
+ let path = config_path(arguments)?;
22
+ let mut doc = load_doc(&path)?;
23
+
24
+ let cal = ensure_table(&mut doc, "calendar")?;
25
+ cal.set_implicit(false);
26
+ set_opt_f64(
27
+ cal,
28
+ "work_hours_per_day",
29
+ arguments.get("work_hours_per_day"),
30
+ );
31
+ set_mixed_array(cal, "closed_weekdays", arguments.get("closed_weekdays"));
32
+ set_string_array(cal, "closed_dates", arguments.get("closed_dates"));
33
+ set_string_array(cal, "open_dates", arguments.get("open_dates"));
34
+ set_f64_map(cal, "day_hours", arguments.get("day_hours"));
35
+ set_opt_str(cal, "schedule_mode", arguments.get("schedule_mode"));
36
+ set_opt_f64(
37
+ cal,
38
+ "overwork_limit_percent",
39
+ arguments.get("overwork_limit_percent"),
40
+ );
41
+ set_opt_f64(cal, "max_utilization", arguments.get("max_utilization"));
42
+
43
+ // schedule_mode is also accepted at the top level for parity with VSCode.
44
+ if let Some(mode) = arguments.get("schedule_mode").and_then(|v| v.as_str()) {
45
+ doc["schedule_mode"] = toml_edit::Item::Value(toml_edit::Value::from(mode));
46
+ }
47
+
48
+ save_doc(&path, &doc)?;
49
+ Ok("Updated calendar configuration".to_string())
50
+ }
51
+
52
+ /// handoff_update_labels — replace the top-level `labels` array.
53
+ pub fn handle_update_labels(arguments: &Value) -> Result<String> {
54
+ let path = config_path(arguments)?;
55
+ let labels = arguments
56
+ .get("labels")
57
+ .and_then(|v| v.as_array())
58
+ .ok_or_else(|| anyhow::anyhow!("'labels' (array of strings) is required"))?;
59
+
60
+ let mut doc = load_doc(&path)?;
61
+ let mut arr = toml_edit::Array::new();
62
+ for it in labels {
63
+ if let Some(s) = it.as_str() {
64
+ arr.push(s);
65
+ }
66
+ }
67
+ let count = arr.len();
68
+ doc["labels"] = toml_edit::Item::Value(toml_edit::Value::Array(arr));
69
+
70
+ save_doc(&path, &doc)?;
71
+ Ok(format!("Set {count} project label(s)"))
72
+ }
73
+
74
+ /// handoff_start_project — record the project start timestamp and, when
75
+ /// `shift_dates` is true, move every task's start/due dates so the earliest
76
+ /// start lands on `start_date`. Mirrors the VSCode startProject command.
77
+ pub fn handle_start_project(arguments: &Value) -> Result<String> {
78
+ let path = config_path(arguments)?;
79
+
80
+ // start_date defaults to today (UTC).
81
+ let start_date_str = match arguments.get("start_date").and_then(|v| v.as_str()) {
82
+ Some(s) => s.to_string(),
83
+ None => Utc::now().format("%Y-%m-%d").to_string(),
84
+ };
85
+ let start_date = NaiveDate::parse_from_str(&start_date_str, "%Y-%m-%d").map_err(|_| {
86
+ anyhow::anyhow!("Invalid start_date '{start_date_str}' (expected YYYY-MM-DD)")
87
+ })?;
88
+
89
+ let mut doc = load_doc(&path)?;
90
+ doc["started_at"] = toml_edit::Item::Value(toml_edit::Value::from(start_date_str.as_str()));
91
+ save_doc(&path, &doc)?;
92
+
93
+ let shift = arguments
94
+ .get("shift_dates")
95
+ .and_then(|v| v.as_bool())
96
+ .unwrap_or(false);
97
+
98
+ if !shift {
99
+ return Ok(format!("Set project start to {start_date_str}"));
100
+ }
101
+
102
+ let tasks_dir = path
103
+ .parent()
104
+ .map(|p| p.join("tasks"))
105
+ .ok_or_else(|| anyhow::anyhow!("Cannot locate tasks dir"))?;
106
+ let shifted = shift_all_dates(&tasks_dir, start_date)?;
107
+
108
+ Ok(format!(
109
+ "Set project start to {start_date_str} and shifted dates on {shifted} task(s)"
110
+ ))
111
+ }
112
+
113
+ /// Find the earliest task start date and shift all task start/due dates so that
114
+ /// earliest start aligns to `target`. Returns the number of tasks changed.
115
+ fn shift_all_dates(tasks_dir: &Path, target: NaiveDate) -> Result<usize> {
116
+ use crate::storage::tasks::{read_task, write_task};
117
+
118
+ let dirs = collect_task_dirs(tasks_dir)?;
119
+
120
+ // Pass 1: find earliest start date.
121
+ let mut earliest: Option<NaiveDate> = None;
122
+ for dir in &dirs {
123
+ if let Some((data, _)) = read_task(dir)? {
124
+ if let Some(sd) = data.schedule.as_ref().and_then(|s| s.start_date.as_deref()) {
125
+ if let Ok(d) = NaiveDate::parse_from_str(sd, "%Y-%m-%d") {
126
+ earliest = Some(earliest.map_or(d, |e| e.min(d)));
127
+ }
128
+ }
129
+ }
130
+ }
131
+ let earliest = match earliest {
132
+ Some(d) => d,
133
+ None => return Ok(0), // no dated tasks → nothing to shift
134
+ };
135
+ let delta = (target - earliest).num_days();
136
+ if delta == 0 {
137
+ return Ok(0);
138
+ }
139
+
140
+ // Pass 2: apply the shift.
141
+ let mut count = 0;
142
+ for dir in &dirs {
143
+ if let Some((mut data, status)) = read_task(dir)? {
144
+ let mut changed = false;
145
+ if let Some(sched) = data.schedule.as_mut() {
146
+ if let Some(sd) = sched.start_date.as_deref() {
147
+ if let Ok(d) = NaiveDate::parse_from_str(sd, "%Y-%m-%d") {
148
+ sched.start_date = Some(shift(d, delta));
149
+ changed = true;
150
+ }
151
+ }
152
+ if let Some(dd) = sched.due_date.as_deref() {
153
+ if let Ok(d) = NaiveDate::parse_from_str(dd, "%Y-%m-%d") {
154
+ sched.due_date = Some(shift(d, delta));
155
+ changed = true;
156
+ }
157
+ }
158
+ }
159
+ if changed {
160
+ data.updated_at = Some(Utc::now().to_rfc3339());
161
+ write_task(dir, &status, &data)?;
162
+ count += 1;
163
+ }
164
+ }
165
+ }
166
+ Ok(count)
167
+ }
168
+
169
+ fn shift(date: NaiveDate, delta_days: i64) -> String {
170
+ (date + Duration::days(delta_days))
171
+ .format("%Y-%m-%d")
172
+ .to_string()
173
+ }
174
+
175
+ fn collect_task_dirs(dir: &Path) -> Result<Vec<std::path::PathBuf>> {
176
+ let mut out = Vec::new();
177
+ collect_into(dir, &mut out)?;
178
+ Ok(out)
179
+ }
180
+
181
+ fn collect_into(dir: &Path, out: &mut Vec<std::path::PathBuf>) -> Result<()> {
182
+ if !dir.exists() {
183
+ return Ok(());
184
+ }
185
+ for entry in std::fs::read_dir(dir)? {
186
+ let entry = entry?;
187
+ let path = entry.path();
188
+ if path.is_dir() {
189
+ if crate::storage::tasks::find_task_file(&path)?.is_some() {
190
+ out.push(path.clone());
191
+ }
192
+ collect_into(&path, out)?;
193
+ }
194
+ }
195
+ Ok(())
196
+ }
@@ -0,0 +1,316 @@
1
+ use anyhow::{Context, Result};
2
+ use chrono::{Datelike, NaiveDate};
3
+ use serde_json::{json, Value};
4
+ use toml_edit::DocumentMut;
5
+
6
+ use super::resolve_project_dir;
7
+ use crate::storage::ensure_handoff_exists;
8
+ use crate::storage::tasks::{build_task_index, is_terminal_status, TaskIndex};
9
+
10
+ pub fn handle(arguments: &Value) -> Result<String> {
11
+ let project_dir = resolve_project_dir(arguments)?;
12
+ let handoff = ensure_handoff_exists(&project_dir)?;
13
+ let config_path = handoff.join("config.toml");
14
+ let tasks_dir = handoff.join("tasks");
15
+
16
+ let start_date_str = arguments
17
+ .get("start_date")
18
+ .and_then(|v| v.as_str())
19
+ .ok_or_else(|| anyhow::anyhow!("'start_date' parameter is required (YYYY-MM-DD)"))?;
20
+ let end_date_str = arguments
21
+ .get("end_date")
22
+ .and_then(|v| v.as_str())
23
+ .ok_or_else(|| anyhow::anyhow!("'end_date' parameter is required (YYYY-MM-DD)"))?;
24
+ let assignee_filter = arguments.get("assignee").and_then(|v| v.as_str());
25
+
26
+ let start = NaiveDate::parse_from_str(start_date_str, "%Y-%m-%d")
27
+ .with_context(|| format!("Invalid start_date: {start_date_str}"))?;
28
+ let end = NaiveDate::parse_from_str(end_date_str, "%Y-%m-%d")
29
+ .with_context(|| format!("Invalid end_date: {end_date_str}"))?;
30
+
31
+ if end < start {
32
+ anyhow::bail!("end_date must be >= start_date");
33
+ }
34
+
35
+ let calendar = parse_calendar(&config_path, assignee_filter)?;
36
+
37
+ let mut days: Vec<Value> = Vec::new();
38
+ let mut total_hours: f64 = 0.0;
39
+ let mut work_days: u32 = 0;
40
+
41
+ let mut date = start;
42
+ while date <= end {
43
+ let hours = calendar.hours_for_date(&date);
44
+ if hours > 0.0 {
45
+ work_days += 1;
46
+ total_hours += hours;
47
+ }
48
+ days.push(json!({
49
+ "date": date.format("%Y-%m-%d").to_string(),
50
+ "capacity_hours": hours,
51
+ "allocated_hours": 0.0,
52
+ }));
53
+ date += chrono::Duration::days(1);
54
+ }
55
+
56
+ // Calculate allocated hours from tasks
57
+ if tasks_dir.exists() {
58
+ let (tree, _) = build_task_index(&tasks_dir, u32::MAX)?;
59
+ allocate_task_hours(&tree, assignee_filter, &start, &end, &mut days);
60
+ }
61
+
62
+ let allocated_hours: f64 = days
63
+ .iter()
64
+ .filter_map(|d| d.get("allocated_hours").and_then(|v| v.as_f64()))
65
+ .sum();
66
+
67
+ let result = json!({
68
+ "work_days": work_days,
69
+ "total_hours": total_hours,
70
+ "allocated_hours": allocated_hours,
71
+ "available_hours": (total_hours - allocated_hours).max(0.0),
72
+ "days": days,
73
+ });
74
+
75
+ serde_json::to_string_pretty(&result).map_err(Into::into)
76
+ }
77
+
78
+ struct CalendarConfig {
79
+ work_hours_per_day: f64,
80
+ closed_weekdays: Vec<u32>,
81
+ closed_dates: Vec<String>,
82
+ open_dates: Vec<String>,
83
+ day_hours: Vec<(String, f64)>,
84
+ }
85
+
86
+ impl CalendarConfig {
87
+ fn hours_for_date(&self, date: &NaiveDate) -> f64 {
88
+ let date_str = date.format("%Y-%m-%d").to_string();
89
+
90
+ // Check per-date hours first
91
+ for (d, h) in &self.day_hours {
92
+ if d == &date_str {
93
+ return *h;
94
+ }
95
+ }
96
+
97
+ // Check if explicitly open (overrides closed weekday)
98
+ let is_open_override = self.open_dates.iter().any(|d| d == &date_str);
99
+
100
+ // Check closed dates
101
+ if self.closed_dates.iter().any(|d| d == &date_str) {
102
+ return 0.0;
103
+ }
104
+
105
+ // Check closed weekdays (chrono: Mon=1 .. Sun=7, we use 0=Sun..6=Sat)
106
+ let weekday_num = match date.weekday() {
107
+ chrono::Weekday::Sun => 0,
108
+ chrono::Weekday::Mon => 1,
109
+ chrono::Weekday::Tue => 2,
110
+ chrono::Weekday::Wed => 3,
111
+ chrono::Weekday::Thu => 4,
112
+ chrono::Weekday::Fri => 5,
113
+ chrono::Weekday::Sat => 6,
114
+ };
115
+
116
+ if !is_open_override && self.closed_weekdays.contains(&weekday_num) {
117
+ return 0.0;
118
+ }
119
+
120
+ // Check per-weekday hours
121
+ let weekday_str = match weekday_num {
122
+ 0 => "sun",
123
+ 1 => "mon",
124
+ 2 => "tue",
125
+ 3 => "wed",
126
+ 4 => "thu",
127
+ 5 => "fri",
128
+ 6 => "sat",
129
+ _ => "",
130
+ };
131
+ for (d, h) in &self.day_hours {
132
+ if d == weekday_str {
133
+ return *h;
134
+ }
135
+ }
136
+
137
+ self.work_hours_per_day
138
+ }
139
+ }
140
+
141
+ fn parse_calendar(config_path: &std::path::Path, assignee: Option<&str>) -> Result<CalendarConfig> {
142
+ let mut cal = CalendarConfig {
143
+ work_hours_per_day: 8.0,
144
+ closed_weekdays: Vec::new(),
145
+ closed_dates: Vec::new(),
146
+ open_dates: Vec::new(),
147
+ day_hours: Vec::new(),
148
+ };
149
+
150
+ if !config_path.exists() {
151
+ return Ok(cal);
152
+ }
153
+
154
+ let raw = std::fs::read_to_string(config_path).with_context(|| "Failed to read config.toml")?;
155
+ let doc: DocumentMut = raw.parse().with_context(|| "Failed to parse config.toml")?;
156
+
157
+ // Read project-level calendar
158
+ if let Some(calendar) = doc.get("calendar").and_then(|v| v.as_table()) {
159
+ if let Some(h) = calendar
160
+ .get("work_hours_per_day")
161
+ .and_then(|v| v.as_float().or_else(|| v.as_integer().map(|i| i as f64)))
162
+ {
163
+ cal.work_hours_per_day = h;
164
+ }
165
+ if let Some(arr) = calendar.get("closed_weekdays").and_then(|v| v.as_array()) {
166
+ cal.closed_weekdays = arr
167
+ .iter()
168
+ .filter_map(|v| {
169
+ v.as_integer()
170
+ .map(|i| i as u32)
171
+ .or_else(|| v.as_str().and_then(weekday_to_num))
172
+ })
173
+ .collect();
174
+ }
175
+ if let Some(arr) = calendar.get("closed_dates").and_then(|v| v.as_array()) {
176
+ cal.closed_dates = arr
177
+ .iter()
178
+ .filter_map(|v| v.as_str().map(String::from))
179
+ .collect();
180
+ }
181
+ if let Some(arr) = calendar.get("open_dates").and_then(|v| v.as_array()) {
182
+ cal.open_dates = arr
183
+ .iter()
184
+ .filter_map(|v| v.as_str().map(String::from))
185
+ .collect();
186
+ }
187
+ if let Some(dh) = calendar.get("day_hours").and_then(|v| v.as_table()) {
188
+ for (k, v) in dh.iter() {
189
+ if let Some(h) = v.as_float().or_else(|| v.as_integer().map(|i| i as f64)) {
190
+ cal.day_hours.push((k.to_string(), h));
191
+ }
192
+ }
193
+ }
194
+ }
195
+
196
+ // Override with assignee-specific calendar
197
+ if let Some(assignee_key) = assignee {
198
+ if let Some(assignees) = doc.get("assignees").and_then(|v| v.as_table()) {
199
+ if let Some(a) = assignees.get(assignee_key).and_then(|v| v.as_table()) {
200
+ if let Some(h) = a
201
+ .get("work_hours_per_day")
202
+ .and_then(|v| v.as_float().or_else(|| v.as_integer().map(|i| i as f64)))
203
+ {
204
+ cal.work_hours_per_day = h;
205
+ }
206
+ if let Some(arr) = a.get("closed_weekdays").and_then(|v| v.as_array()) {
207
+ cal.closed_weekdays = arr
208
+ .iter()
209
+ .filter_map(|v| {
210
+ v.as_integer()
211
+ .map(|i| i as u32)
212
+ .or_else(|| v.as_str().and_then(weekday_to_num))
213
+ })
214
+ .collect();
215
+ }
216
+ if let Some(arr) = a.get("closed_dates").and_then(|v| v.as_array()) {
217
+ for item in arr.iter() {
218
+ if let Some(s) = item.as_str() {
219
+ cal.closed_dates.push(s.to_string());
220
+ }
221
+ }
222
+ }
223
+ if let Some(arr) = a.get("open_dates").and_then(|v| v.as_array()) {
224
+ for item in arr.iter() {
225
+ if let Some(s) = item.as_str() {
226
+ cal.open_dates.push(s.to_string());
227
+ }
228
+ }
229
+ }
230
+ if let Some(dh) = a.get("day_hours").and_then(|v| v.as_table()) {
231
+ for (k, v) in dh.iter() {
232
+ if let Some(h) = v.as_float().or_else(|| v.as_integer().map(|i| i as f64)) {
233
+ cal.day_hours.push((k.to_string(), h));
234
+ }
235
+ }
236
+ }
237
+ }
238
+ }
239
+ }
240
+
241
+ Ok(cal)
242
+ }
243
+
244
+ fn weekday_to_num(s: &str) -> Option<u32> {
245
+ match s.to_lowercase().as_str() {
246
+ "sun" | "sunday" => Some(0),
247
+ "mon" | "monday" => Some(1),
248
+ "tue" | "tuesday" => Some(2),
249
+ "wed" | "wednesday" => Some(3),
250
+ "thu" | "thursday" => Some(4),
251
+ "fri" | "friday" => Some(5),
252
+ "sat" | "saturday" => Some(6),
253
+ _ => None,
254
+ }
255
+ }
256
+
257
+ fn allocate_task_hours(
258
+ tree: &[TaskIndex],
259
+ assignee_filter: Option<&str>,
260
+ start: &NaiveDate,
261
+ end: &NaiveDate,
262
+ days: &mut [Value],
263
+ ) {
264
+ for node in tree {
265
+ if is_terminal_status(&node.status) {
266
+ continue;
267
+ }
268
+
269
+ let matches_assignee = match assignee_filter {
270
+ Some(f) => node.assignee.as_deref() == Some(f),
271
+ None => true,
272
+ };
273
+
274
+ if matches_assignee {
275
+ if let Some(ref sched) = node.schedule {
276
+ if let (Some(ref sd), Some(ref dd)) = (&sched.start_date, &sched.due_date) {
277
+ if let (Ok(task_start), Ok(task_end)) = (
278
+ NaiveDate::parse_from_str(sd, "%Y-%m-%d"),
279
+ NaiveDate::parse_from_str(dd, "%Y-%m-%d"),
280
+ ) {
281
+ let est = sched
282
+ .remaining_hours
283
+ .or(sched.estimate_hours)
284
+ .unwrap_or(0.0);
285
+ let overlap_start = (*start).max(task_start);
286
+ let overlap_end = (*end).min(task_end);
287
+ if overlap_start <= overlap_end {
288
+ let task_days = (task_end - task_start).num_days().max(1) as f64;
289
+ let hours_per_day = est / task_days;
290
+
291
+ let mut date = overlap_start;
292
+ while date <= overlap_end {
293
+ let idx = (date - *start).num_days() as usize;
294
+ if idx < days.len() {
295
+ if let Some(obj) = days[idx].as_object_mut() {
296
+ let cur = obj
297
+ .get("allocated_hours")
298
+ .and_then(|v| v.as_f64())
299
+ .unwrap_or(0.0);
300
+ obj.insert(
301
+ "allocated_hours".to_string(),
302
+ json!(cur + hours_per_day),
303
+ );
304
+ }
305
+ }
306
+ date += chrono::Duration::days(1);
307
+ }
308
+ }
309
+ }
310
+ }
311
+ }
312
+ }
313
+
314
+ allocate_task_hours(&node.children, assignee_filter, start, end, days);
315
+ }
316
+ }