use metis_core::{
application::Application,
dal::Database,
};
use rust_mcp_sdk::{
macros::{mcp_tool, JsonSchema},
schema::{schema_utils::CallToolError, CallToolResult, TextContent},
};
use serde::{Deserialize, Serialize};
use std::path::Path;
use tokio::fs;
#[mcp_tool(
name = "update_exit_criterion",
description = "Update the completion status of an exit criterion",
idempotent_hint = false,
destructive_hint = false,
open_world_hint = false,
read_only_hint = false
)]
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct UpdateExitCriterionTool {
pub project_path: String,
pub document_path: String,
pub criterion_title: String,
pub completed: bool,
pub notes: Option<String>,
}
impl UpdateExitCriterionTool {
pub async fn call_tool(&self) -> std::result::Result<CallToolResult, CallToolError> {
let metis_dir = Path::new(&self.project_path);
if !metis_dir.exists() || !metis_dir.is_dir() {
return Err(CallToolError::new(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!(
"Metis workspace not found at {}. Run initialize_project first.",
metis_dir.display()
),
)));
}
let full_document_path = metis_dir.join(&self.document_path);
if !full_document_path.exists() {
return Err(CallToolError::new(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("Document not found at {}", full_document_path.display()),
)));
}
let content = fs::read_to_string(&full_document_path)
.await
.map_err(|e| CallToolError::new(e))?;
let updated_content = self.update_exit_criterion(&content)?;
fs::write(&full_document_path, updated_content)
.await
.map_err(|e| CallToolError::new(e))?;
self.sync_workspace(metis_dir).await?;
let response = serde_json::json!({
"success": true,
"document_path": self.document_path,
"criterion_title": self.criterion_title,
"completed": self.completed,
"notes": self.notes,
"message": format!(
"Successfully {} exit criterion '{}'",
if self.completed { "completed" } else { "unchecked" },
self.criterion_title
),
"auto_synced": true
});
Ok(CallToolResult::text_content(vec![TextContent::from(
serde_json::to_string_pretty(&response).map_err(CallToolError::new)?,
)]))
}
fn update_exit_criterion(&self, content: &str) -> Result<String, CallToolError> {
let lines: Vec<&str> = content.lines().collect();
let mut result_lines = Vec::new();
let mut found_criterion = false;
for line in lines {
let trimmed = line.trim();
if trimmed.starts_with("- [") {
if let Some(checkbox_end) = trimmed.find(']') {
if checkbox_end >= 3 {
let criterion_text = if trimmed.len() > checkbox_end + 1 {
trimmed[checkbox_end + 1..].trim()
} else {
""
};
if criterion_text.contains(&self.criterion_title)
|| criterion_text.trim() == self.criterion_title.trim()
{
found_criterion = true;
let indentation = &line[..line.len() - line.trim_start().len()];
let checkbox_mark = if self.completed { "x" } else { " " };
let mut updated_line =
format!("{}− [{}] {}", indentation, checkbox_mark, criterion_text);
if self.completed && self.notes.is_some() {
let notes = self.notes.as_ref().unwrap();
if !notes.is_empty() {
updated_line = format!("{} ({})", updated_line, notes);
}
}
result_lines.push(updated_line);
} else {
result_lines.push(line.to_string());
}
} else {
result_lines.push(line.to_string());
}
} else {
result_lines.push(line.to_string());
}
} else {
result_lines.push(line.to_string());
}
}
if !found_criterion {
return Err(CallToolError::new(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!(
"Exit criterion '{}' not found in document",
self.criterion_title
),
)));
}
Ok(result_lines.join("\n"))
}
async fn sync_workspace(&self, metis_dir: &Path) -> Result<(), CallToolError> {
let db_path = metis_dir.join("metis.db");
let database = Database::new(db_path.to_str().unwrap()).map_err(|e| {
CallToolError::new(std::io::Error::new(
std::io::ErrorKind::Other,
format!("Failed to open database for sync: {}", e),
))
})?;
let app = Application::new(database);
app.sync_directory(metis_dir)
.await
.map_err(|e| CallToolError::new(e))?;
Ok(())
}
}