handoff-mcp-server 0.7.1 → 0.7.3
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 +2 -3
- package/scripts/postinstall.js +7 -1
- package/src/mcp/handlers/import_context.rs +1 -6
- package/src/mcp/handlers/save_context.rs +17 -8
- package/src/mcp/tools.rs +7 -1
- package/src/storage/sessions.rs +20 -4
- package/bin/handoff-mcp-bin +0 -0
- package/tests/mcp_protocol.rs +0 -208
- package/tests/storage_config.rs +0 -107
- package/tests/storage_sessions.rs +0 -535
- package/tests/storage_tasks.rs +0 -339
- package/tests/tool_dashboard.rs +0 -165
- package/tests/tool_import_context.rs +0 -443
- package/tests/tool_init.rs +0 -176
- package/tests/tool_referrals.rs +0 -604
- package/tests/tool_sessions.rs +0 -1310
- package/tests/tool_tasks.rs +0 -765
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "handoff-mcp-server",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.3",
|
|
4
4
|
"description": "MCP server that gives AI coding agents persistent memory across sessions",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "AlphaElements <66808803+alphaelements@users.noreply.github.com>",
|
|
@@ -24,10 +24,9 @@
|
|
|
24
24
|
"postinstall": "node scripts/postinstall.js"
|
|
25
25
|
},
|
|
26
26
|
"files": [
|
|
27
|
-
"bin/",
|
|
27
|
+
"bin/handoff-mcp.js",
|
|
28
28
|
"scripts/",
|
|
29
29
|
"src/",
|
|
30
|
-
"tests/",
|
|
31
30
|
"Cargo.toml",
|
|
32
31
|
"Cargo.lock",
|
|
33
32
|
"LICENSE",
|
package/scripts/postinstall.js
CHANGED
|
@@ -10,7 +10,13 @@ const BIN_DIR = path.join(ROOT, "bin");
|
|
|
10
10
|
const BINARY = path.join(BIN_DIR, "handoff-mcp-bin");
|
|
11
11
|
|
|
12
12
|
if (fs.existsSync(BINARY)) {
|
|
13
|
-
|
|
13
|
+
try {
|
|
14
|
+
execSync(`"${BINARY}" --help`, { stdio: "ignore", timeout: 5000 });
|
|
15
|
+
process.exit(0);
|
|
16
|
+
} catch {
|
|
17
|
+
// prebuilt binary exists but can't run on this platform — rebuild
|
|
18
|
+
fs.unlinkSync(BINARY);
|
|
19
|
+
}
|
|
14
20
|
}
|
|
15
21
|
|
|
16
22
|
try {
|
|
@@ -7,8 +7,7 @@ use crate::storage::config::read_config;
|
|
|
7
7
|
use crate::storage::ensure_handoff_exists;
|
|
8
8
|
use crate::storage::git::capture_git_state;
|
|
9
9
|
use crate::storage::sessions::{
|
|
10
|
-
close_active_sessions,
|
|
11
|
-
SessionData,
|
|
10
|
+
close_active_sessions, enforce_history_limit, write_open_session, SessionData,
|
|
12
11
|
};
|
|
13
12
|
use crate::storage::tasks::*;
|
|
14
13
|
|
|
@@ -60,7 +59,6 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
60
59
|
})?;
|
|
61
60
|
|
|
62
61
|
close_active_sessions(&sessions_dir)?;
|
|
63
|
-
let closed = close_open_sessions(&sessions_dir)?;
|
|
64
62
|
let git_state = capture_git_state(&project_dir)?;
|
|
65
63
|
let now = Utc::now().to_rfc3339();
|
|
66
64
|
|
|
@@ -117,12 +115,10 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
117
115
|
};
|
|
118
116
|
enforce_history_limit(&sessions_dir, history_limit)?;
|
|
119
117
|
|
|
120
|
-
let _ = closed;
|
|
121
118
|
session_saved = true;
|
|
122
119
|
} else if let Some(raw_notes) = arguments.get("raw_notes").and_then(|v| v.as_str()) {
|
|
123
120
|
if !raw_notes.is_empty() {
|
|
124
121
|
close_active_sessions(&sessions_dir)?;
|
|
125
|
-
let closed = close_open_sessions(&sessions_dir)?;
|
|
126
122
|
let git_state = capture_git_state(&project_dir)?;
|
|
127
123
|
let now = Utc::now().to_rfc3339();
|
|
128
124
|
|
|
@@ -162,7 +158,6 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
162
158
|
};
|
|
163
159
|
enforce_history_limit(&sessions_dir, history_limit)?;
|
|
164
160
|
|
|
165
|
-
let _ = closed;
|
|
166
161
|
session_saved = true;
|
|
167
162
|
}
|
|
168
163
|
}
|
|
@@ -9,9 +9,9 @@ use crate::storage::config::read_config;
|
|
|
9
9
|
use crate::storage::ensure_handoff_exists;
|
|
10
10
|
use crate::storage::git::capture_git_state;
|
|
11
11
|
use crate::storage::sessions::{
|
|
12
|
-
close_active_sessions,
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
close_active_sessions, close_session_by_id, enforce_history_limit, generate_session_id,
|
|
13
|
+
pause_active_sessions, pause_session_by_id, read_active_sessions, write_session_with_status,
|
|
14
|
+
SessionData,
|
|
15
15
|
};
|
|
16
16
|
|
|
17
17
|
pub fn handle(arguments: &Value) -> Result<String> {
|
|
@@ -32,6 +32,17 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
32
32
|
.get("pause_active")
|
|
33
33
|
.and_then(|v| v.as_bool())
|
|
34
34
|
.unwrap_or(false);
|
|
35
|
+
let session_status = arguments
|
|
36
|
+
.get("session_status")
|
|
37
|
+
.and_then(|v| v.as_str())
|
|
38
|
+
.unwrap_or("open");
|
|
39
|
+
|
|
40
|
+
if session_status != "open" && session_status != "active" {
|
|
41
|
+
anyhow::bail!(
|
|
42
|
+
"Invalid session_status '{}': must be 'open' or 'active'",
|
|
43
|
+
session_status
|
|
44
|
+
);
|
|
45
|
+
}
|
|
35
46
|
|
|
36
47
|
let mut total_paused = 0usize;
|
|
37
48
|
if let Some(id) = pause_id {
|
|
@@ -50,8 +61,7 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
50
61
|
0
|
|
51
62
|
}
|
|
52
63
|
} else if pause_id.is_some() || pause_all {
|
|
53
|
-
|
|
54
|
-
closed_open.len()
|
|
64
|
+
0
|
|
55
65
|
} else {
|
|
56
66
|
let active = read_active_sessions(&sessions_dir)?;
|
|
57
67
|
if active.len() > 1 {
|
|
@@ -64,8 +74,7 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
64
74
|
);
|
|
65
75
|
}
|
|
66
76
|
let closed_active = close_active_sessions(&sessions_dir)?;
|
|
67
|
-
|
|
68
|
-
closed_active.len() + closed_open.len()
|
|
77
|
+
closed_active.len()
|
|
69
78
|
};
|
|
70
79
|
|
|
71
80
|
let git_state = capture_git_state(&project_dir)?;
|
|
@@ -89,7 +98,7 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
89
98
|
environment: arguments.get("environment").cloned(),
|
|
90
99
|
};
|
|
91
100
|
|
|
92
|
-
let path =
|
|
101
|
+
let path = write_session_with_status(&sessions_dir, &data, session_status)?;
|
|
93
102
|
|
|
94
103
|
let history_limit = if config_path.exists() {
|
|
95
104
|
read_config(&config_path)
|
package/src/mcp/tools.rs
CHANGED
|
@@ -57,9 +57,15 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
|
|
|
57
57
|
"type": "string",
|
|
58
58
|
"description": "One-line summary of this session"
|
|
59
59
|
},
|
|
60
|
+
"session_status": {
|
|
61
|
+
"type": "string",
|
|
62
|
+
"description": "Status of the new session: 'open' (default) = saved but not activated, allows multiple open sessions; 'active' = immediately active for this session.",
|
|
63
|
+
"enum": ["open", "active"],
|
|
64
|
+
"default": "open"
|
|
65
|
+
},
|
|
60
66
|
"close_session_id": {
|
|
61
67
|
"type": "string",
|
|
62
|
-
"description": "Session ID to close. If omitted (and no pause options set),
|
|
68
|
+
"description": "Session ID to close. If omitted (and no pause options set), active sessions are closed."
|
|
63
69
|
},
|
|
64
70
|
"pause_session_id": {
|
|
65
71
|
"type": "string",
|
package/src/storage/sessions.rs
CHANGED
|
@@ -321,8 +321,24 @@ pub fn enforce_history_limit(sessions_dir: &Path, limit: u32) -> Result<u32> {
|
|
|
321
321
|
Ok(removed)
|
|
322
322
|
}
|
|
323
323
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
324
|
+
pub fn write_session_with_status(
|
|
325
|
+
sessions_dir: &Path,
|
|
326
|
+
data: &SessionData,
|
|
327
|
+
status: &str,
|
|
328
|
+
) -> Result<PathBuf> {
|
|
329
|
+
let mut data = data.clone();
|
|
330
|
+
if data.id.is_none() {
|
|
331
|
+
data.id = Some(generate_session_id());
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
let ts_part = compact_timestamp(&data);
|
|
335
|
+
let base = generate_session_filename(&data.summary, &ts_part);
|
|
336
|
+
let filename = format!("{base}.{status}.json");
|
|
337
|
+
let path = sessions_dir.join(&filename);
|
|
338
|
+
|
|
339
|
+
let content = serde_json::to_string_pretty(&data).context("Failed to serialize session")?;
|
|
340
|
+
std::fs::write(&path, content)
|
|
341
|
+
.with_context(|| format!("Failed to write session: {}", path.display()))?;
|
|
342
|
+
|
|
343
|
+
Ok(path)
|
|
328
344
|
}
|
package/bin/handoff-mcp-bin
DELETED
|
Binary file
|
package/tests/mcp_protocol.rs
DELETED
|
@@ -1,208 +0,0 @@
|
|
|
1
|
-
use serde_json::{json, Value};
|
|
2
|
-
|
|
3
|
-
fn send(input: &str) -> Option<Value> {
|
|
4
|
-
let result = handoff_mcp::mcp::protocol::process_line(input)?;
|
|
5
|
-
Some(serde_json::from_str(&result).expect("response should be valid JSON"))
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
#[test]
|
|
9
|
-
fn initialize_returns_capabilities() {
|
|
10
|
-
let resp = send(
|
|
11
|
-
r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"0.1.0"}}}"#,
|
|
12
|
-
)
|
|
13
|
-
.expect("initialize should return a response");
|
|
14
|
-
|
|
15
|
-
assert_eq!(resp["jsonrpc"], "2.0");
|
|
16
|
-
assert_eq!(resp["id"], 1);
|
|
17
|
-
assert!(resp["error"].is_null());
|
|
18
|
-
|
|
19
|
-
let result = &resp["result"];
|
|
20
|
-
assert_eq!(result["protocolVersion"], "2025-03-26");
|
|
21
|
-
assert!(result["capabilities"]["tools"].is_object());
|
|
22
|
-
assert!(result["capabilities"]["resources"].is_object());
|
|
23
|
-
assert_eq!(result["serverInfo"]["name"], "handoff-mcp");
|
|
24
|
-
assert!(result["serverInfo"]["version"].is_string());
|
|
25
|
-
assert!(result["instructions"].is_string());
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
#[test]
|
|
29
|
-
fn initialized_notification_returns_nothing() {
|
|
30
|
-
let resp = send(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#);
|
|
31
|
-
assert!(
|
|
32
|
-
resp.is_none(),
|
|
33
|
-
"notifications should not produce a response"
|
|
34
|
-
);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
#[test]
|
|
38
|
-
fn tools_list_returns_all_tools() {
|
|
39
|
-
let resp = send(r#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#)
|
|
40
|
-
.expect("tools/list should return a response");
|
|
41
|
-
|
|
42
|
-
assert_eq!(resp["id"], 2);
|
|
43
|
-
assert!(resp["error"].is_null());
|
|
44
|
-
|
|
45
|
-
let tools = resp["result"]["tools"]
|
|
46
|
-
.as_array()
|
|
47
|
-
.expect("tools should be an array");
|
|
48
|
-
|
|
49
|
-
let expected_names = [
|
|
50
|
-
"handoff_init",
|
|
51
|
-
"handoff_load_context",
|
|
52
|
-
"handoff_save_context",
|
|
53
|
-
"handoff_list_tasks",
|
|
54
|
-
"handoff_update_task",
|
|
55
|
-
"handoff_get_config",
|
|
56
|
-
"handoff_update_config",
|
|
57
|
-
"handoff_dashboard",
|
|
58
|
-
"handoff_import_context",
|
|
59
|
-
"handoff_refer",
|
|
60
|
-
"handoff_list_referrals",
|
|
61
|
-
"handoff_update_referral",
|
|
62
|
-
];
|
|
63
|
-
|
|
64
|
-
let tool_names: Vec<&str> = tools
|
|
65
|
-
.iter()
|
|
66
|
-
.map(|t| t["name"].as_str().expect("tool name should be a string"))
|
|
67
|
-
.collect();
|
|
68
|
-
|
|
69
|
-
for expected in &expected_names {
|
|
70
|
-
assert!(tool_names.contains(expected), "missing tool: {expected}");
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
for tool in tools {
|
|
74
|
-
assert!(
|
|
75
|
-
tool["description"].is_string(),
|
|
76
|
-
"tool should have description"
|
|
77
|
-
);
|
|
78
|
-
assert!(
|
|
79
|
-
tool["inputSchema"].is_object(),
|
|
80
|
-
"tool should have inputSchema"
|
|
81
|
-
);
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
#[test]
|
|
86
|
-
fn resources_list_returns_resources() {
|
|
87
|
-
let resp = send(r#"{"jsonrpc":"2.0","id":3,"method":"resources/list","params":{}}"#)
|
|
88
|
-
.expect("resources/list should return a response");
|
|
89
|
-
|
|
90
|
-
assert_eq!(resp["id"], 3);
|
|
91
|
-
assert!(resp["error"].is_null());
|
|
92
|
-
|
|
93
|
-
let resources = resp["result"]["resources"]
|
|
94
|
-
.as_array()
|
|
95
|
-
.expect("resources should be an array");
|
|
96
|
-
|
|
97
|
-
assert_eq!(resources.len(), 2);
|
|
98
|
-
|
|
99
|
-
let uris: Vec<&str> = resources
|
|
100
|
-
.iter()
|
|
101
|
-
.map(|r| r["uri"].as_str().expect("resource uri"))
|
|
102
|
-
.collect();
|
|
103
|
-
|
|
104
|
-
assert!(uris.contains(&"handoff://sessions"));
|
|
105
|
-
assert!(uris.contains(&"handoff://config"));
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
#[test]
|
|
109
|
-
fn invalid_json_returns_parse_error() {
|
|
110
|
-
let resp = send("not valid json at all").expect("should return error response");
|
|
111
|
-
|
|
112
|
-
assert_eq!(resp["jsonrpc"], "2.0");
|
|
113
|
-
assert!(resp["id"].is_null());
|
|
114
|
-
|
|
115
|
-
let error = &resp["error"];
|
|
116
|
-
assert_eq!(error["code"], -32700);
|
|
117
|
-
assert!(error["message"].as_str().unwrap().contains("Parse error"));
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
#[test]
|
|
121
|
-
fn unknown_method_returns_method_not_found() {
|
|
122
|
-
let resp = send(r#"{"jsonrpc":"2.0","id":99,"method":"nonexistent/method","params":{}}"#)
|
|
123
|
-
.expect("should return error response");
|
|
124
|
-
|
|
125
|
-
assert_eq!(resp["id"], 99);
|
|
126
|
-
|
|
127
|
-
let error = &resp["error"];
|
|
128
|
-
assert_eq!(error["code"], -32601);
|
|
129
|
-
assert!(error["message"]
|
|
130
|
-
.as_str()
|
|
131
|
-
.unwrap()
|
|
132
|
-
.contains("Method not found"));
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
#[test]
|
|
136
|
-
fn invalid_jsonrpc_version_returns_error() {
|
|
137
|
-
let resp = send(r#"{"jsonrpc":"1.0","id":5,"method":"initialize","params":{}}"#)
|
|
138
|
-
.expect("should return error response");
|
|
139
|
-
|
|
140
|
-
assert_eq!(resp["id"], 5);
|
|
141
|
-
let error = &resp["error"];
|
|
142
|
-
assert_eq!(error["code"], -32600);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
#[test]
|
|
146
|
-
fn empty_line_returns_none() {
|
|
147
|
-
assert!(send("").is_none());
|
|
148
|
-
assert!(send(" ").is_none());
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
#[test]
|
|
152
|
-
fn request_id_is_preserved() {
|
|
153
|
-
let resp_str = send(r#"{"jsonrpc":"2.0","id":"abc-123","method":"tools/list","params":{}}"#)
|
|
154
|
-
.expect("should return response");
|
|
155
|
-
assert_eq!(resp_str["id"], "abc-123");
|
|
156
|
-
|
|
157
|
-
let resp_num = send(r#"{"jsonrpc":"2.0","id":42,"method":"tools/list","params":{}}"#)
|
|
158
|
-
.expect("should return response");
|
|
159
|
-
assert_eq!(resp_num["id"], 42);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
#[test]
|
|
163
|
-
fn tools_have_valid_input_schemas() {
|
|
164
|
-
let resp = send(r#"{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}"#)
|
|
165
|
-
.expect("tools/list response");
|
|
166
|
-
|
|
167
|
-
let tools = resp["result"]["tools"].as_array().unwrap();
|
|
168
|
-
for tool in tools {
|
|
169
|
-
let schema = &tool["inputSchema"];
|
|
170
|
-
assert_eq!(
|
|
171
|
-
schema["type"], "object",
|
|
172
|
-
"inputSchema for {} should have type: object",
|
|
173
|
-
tool["name"]
|
|
174
|
-
);
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
#[test]
|
|
179
|
-
fn multiple_requests_in_sequence() {
|
|
180
|
-
let r1 = send(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#).unwrap();
|
|
181
|
-
assert!(r1["result"].is_object());
|
|
182
|
-
|
|
183
|
-
send(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#);
|
|
184
|
-
|
|
185
|
-
let r2 = send(r#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#).unwrap();
|
|
186
|
-
assert!(r2["result"]["tools"].is_array());
|
|
187
|
-
|
|
188
|
-
let r3 = send(r#"{"jsonrpc":"2.0","id":3,"method":"resources/list","params":{}}"#).unwrap();
|
|
189
|
-
assert!(r3["result"]["resources"].is_array());
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
#[test]
|
|
193
|
-
fn e2e_json_roundtrip() {
|
|
194
|
-
let input = json!({
|
|
195
|
-
"jsonrpc": "2.0",
|
|
196
|
-
"id": 1,
|
|
197
|
-
"method": "initialize",
|
|
198
|
-
"params": {
|
|
199
|
-
"protocolVersion": "2025-03-26",
|
|
200
|
-
"capabilities": {},
|
|
201
|
-
"clientInfo": { "name": "test-client", "version": "1.0" }
|
|
202
|
-
}
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
let resp = send(&input.to_string()).unwrap();
|
|
206
|
-
assert_eq!(resp["result"]["protocolVersion"], "2025-03-26");
|
|
207
|
-
assert_eq!(resp["result"]["serverInfo"]["name"], "handoff-mcp");
|
|
208
|
-
}
|
package/tests/storage_config.rs
DELETED
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
use handoff_mcp::storage::config::{read_config, write_config, Config};
|
|
2
|
-
use std::fs;
|
|
3
|
-
use tempfile::TempDir;
|
|
4
|
-
|
|
5
|
-
fn setup() -> TempDir {
|
|
6
|
-
tempfile::tempdir().expect("failed to create temp dir")
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
#[test]
|
|
10
|
-
fn write_and_read_config() {
|
|
11
|
-
let dir = setup();
|
|
12
|
-
let path = dir.path().join("config.toml");
|
|
13
|
-
|
|
14
|
-
let config = Config::new("test-project", "A test project");
|
|
15
|
-
write_config(&path, &config).unwrap();
|
|
16
|
-
|
|
17
|
-
let read_back = read_config(&path).unwrap();
|
|
18
|
-
assert_eq!(read_back.project.name, "test-project");
|
|
19
|
-
assert_eq!(
|
|
20
|
-
read_back.project.description.as_deref(),
|
|
21
|
-
Some("A test project")
|
|
22
|
-
);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
#[test]
|
|
26
|
-
fn config_has_correct_defaults() {
|
|
27
|
-
let dir = setup();
|
|
28
|
-
let path = dir.path().join("config.toml");
|
|
29
|
-
|
|
30
|
-
let config = Config::new("proj", "");
|
|
31
|
-
write_config(&path, &config).unwrap();
|
|
32
|
-
|
|
33
|
-
let read_back = read_config(&path).unwrap();
|
|
34
|
-
assert_eq!(read_back.settings.history_limit, 20);
|
|
35
|
-
assert_eq!(read_back.settings.done_task_limit, 10);
|
|
36
|
-
assert!(read_back.settings.auto_git_summary);
|
|
37
|
-
assert!(read_back.settings.context_files.is_empty());
|
|
38
|
-
assert_eq!(read_back.dashboard.scan_dirs, vec!["~/pro/"]);
|
|
39
|
-
assert!(read_back.dashboard.exclude_patterns.is_empty());
|
|
40
|
-
assert!(read_back.project.description.is_none());
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
#[test]
|
|
44
|
-
fn config_with_custom_values() {
|
|
45
|
-
let dir = setup();
|
|
46
|
-
let path = dir.path().join("config.toml");
|
|
47
|
-
|
|
48
|
-
let toml_content = r#"
|
|
49
|
-
[project]
|
|
50
|
-
name = "custom-proj"
|
|
51
|
-
description = "Custom"
|
|
52
|
-
|
|
53
|
-
[settings]
|
|
54
|
-
history_limit = 50
|
|
55
|
-
done_task_limit = 5
|
|
56
|
-
auto_git_summary = false
|
|
57
|
-
context_files = ["README.md", "CLAUDE.md"]
|
|
58
|
-
|
|
59
|
-
[dashboard]
|
|
60
|
-
scan_dirs = ["~/work/", "~/projects/"]
|
|
61
|
-
exclude_patterns = ["*/target", "*/node_modules"]
|
|
62
|
-
"#;
|
|
63
|
-
fs::write(&path, toml_content).unwrap();
|
|
64
|
-
|
|
65
|
-
let config = read_config(&path).unwrap();
|
|
66
|
-
assert_eq!(config.project.name, "custom-proj");
|
|
67
|
-
assert_eq!(config.settings.history_limit, 50);
|
|
68
|
-
assert_eq!(config.settings.done_task_limit, 5);
|
|
69
|
-
assert!(!config.settings.auto_git_summary);
|
|
70
|
-
assert_eq!(config.settings.context_files.len(), 2);
|
|
71
|
-
assert_eq!(config.dashboard.scan_dirs.len(), 2);
|
|
72
|
-
assert_eq!(config.dashboard.exclude_patterns.len(), 2);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
#[test]
|
|
76
|
-
fn config_missing_file_returns_error() {
|
|
77
|
-
let dir = setup();
|
|
78
|
-
let path = dir.path().join("nonexistent.toml");
|
|
79
|
-
assert!(read_config(&path).is_err());
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
#[test]
|
|
83
|
-
fn config_invalid_toml_returns_error() {
|
|
84
|
-
let dir = setup();
|
|
85
|
-
let path = dir.path().join("bad.toml");
|
|
86
|
-
fs::write(&path, "this is not valid toml [[[").unwrap();
|
|
87
|
-
assert!(read_config(&path).is_err());
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
#[test]
|
|
91
|
-
fn config_partial_toml_uses_defaults() {
|
|
92
|
-
let dir = setup();
|
|
93
|
-
let path = dir.path().join("config.toml");
|
|
94
|
-
fs::write(
|
|
95
|
-
&path,
|
|
96
|
-
r#"
|
|
97
|
-
[project]
|
|
98
|
-
name = "minimal"
|
|
99
|
-
"#,
|
|
100
|
-
)
|
|
101
|
-
.unwrap();
|
|
102
|
-
|
|
103
|
-
let config = read_config(&path).unwrap();
|
|
104
|
-
assert_eq!(config.project.name, "minimal");
|
|
105
|
-
assert_eq!(config.settings.history_limit, 20);
|
|
106
|
-
assert_eq!(config.dashboard.scan_dirs, vec!["~/pro/"]);
|
|
107
|
-
}
|