handoff-mcp-server 0.2.0 → 0.3.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.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/package.json +1 -1
- package/src/mcp/handlers/check_criterion.rs +68 -0
- package/src/mcp/handlers/dashboard.rs +7 -9
- package/src/mcp/handlers/get_task.rs +40 -0
- package/src/mcp/handlers/import_context.rs +4 -4
- package/src/mcp/handlers/load_context.rs +7 -0
- package/src/mcp/handlers/mod.rs +9 -0
- package/src/mcp/handlers/refer.rs +152 -0
- package/src/mcp/handlers/referrals.rs +55 -0
- package/src/mcp/handlers/update_task.rs +10 -9
- package/src/mcp/tools.rs +154 -3
- package/src/storage/mod.rs +10 -0
- package/src/storage/referrals.rs +210 -0
- package/src/storage/tasks.rs +18 -0
- package/tests/mcp_protocol.rs +4 -0
- package/tests/storage_tasks.rs +34 -0
- package/tests/tool_import_context.rs +66 -0
- package/tests/tool_referrals.rs +442 -0
- package/tests/tool_tasks.rs +357 -0
package/src/mcp/tools.rs
CHANGED
|
@@ -156,6 +156,50 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
|
|
|
156
156
|
}
|
|
157
157
|
}),
|
|
158
158
|
},
|
|
159
|
+
ToolDefinition {
|
|
160
|
+
name: "handoff_get_task".to_string(),
|
|
161
|
+
description: "Get full task details (notes, done_criteria, labels, links) by task ID. Use when list_tasks summary is not enough.".to_string(),
|
|
162
|
+
input_schema: json!({
|
|
163
|
+
"type": "object",
|
|
164
|
+
"properties": {
|
|
165
|
+
"project_dir": {
|
|
166
|
+
"type": "string",
|
|
167
|
+
"description": "Project directory path. Defaults to current working directory."
|
|
168
|
+
},
|
|
169
|
+
"task_id": {
|
|
170
|
+
"type": "string",
|
|
171
|
+
"description": "Task ID to retrieve (e.g. 't1', 't1.2')."
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
"required": ["task_id"]
|
|
175
|
+
}),
|
|
176
|
+
},
|
|
177
|
+
ToolDefinition {
|
|
178
|
+
name: "handoff_check_criterion".to_string(),
|
|
179
|
+
description: "Toggle a single done_criteria item by index. No need to resend the entire criteria list.".to_string(),
|
|
180
|
+
input_schema: json!({
|
|
181
|
+
"type": "object",
|
|
182
|
+
"properties": {
|
|
183
|
+
"project_dir": {
|
|
184
|
+
"type": "string",
|
|
185
|
+
"description": "Project directory path. Defaults to current working directory."
|
|
186
|
+
},
|
|
187
|
+
"task_id": {
|
|
188
|
+
"type": "string",
|
|
189
|
+
"description": "Task ID containing the criterion."
|
|
190
|
+
},
|
|
191
|
+
"criterion_index": {
|
|
192
|
+
"type": "integer",
|
|
193
|
+
"description": "0-based index of the done_criteria item to toggle."
|
|
194
|
+
},
|
|
195
|
+
"checked": {
|
|
196
|
+
"type": "boolean",
|
|
197
|
+
"description": "true to mark as checked, false to uncheck."
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
"required": ["task_id", "criterion_index", "checked"]
|
|
201
|
+
}),
|
|
202
|
+
},
|
|
159
203
|
ToolDefinition {
|
|
160
204
|
name: "handoff_update_task".to_string(),
|
|
161
205
|
description: "Add, update, or move a task. Manages the tasks/ directory structure.".to_string(),
|
|
@@ -170,7 +214,7 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
|
|
|
170
214
|
"type": "object",
|
|
171
215
|
"properties": {
|
|
172
216
|
"id": { "type": "string", "description": "Task ID. Omit for new task (auto-generated)." },
|
|
173
|
-
"title": { "type": "string" },
|
|
217
|
+
"title": { "type": "string", "description": "Required for new tasks. Optional when updating (id present)." },
|
|
174
218
|
"status": {
|
|
175
219
|
"type": "string",
|
|
176
220
|
"enum": ["todo", "in_progress", "review", "done", "blocked", "skipped"]
|
|
@@ -200,7 +244,6 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
|
|
|
200
244
|
}
|
|
201
245
|
}
|
|
202
246
|
},
|
|
203
|
-
"required": ["title"]
|
|
204
247
|
},
|
|
205
248
|
"parent_id": {
|
|
206
249
|
"type": "string",
|
|
@@ -279,7 +322,7 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
|
|
|
279
322
|
"properties": {
|
|
280
323
|
"description": {
|
|
281
324
|
"type": "string",
|
|
282
|
-
"description": "What is being imported (e.g. 'tmp/260601-sprint-handoff.md
|
|
325
|
+
"description": "What is being imported (e.g. 'Migration from tmp/260601-sprint-handoff.md')"
|
|
283
326
|
},
|
|
284
327
|
"format": {
|
|
285
328
|
"type": "string",
|
|
@@ -427,6 +470,114 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
|
|
|
427
470
|
}
|
|
428
471
|
}),
|
|
429
472
|
},
|
|
473
|
+
ToolDefinition {
|
|
474
|
+
name: "handoff_refer".to_string(),
|
|
475
|
+
description: "Send a cross-project referral (improvement request, bug report, work request) to another project's .handoff/. The target project sees it on load_context.".to_string(),
|
|
476
|
+
input_schema: json!({
|
|
477
|
+
"type": "object",
|
|
478
|
+
"properties": {
|
|
479
|
+
"project_dir": {
|
|
480
|
+
"type": "string",
|
|
481
|
+
"description": "Source project directory (sender). Defaults to current working directory."
|
|
482
|
+
},
|
|
483
|
+
"target_project": {
|
|
484
|
+
"type": "string",
|
|
485
|
+
"description": "Target project name (resolved via scan_dirs). Use this OR target_project_dir."
|
|
486
|
+
},
|
|
487
|
+
"target_project_dir": {
|
|
488
|
+
"type": "string",
|
|
489
|
+
"description": "Target project directory path (absolute). Takes precedence over target_project."
|
|
490
|
+
},
|
|
491
|
+
"referral_type": {
|
|
492
|
+
"type": "string",
|
|
493
|
+
"enum": ["improvement", "bug", "request", "info"],
|
|
494
|
+
"description": "Type of referral. Defaults to 'request'."
|
|
495
|
+
},
|
|
496
|
+
"summary": {
|
|
497
|
+
"type": "string",
|
|
498
|
+
"description": "One-line summary of the referral."
|
|
499
|
+
},
|
|
500
|
+
"details": {
|
|
501
|
+
"type": "string",
|
|
502
|
+
"description": "Detailed description of the referral."
|
|
503
|
+
},
|
|
504
|
+
"priority": {
|
|
505
|
+
"type": "string",
|
|
506
|
+
"enum": ["low", "medium", "high"],
|
|
507
|
+
"description": "Priority of the referral."
|
|
508
|
+
},
|
|
509
|
+
"tasks": {
|
|
510
|
+
"type": "array",
|
|
511
|
+
"description": "Suggested tasks for the target project.",
|
|
512
|
+
"items": {
|
|
513
|
+
"type": "object",
|
|
514
|
+
"properties": {
|
|
515
|
+
"title": { "type": "string" },
|
|
516
|
+
"priority": { "type": "string", "enum": ["low", "medium", "high"] },
|
|
517
|
+
"done_criteria": {
|
|
518
|
+
"type": "array",
|
|
519
|
+
"items": {
|
|
520
|
+
"type": "object",
|
|
521
|
+
"properties": {
|
|
522
|
+
"item": { "type": "string" },
|
|
523
|
+
"checked": { "type": "boolean" }
|
|
524
|
+
},
|
|
525
|
+
"required": ["item"]
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
},
|
|
529
|
+
"required": ["title"]
|
|
530
|
+
}
|
|
531
|
+
},
|
|
532
|
+
"context": {
|
|
533
|
+
"type": "object",
|
|
534
|
+
"description": "Additional context (branch, commit, references)."
|
|
535
|
+
}
|
|
536
|
+
},
|
|
537
|
+
"required": ["summary"]
|
|
538
|
+
}),
|
|
539
|
+
},
|
|
540
|
+
ToolDefinition {
|
|
541
|
+
name: "handoff_list_referrals".to_string(),
|
|
542
|
+
description: "List incoming referrals from other projects with optional status filter.".to_string(),
|
|
543
|
+
input_schema: json!({
|
|
544
|
+
"type": "object",
|
|
545
|
+
"properties": {
|
|
546
|
+
"project_dir": {
|
|
547
|
+
"type": "string",
|
|
548
|
+
"description": "Project directory path. Defaults to current working directory."
|
|
549
|
+
},
|
|
550
|
+
"status_filter": {
|
|
551
|
+
"type": "string",
|
|
552
|
+
"enum": ["open", "acknowledged", "resolved"],
|
|
553
|
+
"description": "Filter by referral status."
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}),
|
|
557
|
+
},
|
|
558
|
+
ToolDefinition {
|
|
559
|
+
name: "handoff_update_referral".to_string(),
|
|
560
|
+
description: "Update the status of an incoming referral (open -> acknowledged -> resolved).".to_string(),
|
|
561
|
+
input_schema: json!({
|
|
562
|
+
"type": "object",
|
|
563
|
+
"properties": {
|
|
564
|
+
"project_dir": {
|
|
565
|
+
"type": "string",
|
|
566
|
+
"description": "Project directory path. Defaults to current working directory."
|
|
567
|
+
},
|
|
568
|
+
"referral_id": {
|
|
569
|
+
"type": "string",
|
|
570
|
+
"description": "ID of the referral to update."
|
|
571
|
+
},
|
|
572
|
+
"status": {
|
|
573
|
+
"type": "string",
|
|
574
|
+
"enum": ["open", "acknowledged", "resolved"],
|
|
575
|
+
"description": "New status for the referral."
|
|
576
|
+
}
|
|
577
|
+
},
|
|
578
|
+
"required": ["referral_id", "status"]
|
|
579
|
+
}),
|
|
580
|
+
},
|
|
430
581
|
]
|
|
431
582
|
}
|
|
432
583
|
|
package/src/storage/mod.rs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
pub mod config;
|
|
2
2
|
pub mod git;
|
|
3
|
+
pub mod referrals;
|
|
3
4
|
pub mod sessions;
|
|
4
5
|
pub mod tasks;
|
|
5
6
|
|
|
@@ -7,6 +8,15 @@ use std::path::{Path, PathBuf};
|
|
|
7
8
|
|
|
8
9
|
use anyhow::{Context, Result};
|
|
9
10
|
|
|
11
|
+
pub fn expand_tilde(path: &str) -> String {
|
|
12
|
+
if let Some(rest) = path.strip_prefix("~/") {
|
|
13
|
+
if let Ok(home) = std::env::var("HOME") {
|
|
14
|
+
return format!("{home}/{rest}");
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
path.to_string()
|
|
18
|
+
}
|
|
19
|
+
|
|
10
20
|
pub fn handoff_dir(project_dir: &Path) -> PathBuf {
|
|
11
21
|
project_dir.join(".handoff")
|
|
12
22
|
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
use std::path::{Path, PathBuf};
|
|
2
|
+
|
|
3
|
+
use anyhow::{Context, Result};
|
|
4
|
+
use serde::{Deserialize, Serialize};
|
|
5
|
+
use serde_json::Value;
|
|
6
|
+
|
|
7
|
+
const VALID_REFERRAL_TYPES: &[&str] = &["improvement", "bug", "request", "info"];
|
|
8
|
+
const VALID_REFERRAL_STATUSES: &[&str] = &["open", "acknowledged", "resolved"];
|
|
9
|
+
|
|
10
|
+
pub fn is_valid_referral_type(t: &str) -> bool {
|
|
11
|
+
VALID_REFERRAL_TYPES.contains(&t)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
pub fn is_valid_referral_status(s: &str) -> bool {
|
|
15
|
+
VALID_REFERRAL_STATUSES.contains(&s)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
19
|
+
pub struct ReferralData {
|
|
20
|
+
pub id: String,
|
|
21
|
+
pub source_project: String,
|
|
22
|
+
pub source_project_dir: String,
|
|
23
|
+
pub created_at: String,
|
|
24
|
+
pub referral_type: String,
|
|
25
|
+
pub summary: String,
|
|
26
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
27
|
+
pub details: Option<String>,
|
|
28
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
29
|
+
pub priority: Option<String>,
|
|
30
|
+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
31
|
+
pub tasks: Vec<Value>,
|
|
32
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
33
|
+
pub context: Option<Value>,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
37
|
+
pub struct ReferralSummary {
|
|
38
|
+
pub id: String,
|
|
39
|
+
pub source_project: String,
|
|
40
|
+
pub referral_type: String,
|
|
41
|
+
pub summary: String,
|
|
42
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
43
|
+
pub priority: Option<String>,
|
|
44
|
+
pub status: String,
|
|
45
|
+
pub created_at: String,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
pub fn write_referral(referrals_dir: &Path, data: &ReferralData) -> Result<PathBuf> {
|
|
49
|
+
std::fs::create_dir_all(referrals_dir).with_context(|| {
|
|
50
|
+
format!(
|
|
51
|
+
"Failed to create referrals dir: {}",
|
|
52
|
+
referrals_dir.display()
|
|
53
|
+
)
|
|
54
|
+
})?;
|
|
55
|
+
|
|
56
|
+
let slug = source_to_slug(&data.source_project);
|
|
57
|
+
let id_suffix = data.id.replace("ref-", "");
|
|
58
|
+
let filename = format!("{id_suffix}-{slug}.open.json");
|
|
59
|
+
let file_path = referrals_dir.join(&filename);
|
|
60
|
+
|
|
61
|
+
let content = serde_json::to_string_pretty(data).context("Failed to serialize referral")?;
|
|
62
|
+
std::fs::write(&file_path, content)
|
|
63
|
+
.with_context(|| format!("Failed to write referral: {}", file_path.display()))?;
|
|
64
|
+
|
|
65
|
+
Ok(file_path)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
pub fn read_referrals(
|
|
69
|
+
referrals_dir: &Path,
|
|
70
|
+
status_filter: Option<&str>,
|
|
71
|
+
) -> Result<Vec<(ReferralData, String)>> {
|
|
72
|
+
let mut results = Vec::new();
|
|
73
|
+
|
|
74
|
+
if !referrals_dir.exists() {
|
|
75
|
+
return Ok(results);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let mut entries: Vec<_> = std::fs::read_dir(referrals_dir)?
|
|
79
|
+
.filter_map(|e| e.ok())
|
|
80
|
+
.collect();
|
|
81
|
+
entries.sort_by_key(|e| e.file_name());
|
|
82
|
+
|
|
83
|
+
for entry in entries {
|
|
84
|
+
let name = entry.file_name().to_string_lossy().to_string();
|
|
85
|
+
if let Some(status) = parse_referral_status(&name) {
|
|
86
|
+
if let Some(filter) = status_filter {
|
|
87
|
+
if status != filter {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
let content = std::fs::read_to_string(entry.path())
|
|
92
|
+
.with_context(|| format!("Failed to read referral: {}", entry.path().display()))?;
|
|
93
|
+
let data: ReferralData = serde_json::from_str(&content)
|
|
94
|
+
.with_context(|| format!("Failed to parse referral: {}", entry.path().display()))?;
|
|
95
|
+
results.push((data, status));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
Ok(results)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
pub fn read_referral_summaries(
|
|
103
|
+
referrals_dir: &Path,
|
|
104
|
+
status_filter: Option<&str>,
|
|
105
|
+
) -> Result<Vec<ReferralSummary>> {
|
|
106
|
+
let referrals = read_referrals(referrals_dir, status_filter)?;
|
|
107
|
+
Ok(referrals
|
|
108
|
+
.into_iter()
|
|
109
|
+
.map(|(data, status)| ReferralSummary {
|
|
110
|
+
id: data.id,
|
|
111
|
+
source_project: data.source_project,
|
|
112
|
+
referral_type: data.referral_type,
|
|
113
|
+
summary: data.summary,
|
|
114
|
+
priority: data.priority,
|
|
115
|
+
status,
|
|
116
|
+
created_at: data.created_at,
|
|
117
|
+
})
|
|
118
|
+
.collect())
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
pub fn change_referral_status(
|
|
122
|
+
referrals_dir: &Path,
|
|
123
|
+
referral_id: &str,
|
|
124
|
+
new_status: &str,
|
|
125
|
+
) -> Result<()> {
|
|
126
|
+
if !is_valid_referral_status(new_status) {
|
|
127
|
+
anyhow::bail!(
|
|
128
|
+
"Invalid referral status: '{new_status}'. Must be one of: {}",
|
|
129
|
+
VALID_REFERRAL_STATUSES.join(", ")
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let (old_path, old_status) = find_referral_file(referrals_dir, referral_id)?
|
|
134
|
+
.ok_or_else(|| anyhow::anyhow!("Referral not found: {referral_id}"))?;
|
|
135
|
+
|
|
136
|
+
if old_status == new_status {
|
|
137
|
+
return Ok(());
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let old_name = old_path
|
|
141
|
+
.file_name()
|
|
142
|
+
.ok_or_else(|| anyhow::anyhow!("Invalid referral path"))?
|
|
143
|
+
.to_string_lossy()
|
|
144
|
+
.to_string();
|
|
145
|
+
|
|
146
|
+
let new_name = old_name.replace(
|
|
147
|
+
&format!(".{old_status}.json"),
|
|
148
|
+
&format!(".{new_status}.json"),
|
|
149
|
+
);
|
|
150
|
+
let new_path = referrals_dir.join(&new_name);
|
|
151
|
+
|
|
152
|
+
std::fs::rename(&old_path, &new_path).with_context(|| {
|
|
153
|
+
format!(
|
|
154
|
+
"Failed to rename {} -> {}",
|
|
155
|
+
old_path.display(),
|
|
156
|
+
new_path.display()
|
|
157
|
+
)
|
|
158
|
+
})?;
|
|
159
|
+
|
|
160
|
+
Ok(())
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
pub fn find_referral_file(
|
|
164
|
+
referrals_dir: &Path,
|
|
165
|
+
referral_id: &str,
|
|
166
|
+
) -> Result<Option<(PathBuf, String)>> {
|
|
167
|
+
if !referrals_dir.exists() {
|
|
168
|
+
return Ok(None);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
for entry in std::fs::read_dir(referrals_dir)? {
|
|
172
|
+
let entry = entry?;
|
|
173
|
+
let name = entry.file_name().to_string_lossy().to_string();
|
|
174
|
+
if let Some(status) = parse_referral_status(&name) {
|
|
175
|
+
let content = std::fs::read_to_string(entry.path())?;
|
|
176
|
+
if let Ok(data) = serde_json::from_str::<ReferralData>(&content) {
|
|
177
|
+
if data.id == referral_id {
|
|
178
|
+
return Ok(Some((entry.path(), status)));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
Ok(None)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
fn parse_referral_status(filename: &str) -> Option<String> {
|
|
188
|
+
let name = filename.strip_suffix(".json")?;
|
|
189
|
+
for status in VALID_REFERRAL_STATUSES {
|
|
190
|
+
if name.ends_with(&format!(".{status}")) {
|
|
191
|
+
return Some(status.to_string());
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
None
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
fn source_to_slug(source: &str) -> String {
|
|
198
|
+
let slug: String = source
|
|
199
|
+
.chars()
|
|
200
|
+
.take(30)
|
|
201
|
+
.map(|c| {
|
|
202
|
+
if c.is_ascii_alphanumeric() {
|
|
203
|
+
c.to_ascii_lowercase()
|
|
204
|
+
} else {
|
|
205
|
+
'-'
|
|
206
|
+
}
|
|
207
|
+
})
|
|
208
|
+
.collect();
|
|
209
|
+
slug.trim_matches('-').to_string()
|
|
210
|
+
}
|
package/src/storage/tasks.rs
CHANGED
|
@@ -56,10 +56,28 @@ const VALID_STATUSES: &[&str] = &[
|
|
|
56
56
|
"skipped",
|
|
57
57
|
];
|
|
58
58
|
|
|
59
|
+
const VALID_PRIORITIES: &[&str] = &["low", "medium", "high"];
|
|
60
|
+
|
|
59
61
|
pub fn is_valid_status(status: &str) -> bool {
|
|
60
62
|
VALID_STATUSES.contains(&status)
|
|
61
63
|
}
|
|
62
64
|
|
|
65
|
+
pub fn is_valid_priority(priority: &str) -> bool {
|
|
66
|
+
VALID_PRIORITIES.contains(&priority)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
pub fn validate_priority(priority: Option<&str>) -> Result<()> {
|
|
70
|
+
if let Some(p) = priority {
|
|
71
|
+
if !is_valid_priority(p) {
|
|
72
|
+
anyhow::bail!(
|
|
73
|
+
"Invalid priority: '{p}'. Must be one of: {}",
|
|
74
|
+
VALID_PRIORITIES.join(", ")
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
Ok(())
|
|
79
|
+
}
|
|
80
|
+
|
|
63
81
|
pub fn is_terminal_status(status: &str) -> bool {
|
|
64
82
|
status == "done" || status == "skipped"
|
|
65
83
|
}
|
package/tests/mcp_protocol.rs
CHANGED
|
@@ -55,6 +55,10 @@ fn tools_list_returns_all_tools() {
|
|
|
55
55
|
"handoff_get_config",
|
|
56
56
|
"handoff_update_config",
|
|
57
57
|
"handoff_dashboard",
|
|
58
|
+
"handoff_import_context",
|
|
59
|
+
"handoff_refer",
|
|
60
|
+
"handoff_list_referrals",
|
|
61
|
+
"handoff_update_referral",
|
|
58
62
|
];
|
|
59
63
|
|
|
60
64
|
let tool_names: Vec<&str> = tools
|
package/tests/storage_tasks.rs
CHANGED
|
@@ -300,3 +300,37 @@ fn validate_skipped_child_not_terminal_fails() {
|
|
|
300
300
|
let data = make_task("t1", "Parent");
|
|
301
301
|
assert!(validate_skipped_transition(&task_dir, &data).is_err());
|
|
302
302
|
}
|
|
303
|
+
|
|
304
|
+
#[test]
|
|
305
|
+
fn is_valid_priority_accepts_valid() {
|
|
306
|
+
assert!(is_valid_priority("low"));
|
|
307
|
+
assert!(is_valid_priority("medium"));
|
|
308
|
+
assert!(is_valid_priority("high"));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
#[test]
|
|
312
|
+
fn is_valid_priority_rejects_invalid() {
|
|
313
|
+
assert!(!is_valid_priority("critical"));
|
|
314
|
+
assert!(!is_valid_priority("urgent"));
|
|
315
|
+
assert!(!is_valid_priority(""));
|
|
316
|
+
assert!(!is_valid_priority("HIGH"));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
#[test]
|
|
320
|
+
fn validate_priority_none_is_ok() {
|
|
321
|
+
assert!(validate_priority(None).is_ok());
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
#[test]
|
|
325
|
+
fn validate_priority_valid_is_ok() {
|
|
326
|
+
assert!(validate_priority(Some("low")).is_ok());
|
|
327
|
+
assert!(validate_priority(Some("medium")).is_ok());
|
|
328
|
+
assert!(validate_priority(Some("high")).is_ok());
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
#[test]
|
|
332
|
+
fn validate_priority_invalid_is_err() {
|
|
333
|
+
let err = validate_priority(Some("critical")).unwrap_err();
|
|
334
|
+
assert!(err.to_string().contains("Invalid priority"));
|
|
335
|
+
assert!(err.to_string().contains("critical"));
|
|
336
|
+
}
|
|
@@ -375,3 +375,69 @@ fn import_source_recorded_in_environment() {
|
|
|
375
375
|
"markdown"
|
|
376
376
|
);
|
|
377
377
|
}
|
|
378
|
+
|
|
379
|
+
fn is_error(resp: &Value) -> bool {
|
|
380
|
+
resp["result"]["isError"].as_bool().unwrap_or(false)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
#[test]
|
|
384
|
+
fn import_invalid_priority_rejected() {
|
|
385
|
+
let dir = setup_project();
|
|
386
|
+
let resp = call_tool(
|
|
387
|
+
"handoff_import_context",
|
|
388
|
+
json!({
|
|
389
|
+
"project_dir": dir.path().to_string_lossy(),
|
|
390
|
+
"source": { "description": "bad priority import" },
|
|
391
|
+
"tasks": [
|
|
392
|
+
{ "title": "Task", "priority": "urgent" }
|
|
393
|
+
]
|
|
394
|
+
}),
|
|
395
|
+
);
|
|
396
|
+
assert!(is_error(&resp));
|
|
397
|
+
let text = get_text(&resp);
|
|
398
|
+
assert!(text.contains("Invalid priority"));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
#[test]
|
|
402
|
+
fn import_valid_priorities_accepted() {
|
|
403
|
+
let dir = setup_project();
|
|
404
|
+
let resp = call_tool(
|
|
405
|
+
"handoff_import_context",
|
|
406
|
+
json!({
|
|
407
|
+
"project_dir": dir.path().to_string_lossy(),
|
|
408
|
+
"source": { "description": "valid priorities" },
|
|
409
|
+
"tasks": [
|
|
410
|
+
{ "title": "Low", "priority": "low" },
|
|
411
|
+
{ "title": "Medium", "priority": "medium" },
|
|
412
|
+
{ "title": "High", "priority": "high" }
|
|
413
|
+
]
|
|
414
|
+
}),
|
|
415
|
+
);
|
|
416
|
+
assert!(!is_error(&resp), "error: {}", get_text(&resp));
|
|
417
|
+
let text = get_text(&resp);
|
|
418
|
+
assert!(text.contains("Tasks created: 3"));
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
#[test]
|
|
422
|
+
fn import_invalid_priority_in_child_rejected() {
|
|
423
|
+
let dir = setup_project();
|
|
424
|
+
let resp = call_tool(
|
|
425
|
+
"handoff_import_context",
|
|
426
|
+
json!({
|
|
427
|
+
"project_dir": dir.path().to_string_lossy(),
|
|
428
|
+
"source": { "description": "bad child priority" },
|
|
429
|
+
"tasks": [
|
|
430
|
+
{
|
|
431
|
+
"title": "Parent",
|
|
432
|
+
"priority": "high",
|
|
433
|
+
"children": [
|
|
434
|
+
{ "title": "Child", "priority": "ASAP" }
|
|
435
|
+
]
|
|
436
|
+
}
|
|
437
|
+
]
|
|
438
|
+
}),
|
|
439
|
+
);
|
|
440
|
+
assert!(is_error(&resp));
|
|
441
|
+
let text = get_text(&resp);
|
|
442
|
+
assert!(text.contains("Invalid priority"));
|
|
443
|
+
}
|