use super::{get_string, get_string_array, get_string_or_array, make_tool_with_prompts};
use crate::config::Prompts;
use crate::db::Database;
use crate::error::ToolError;
use crate::format::{markdown_to_json, OutputFormat};
use anyhow::Result;
use rmcp::model::Tool;
use serde_json::{json, Value};
use std::path::{Component, Path, PathBuf};
fn normalize_file_path(path: &str) -> String {
let path = Path::new(path);
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(path)
};
let normalized = normalize_path_components(&absolute);
path_to_forward_slashes(&normalized)
}
fn normalize_path_components(path: &Path) -> PathBuf {
let mut components = Vec::new();
for component in path.components() {
match component {
Component::Prefix(p) => {
components.push(Component::Prefix(p));
}
Component::RootDir => {
components.push(Component::RootDir);
}
Component::CurDir => {
}
Component::ParentDir => {
if let Some(Component::Normal(_)) = components.last() {
components.pop();
} else {
components.push(Component::ParentDir);
}
}
Component::Normal(name) => {
components.push(Component::Normal(name));
}
}
}
components.iter().collect()
}
fn path_to_forward_slashes(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
fn normalize_file_paths(paths: Vec<String>) -> Vec<String> {
paths.into_iter().map(|p| normalize_file_path(&p)).collect()
}
fn format_duration(ms: i64) -> String {
if ms < 1000 {
return format!("{}ms", ms);
}
let secs = ms / 1000;
if secs < 60 {
return format!("{}s", secs);
}
let mins = secs / 60;
if mins < 60 {
let rem_secs = secs % 60;
return if rem_secs > 0 {
format!("{}m {}s", mins, rem_secs)
} else {
format!("{}m", mins)
};
}
let hours = mins / 60;
let rem_mins = mins % 60;
if rem_mins > 0 {
format!("{}h {}m", hours, rem_mins)
} else {
format!("{}h", hours)
}
}
pub fn get_tools(prompts: &Prompts) -> Vec<Tool> {
vec![
make_tool_with_prompts(
"mark_file",
"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.",
json!({
"agent": {
"type": "string",
"description": "Agent ID"
},
"file": {
"oneOf": [
{ "type": "string" },
{ "type": "array", "items": { "type": "string" } }
],
"description": "Relative file path or array of file paths"
},
"task": {
"type": "string",
"description": "Optional task ID to associate with the mark (for auto-cleanup when task completes)"
},
"reason": {
"type": "string",
"description": "Optional reason for marking (visible to other agents)"
}
}),
vec!["agent", "file"],
prompts,
),
make_tool_with_prompts(
"unmark_file",
"Remove mark from a file. Optionally include a note for the next agent.",
json!({
"agent": {
"type": "string",
"description": "Agent ID"
},
"file": {
"oneOf": [
{ "type": "string" },
{ "type": "array", "items": { "type": "string" } }
],
"description": "Relative file path, array of paths, or '*' to unmark all files held by this agent"
},
"task": {
"type": "string",
"description": "Optional task ID - unmark all files associated with this task"
},
"reason": {
"type": "string",
"description": "Optional reason/note for next agent"
}
}),
vec!["agent"],
prompts,
),
make_tool_with_prompts(
"list_marks",
"Get current file marks. Requires at least one filter: agent, task, or files.",
json!({
"files": {
"type": "array",
"items": { "type": "string" },
"description": "Specific file paths to check"
},
"agent": {
"type": "string",
"description": "Filter by agent ID"
},
"task": {
"type": "string",
"description": "Filter by task ID"
}
}),
vec![],
prompts,
),
make_tool_with_prompts(
"mark_updates",
"Poll for file mark changes since last call. Returns new marks and removals. Use for coordination between agents.",
json!({
"agent": {
"type": "string",
"description": "Agent ID (tracks poll position)"
}
}),
vec!["agent"],
prompts,
),
]
}
pub fn mark_file(db: &Database, args: Value) -> Result<Value> {
let worker_id = get_string(&args, "agent")
.ok_or_else(|| ToolError::missing_field("agent"))?;
let file_paths = get_string_or_array(&args, "file")
.ok_or_else(|| ToolError::missing_field("file"))?;
let task_id = get_string(&args, "task");
let reason = get_string(&args, "reason");
let normalized_paths = normalize_file_paths(file_paths);
let mut results = Vec::new();
let mut warnings = Vec::new();
for file_path in &normalized_paths {
let warning = db.lock_file(file_path.clone(), &worker_id, reason.clone(), task_id.clone())?;
if let Some(other_agent) = warning {
warnings.push(json!({
"file": file_path,
"marked_by": other_agent
}));
}
results.push(file_path.clone());
}
let mut response = json!({
"success": true,
"marked": results
});
if !warnings.is_empty() {
response["warnings"] = json!(warnings);
}
Ok(response)
}
pub fn unmark_file(db: &Database, args: Value) -> Result<Value> {
let worker_id = get_string(&args, "agent")
.ok_or_else(|| ToolError::missing_field("agent"))?;
let reason = get_string(&args, "reason");
let task_id = get_string(&args, "task");
if let Some(tid) = task_id {
let unmarked = db.release_task_locks_verbose(&tid, reason)?;
return Ok(json!({
"success": true,
"unmarked": unmarked.iter().map(|(f, w)| json!({
"file": f,
"agent": w
})).collect::<Vec<_>>(),
"count": unmarked.len()
}));
}
let file_param = get_string_or_array(&args, "file");
match file_param {
Some(files) if files.len() == 1 && files[0] == "*" => {
let unmarked = db.release_worker_locks_verbose(&worker_id, reason)?;
Ok(json!({
"success": true,
"unmarked": unmarked.iter().map(|(f, w)| json!({
"file": f,
"agent": w
})).collect::<Vec<_>>(),
"count": unmarked.len()
}))
}
Some(files) => {
let normalized_files = normalize_file_paths(files);
let unmarked = db.unlock_files_verbose(normalized_files, &worker_id, reason)?;
Ok(json!({
"success": true,
"unmarked": unmarked.iter().map(|(f, w)| json!({
"file": f,
"agent": w
})).collect::<Vec<_>>(),
"count": unmarked.len()
}))
}
None => {
Err(ToolError::missing_field("file or task").into())
}
}
}
pub fn list_marks(db: &Database, default_format: OutputFormat, args: Value) -> Result<Value> {
let files = get_string_array(&args, "files");
let worker_id = get_string(&args, "agent");
let task_id = get_string(&args, "task");
let format = get_string(&args, "format")
.and_then(|s| OutputFormat::from_str(&s))
.unwrap_or(default_format);
if files.is_none() && worker_id.is_none() && task_id.is_none() {
return Err(ToolError::invalid_value(
"filter",
"At least one filter required: agent, task, or files"
).into());
}
let normalized_files = files.map(normalize_file_paths);
let marks = db.get_file_locks(normalized_files, worker_id.as_deref(), task_id.as_deref())?;
let now = crate::db::now_ms();
match format {
OutputFormat::Markdown => {
let mut md = String::from("# File Marks\n\n");
if marks.is_empty() {
md.push_str("No marks found.\n");
} else {
md.push_str("| File | Agent | Task | Reason | Age |\n");
md.push_str("|------|-------|------|--------|-----|\n");
for (path, mark) in &marks {
let age_ms = now - mark.locked_at;
let age_str = format_duration(age_ms);
md.push_str(&format!(
"| {} | {} | {} | {} | {} |\n",
path,
mark.worker_id,
mark.task_id.as_deref().unwrap_or("-"),
mark.reason.as_deref().unwrap_or("-"),
age_str
));
}
}
Ok(markdown_to_json(md))
}
OutputFormat::Json => {
let marks_json: Vec<Value> = marks
.into_iter()
.map(|(path, mark)| {
let age_ms = now - mark.locked_at;
json!({
"file": path,
"agent": mark.worker_id,
"task_id": mark.task_id,
"reason": mark.reason,
"marked_at": mark.locked_at,
"mark_age_ms": age_ms
})
})
.collect();
Ok(json!({ "marks": marks_json }))
}
}
}
pub async fn mark_updates_async(db: std::sync::Arc<Database>, args: Value) -> Result<Value> {
let worker_id = get_string(&args, "agent")
.ok_or_else(|| ToolError::missing_field("agent"))?;
let updates = tokio::task::spawn_blocking(move || {
db.claim_updates(&worker_id)
})
.await
.map_err(|e| anyhow::anyhow!("Task join error: {}", e))??;
Ok(json!({
"new_marks": updates.new_claims.iter().map(|e| json!({
"file": e.file_path,
"agent": e.worker_id,
"reason": e.reason,
"marked_at": e.timestamp
})).collect::<Vec<_>>(),
"removed_marks": updates.dropped_claims.iter().map(|e| json!({
"file": e.file_path,
"agent": e.worker_id,
"reason": e.reason,
"removed_at": e.timestamp
})).collect::<Vec<_>>(),
"sequence": updates.sequence
}))
}
pub fn mark_updates(db: &Database, args: Value) -> Result<Value> {
let worker_id = get_string(&args, "agent")
.ok_or_else(|| ToolError::missing_field("agent"))?;
let updates = db.claim_updates(&worker_id)?;
Ok(json!({
"new_marks": updates.new_claims.iter().map(|e| json!({
"file": e.file_path,
"agent": e.worker_id,
"reason": e.reason,
"marked_at": e.timestamp
})).collect::<Vec<_>>(),
"removed_marks": updates.dropped_claims.iter().map(|e| json!({
"file": e.file_path,
"agent": e.worker_id,
"reason": e.reason,
"removed_at": e.timestamp
})).collect::<Vec<_>>(),
"sequence": updates.sequence
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_normalize_path_components() {
let path = Path::new("/foo/./bar/./baz");
let normalized = normalize_path_components(path);
assert_eq!(path_to_forward_slashes(&normalized), "/foo/bar/baz");
let path = Path::new("/foo/bar/../baz");
let normalized = normalize_path_components(path);
assert_eq!(path_to_forward_slashes(&normalized), "/foo/baz");
let path = Path::new("/foo/bar/./baz/../qux");
let normalized = normalize_path_components(path);
assert_eq!(path_to_forward_slashes(&normalized), "/foo/bar/qux");
}
#[test]
fn test_path_to_forward_slashes() {
let path = Path::new("C:\\foo\\bar\\baz");
assert_eq!(path_to_forward_slashes(path), "C:/foo/bar/baz");
let path = Path::new("/foo/bar/baz");
assert_eq!(path_to_forward_slashes(path), "/foo/bar/baz");
}
#[test]
fn test_normalize_file_paths() {
let paths = vec![
"src/main.rs".to_string(),
"./src/lib.rs".to_string(),
];
let normalized = normalize_file_paths(paths);
for path in &normalized {
assert!(
path.starts_with('/') || (path.len() > 2 && path.chars().nth(1) == Some(':')),
"Path should be absolute: {}",
path
);
}
}
}