1use super::{
12 IdList, get_string, get_string_or_array, get_string_or_array_or_wildcard,
13 make_tool_with_prompts,
14};
15use crate::config::Prompts;
16use crate::db::Database;
17use crate::db::locks::ExclusiveLockResult;
18use crate::error::ToolError;
19use crate::format::{OutputFormat, ToolResult};
20use anyhow::Result;
21use rmcp::model::Tool;
22use serde_json::{Value, json};
23use std::path::{Component, Path, PathBuf};
24
25const LOCK_PREFIX: &str = "lock:";
27
28pub(crate) fn normalize_file_path(path: &str) -> String {
41 let path = Path::new(path);
42
43 let absolute = if path.is_absolute() {
45 path.to_path_buf()
46 } else {
47 std::env::current_dir()
49 .unwrap_or_else(|_| PathBuf::from("."))
50 .join(path)
51 };
52
53 let normalized = normalize_path_components(&absolute);
55
56 path_to_forward_slashes(&normalized)
58}
59
60fn normalize_path_components(path: &Path) -> PathBuf {
63 let mut components = Vec::new();
64
65 for component in path.components() {
66 match component {
67 Component::Prefix(p) => {
68 components.push(Component::Prefix(p));
70 }
71 Component::RootDir => {
72 components.push(Component::RootDir);
73 }
74 Component::CurDir => {
75 }
77 Component::ParentDir => {
78 if let Some(Component::Normal(_)) = components.last() {
80 components.pop();
81 } else {
82 components.push(Component::ParentDir);
85 }
86 }
87 Component::Normal(name) => {
88 components.push(Component::Normal(name));
89 }
90 }
91 }
92
93 components.iter().collect()
94}
95
96fn path_to_forward_slashes(path: &Path) -> String {
98 path.to_string_lossy().replace('\\', "/")
99}
100
101fn normalize_file_paths(paths: Vec<String>) -> Vec<String> {
103 paths.into_iter().map(|p| normalize_file_path(&p)).collect()
104}
105
106fn format_duration(ms: i64) -> String {
108 if ms < 1000 {
109 return format!("{}ms", ms);
110 }
111 let secs = ms / 1000;
112 if secs < 60 {
113 return format!("{}s", secs);
114 }
115 let mins = secs / 60;
116 if mins < 60 {
117 let rem_secs = secs % 60;
118 return if rem_secs > 0 {
119 format!("{}m {}s", mins, rem_secs)
120 } else {
121 format!("{}m", mins)
122 };
123 }
124 let hours = mins / 60;
125 let rem_mins = mins % 60;
126 if rem_mins > 0 {
127 format!("{}h {}m", hours, rem_mins)
128 } else {
129 format!("{}h", hours)
130 }
131}
132
133pub fn get_tools(prompts: &Prompts) -> Vec<Tool> {
134 vec![
135 make_tool_with_prompts(
136 "mark_file",
137 "Mark a file to signal intent to work on it (advisory, non-blocking). Returns warning if another agent has marked the file. Track changes via mark_updates.\n\nUse the `lock:` prefix for exclusive locks: `lock:resource-name` will reject (not just warn) if another agent holds the lock. Example: `mark_file(file=\"lock:git-commit\")` acquires a mutual-exclusion lock on the resource \"git-commit\".",
138 json!({
139 "agent": {
140 "type": "string",
141 "description": "Agent ID"
142 },
143 "file": {
144 "oneOf": [
145 { "type": "string" },
146 { "type": "array", "items": { "type": "string" } }
147 ],
148 "description": "Relative file path, array of file paths, or lock resource(s) with 'lock:' prefix (e.g. 'lock:git-commit' for exclusive locks)"
149 },
150 "task": {
151 "type": "string",
152 "description": "Optional task ID to associate with the mark (for auto-cleanup when task completes)"
153 },
154 "reason": {
155 "type": "string",
156 "description": "Optional reason for marking (visible to other agents)"
157 }
158 }),
159 vec!["agent", "file"],
160 prompts,
161 ),
162 make_tool_with_prompts(
163 "unmark_file",
164 "Remove mark from a file. Optionally include a note for the next agent.",
165 json!({
166 "agent": {
167 "type": "string",
168 "description": "Agent ID"
169 },
170 "file": {
171 "oneOf": [
172 { "type": "string" },
173 { "type": "array", "items": { "type": "string" } }
174 ],
175 "description": "Relative file path, array of paths, or '*' to unmark all files held by this agent"
176 },
177 "task": {
178 "type": "string",
179 "description": "Optional task ID - unmark all files associated with this task"
180 },
181 "reason": {
182 "type": "string",
183 "description": "Optional reason/note for next agent"
184 }
185 }),
186 vec!["agent"],
187 prompts,
188 ),
189 make_tool_with_prompts(
190 "list_marks",
191 "Get current file marks. Requires at least one filter: agent, task, or files.",
192 json!({
193 "files": {
194 "type": "array",
195 "items": { "type": "string" },
196 "description": "Specific file paths to check"
197 },
198 "agent": {
199 "type": "string",
200 "description": "Filter by agent ID"
201 },
202 "task": {
203 "type": "string",
204 "description": "Filter by task ID"
205 }
206 }),
207 vec![],
208 prompts,
209 ),
210 make_tool_with_prompts(
211 "mark_updates",
212 "Poll for file mark changes since last call. Returns new marks and removals. Use for coordination between agents.",
213 json!({
214 "agent": {
215 "type": "string",
216 "description": "Agent ID (tracks poll position)"
217 }
218 }),
219 vec!["agent"],
220 prompts,
221 ),
222 ]
223}
224
225pub fn mark_file(db: &Database, args: Value) -> Result<Value> {
226 let worker_id = get_string(&args, "agent").ok_or_else(|| ToolError::missing_field("agent"))?;
227 let file_paths =
228 get_string_or_array(&args, "file").ok_or_else(|| ToolError::missing_field("file"))?;
229 let task_id = get_string(&args, "task");
230 let reason = get_string(&args, "reason");
231
232 let mut lock_paths: Vec<String> = Vec::new();
234 let mut regular_paths: Vec<String> = Vec::new();
235
236 for path in file_paths {
237 if path.starts_with(LOCK_PREFIX) {
238 lock_paths.push(path);
240 } else {
241 regular_paths.push(path);
242 }
243 }
244
245 let normalized_regular = normalize_file_paths(regular_paths);
247
248 let mut results = Vec::new();
249 let mut warnings = Vec::new();
250 let mut locks_acquired = Vec::new();
251
252 for lock_path in &lock_paths {
254 let result = db.lock_file_exclusive(
255 lock_path.clone(),
256 &worker_id,
257 reason.clone(),
258 task_id.clone(),
259 )?;
260
261 match result {
262 ExclusiveLockResult::HeldByOther(other_agent) => {
263 return Err(ToolError::lock_conflict(lock_path, &other_agent).into());
265 }
266 ExclusiveLockResult::Acquired => {
267 locks_acquired.push(lock_path.clone());
268 }
269 ExclusiveLockResult::AlreadyHeldBySelf => {
270 locks_acquired.push(lock_path.clone());
271 }
272 }
273 }
274
275 for file_path in &normalized_regular {
277 let warning = db.lock_file(
278 file_path.clone(),
279 &worker_id,
280 reason.clone(),
281 task_id.clone(),
282 )?;
283
284 if let Some(other_agent) = warning {
285 warnings.push(json!({
286 "file": file_path,
287 "marked_by": other_agent
288 }));
289 }
290 results.push(file_path.clone());
291 }
292
293 let mut response = json!({
294 "success": true,
295 "marked": results
296 });
297
298 if !locks_acquired.is_empty() {
299 response["locks_acquired"] = json!(locks_acquired);
300 }
301
302 if !warnings.is_empty() {
303 response["warnings"] = json!(warnings);
304 }
305
306 Ok(response)
307}
308
309pub fn unmark_file(db: &Database, args: Value) -> Result<Value> {
310 let worker_id = get_string(&args, "agent").ok_or_else(|| ToolError::missing_field("agent"))?;
311 let reason = get_string(&args, "reason");
312 let task_id = get_string(&args, "task");
313
314 if let Some(tid) = task_id {
316 let unmarked = db.release_task_locks_verbose(&tid, reason)?;
317 return Ok(json!({
318 "success": true,
319 "unmarked": unmarked.iter().map(|(f, w)| json!({
320 "file": f,
321 "agent": w
322 })).collect::<Vec<_>>(),
323 "count": unmarked.len()
324 }));
325 }
326
327 let file_param = get_string_or_array_or_wildcard(&args, "file");
329
330 match file_param {
331 Some(IdList::Wildcard) => {
332 let unmarked = db.release_worker_locks_verbose(&worker_id, reason)?;
334 Ok(json!({
335 "success": true,
336 "unmarked": unmarked.iter().map(|(f, w)| json!({
337 "file": f,
338 "agent": w
339 })).collect::<Vec<_>>(),
340 "count": unmarked.len()
341 }))
342 }
343 Some(IdList::Ids(files)) => {
344 let mut all_paths: Vec<String> = Vec::new();
346 for f in files {
347 if f.starts_with(LOCK_PREFIX) {
348 all_paths.push(f);
349 } else {
350 all_paths.push(normalize_file_path(&f));
351 }
352 }
353 let unmarked = db.unlock_files_verbose(all_paths, &worker_id, reason)?;
355 Ok(json!({
356 "success": true,
357 "unmarked": unmarked.iter().map(|(f, w)| json!({
358 "file": f,
359 "agent": w
360 })).collect::<Vec<_>>(),
361 "count": unmarked.len()
362 }))
363 }
364 None => {
365 Err(ToolError::missing_field("file or task").into())
367 }
368 }
369}
370
371pub fn list_marks(db: &Database, default_format: OutputFormat, args: Value) -> Result<ToolResult> {
372 let files = get_string_or_array(&args, "files");
373 let worker_id = get_string(&args, "agent");
374 let task_id = get_string(&args, "task");
375 let format = get_string(&args, "format")
376 .and_then(|s| OutputFormat::parse(&s))
377 .unwrap_or(default_format);
378
379 if files.is_none() && worker_id.is_none() && task_id.is_none() {
381 return Err(ToolError::invalid_value(
382 "filter",
383 "At least one filter required: agent, task, or files",
384 )
385 .into());
386 }
387
388 let normalized_files = files.map(|paths| {
390 paths
391 .into_iter()
392 .map(|p| {
393 if p.starts_with(LOCK_PREFIX) {
394 p
395 } else {
396 normalize_file_path(&p)
397 }
398 })
399 .collect()
400 });
401
402 let marks = db.get_file_locks(normalized_files, worker_id.as_deref(), task_id.as_deref())?;
403 let now = crate::db::now_ms();
404
405 match format {
406 OutputFormat::Markdown => {
407 let mut md = String::from("# File Marks\n\n");
408 if marks.is_empty() {
409 md.push_str("No marks found.\n");
410 } else {
411 md.push_str("| File | Type | Agent | Task | Reason | Age |\n");
412 md.push_str("|------|------|-------|------|--------|-----|\n");
413 for (path, mark) in &marks {
414 let age_ms = now - mark.locked_at;
415 let age_str = format_duration(age_ms);
416 let lock_type = if path.starts_with(LOCK_PREFIX) {
417 "exclusive"
418 } else {
419 "advisory"
420 };
421 md.push_str(&format!(
422 "| {} | {} | {} | {} | {} | {} |\n",
423 path,
424 lock_type,
425 mark.worker_id,
426 mark.task_id.as_deref().unwrap_or("-"),
427 mark.reason.as_deref().unwrap_or("-"),
428 age_str
429 ));
430 }
431 }
432 Ok(ToolResult::Raw(md))
433 }
434 OutputFormat::Json => {
435 let marks_json: Vec<Value> = marks
436 .into_iter()
437 .map(|(path, mark)| {
438 let is_lock = path.starts_with(LOCK_PREFIX);
439 let age_ms = now - mark.locked_at;
440 json!({
441 "file": path,
442 "is_lock": is_lock,
443 "agent": mark.worker_id,
444 "task_id": mark.task_id,
445 "reason": mark.reason,
446 "marked_at": crate::types::ms_to_iso(mark.locked_at),
447 "mark_age_ms": age_ms
448 })
449 })
450 .collect();
451
452 Ok(ToolResult::Json(json!({ "marks": marks_json })))
453 }
454 }
455}
456
457pub async fn mark_updates_async(db: std::sync::Arc<Database>, args: Value) -> Result<Value> {
459 let worker_id = get_string(&args, "agent").ok_or_else(|| ToolError::missing_field("agent"))?;
460
461 let updates = tokio::task::spawn_blocking(move || db.claim_updates(&worker_id))
463 .await
464 .map_err(|e| anyhow::anyhow!("Task join error: {}", e))??;
465
466 Ok(json!({
467 "new_marks": updates.new_claims.iter().map(|e| json!({
468 "file": e.file_path,
469 "agent": e.worker_id,
470 "reason": e.reason,
471 "marked_at": crate::types::ms_to_iso(e.timestamp)
472 })).collect::<Vec<_>>(),
473 "removed_marks": updates.dropped_claims.iter().map(|e| json!({
474 "file": e.file_path,
475 "agent": e.worker_id,
476 "reason": e.reason,
477 "removed_at": crate::types::ms_to_iso(e.timestamp)
478 })).collect::<Vec<_>>(),
479 "sequence": updates.sequence
480 }))
481}
482
483pub fn mark_updates(db: &Database, args: Value) -> Result<Value> {
485 let worker_id = get_string(&args, "agent").ok_or_else(|| ToolError::missing_field("agent"))?;
486
487 let updates = db.claim_updates(&worker_id)?;
488
489 Ok(json!({
490 "new_marks": updates.new_claims.iter().map(|e| json!({
491 "file": e.file_path,
492 "agent": e.worker_id,
493 "reason": e.reason,
494 "marked_at": crate::types::ms_to_iso(e.timestamp)
495 })).collect::<Vec<_>>(),
496 "removed_marks": updates.dropped_claims.iter().map(|e| json!({
497 "file": e.file_path,
498 "agent": e.worker_id,
499 "reason": e.reason,
500 "removed_at": crate::types::ms_to_iso(e.timestamp)
501 })).collect::<Vec<_>>(),
502 "sequence": updates.sequence
503 }))
504}
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509
510 #[test]
511 fn test_normalize_path_components() {
512 let path = Path::new("/foo/./bar/./baz");
514 let normalized = normalize_path_components(path);
515 assert_eq!(path_to_forward_slashes(&normalized), "/foo/bar/baz");
516
517 let path = Path::new("/foo/bar/../baz");
519 let normalized = normalize_path_components(path);
520 assert_eq!(path_to_forward_slashes(&normalized), "/foo/baz");
521
522 let path = Path::new("/foo/bar/./baz/../qux");
524 let normalized = normalize_path_components(path);
525 assert_eq!(path_to_forward_slashes(&normalized), "/foo/bar/qux");
526 }
527
528 #[test]
529 fn test_path_to_forward_slashes() {
530 let path = Path::new("C:\\foo\\bar\\baz");
532 assert_eq!(path_to_forward_slashes(path), "C:/foo/bar/baz");
533
534 let path = Path::new("/foo/bar/baz");
536 assert_eq!(path_to_forward_slashes(path), "/foo/bar/baz");
537 }
538
539 #[test]
540 fn test_normalize_file_paths() {
541 let paths = vec!["src/main.rs".to_string(), "./src/lib.rs".to_string()];
543 let normalized = normalize_file_paths(paths);
544
545 for path in &normalized {
547 assert!(
548 path.starts_with('/') || (path.len() > 2 && path.chars().nth(1) == Some(':')),
549 "Path should be absolute: {}",
550 path
551 );
552 }
553 }
554}