use regex::Regex;
use serde_json::Value;
use crate::client::ConfluenceClient;
use crate::mcp::{CallToolResult, ToolDefinition};
use crate::types::{ConfluencePage, ConfluencePageV1};
use super::schema;
pub fn definitions() -> Vec<ToolDefinition> {
vec![
ToolDefinition {
name: "update_release_table_cell".to_string(),
description: "Update a specific cell in a Confluence table. Finds a row by identifier and updates a specific column.".to_string(),
input_schema: schema(&[
("pageId", "string", true, "The page ID containing the table"),
("rowIdentifier", "string", true, "Text to identify the row (e.g., a date, feature name, or SDLC number)"),
("columnIdentifier", "string", true, "Text to identify the column header"),
("newContent", "string", true, "New HTML/storage format content for the cell"),
("replaceMode", "string", false, "How to update: replace (default), append, or prepend"),
]),
},
ToolDefinition {
name: "find_replace_in_page".to_string(),
description: "Find and replace text/patterns in a Confluence page storage format."
.to_string(),
input_schema: schema(&[
("pageId", "string", true, "The page ID to update"),
(
"findPattern",
"string",
true,
"The text or regex pattern to find",
),
(
"replaceWith",
"string",
true,
"The replacement text/content",
),
(
"isRegex",
"boolean",
false,
"Whether findPattern is a regex (default: false)",
),
(
"globalReplace",
"boolean",
false,
"Replace all occurrences (default: false)",
),
]),
},
ToolDefinition {
name: "insert_table_row".to_string(),
description: "Insert a new row into a Confluence table.".to_string(),
input_schema: schema(&[
("pageId", "string", true, "The page ID containing the table"),
(
"tableIdentifier",
"string",
true,
"Text to identify the table (e.g., a column header)",
),
(
"afterRowIdentifier",
"string",
false,
"Insert after the row containing this text (default: end of table)",
),
(
"rowContent",
"string",
true,
"Complete <tr>...</tr> HTML for the new row",
),
]),
},
]
}
async fn fetch_page_for_edit(
client: &ConfluenceClient,
page_id: &str,
) -> Result<(String, String, i64), String> {
if client.config().is_cloud {
let page: ConfluencePage = client
.get(&format!("/pages/{page_id}?body-format=storage"))
.await?;
let body = page
.body
.and_then(|b| b.storage)
.map_or(String::new(), |s| s.value);
let ver = page.version.map_or(1, |v| v.number);
Ok((page.title, body, ver))
} else {
let page: ConfluencePageV1 = client
.get(&format!(
"/content/{page_id}?expand=body.storage,version"
))
.await?;
let body = page
.body
.and_then(|b| b.storage)
.map_or(String::new(), |s| s.value);
let ver = page.version.map_or(1, |v| v.number);
Ok((page.title, body, ver))
}
}
async fn save_page(
client: &ConfluenceClient,
page_id: &str,
title: &str,
content: &str,
version: i64,
message: &str,
) -> Result<(), String> {
if client.config().is_cloud {
let body = serde_json::json!({
"id": page_id,
"status": "current",
"title": title,
"body": {
"representation": "storage",
"value": content,
},
"version": {
"number": version,
"message": message,
}
});
client
.put::<Value>(&format!("/pages/{page_id}"), &body)
.await?;
} else {
let body = serde_json::json!({
"type": "page",
"title": title,
"body": {
"storage": {
"value": content,
"representation": "storage"
}
},
"version": {
"number": version,
"message": message,
}
});
client
.put::<Value>(&format!("/content/{page_id}"), &body)
.await?;
}
Ok(())
}
pub async fn update_release_table_cell(client: &ConfluenceClient, args: &Value) -> CallToolResult {
let page_id = match args.get("pageId").and_then(|v| v.as_str()) {
Some(id) => id,
None => return CallToolResult::text("Error: pageId is required."),
};
let row_id = match args.get("rowIdentifier").and_then(|v| v.as_str()) {
Some(r) => r,
None => return CallToolResult::text("Error: rowIdentifier is required."),
};
let col_id = match args.get("columnIdentifier").and_then(|v| v.as_str()) {
Some(c) => c,
None => return CallToolResult::text("Error: columnIdentifier is required."),
};
let new_content = match args.get("newContent").and_then(|v| v.as_str()) {
Some(c) => c,
None => return CallToolResult::text("Error: newContent is required."),
};
let replace_mode = args
.get("replaceMode")
.and_then(|v| v.as_str())
.unwrap_or("replace");
let (title, body, version) = match fetch_page_for_edit(client, page_id).await {
Ok(v) => v,
Err(e) => return CallToolResult::text(format!("Error updating table cell: {e}")),
};
if body.is_empty() {
return CallToolResult::text(format!("Page \"{title}\" has no content."));
}
let table_re = Regex::new(r"(?is)<table[^>]*>[\s\S]*?</table>").unwrap();
let tables: Vec<&str> = table_re.find_iter(&body).map(|m| m.as_str()).collect();
if tables.is_empty() {
return CallToolResult::text(format!("No tables found in page \"{title}\"."));
}
let mut updated_content = body.clone();
let mut cell_found = false;
let mut cell_updated = false;
let col_lower = col_id.to_lowercase();
let row_lower = row_id.to_lowercase();
for table in &tables {
if !table.to_lowercase().contains(&col_lower) {
continue;
}
let row_re = Regex::new(r"(?is)<tr[^>]*>[\s\S]*?</tr>").unwrap();
let rows: Vec<&str> = row_re.find_iter(table).map(|m| m.as_str()).collect();
if rows.is_empty() {
continue;
}
let header_cell_re = Regex::new(r"(?is)<t[hd][^>]*>[\s\S]*?</t[hd]>").unwrap();
let header_cells: Vec<&str> = header_cell_re
.find_iter(rows[0])
.map(|m| m.as_str())
.collect();
let col_idx = header_cells
.iter()
.position(|c| c.to_lowercase().contains(&col_lower));
let col_idx = match col_idx {
Some(i) => i,
None => continue,
};
for row in rows.iter().skip(1) {
if !row.to_lowercase().contains(&row_lower) {
continue;
}
cell_found = true;
let td_re = Regex::new(r"(?is)<td([^>]*)>([\s\S]*?)</td>").unwrap();
let cells: Vec<regex::Captures> = td_re.captures_iter(row).collect();
if col_idx >= cells.len() {
continue;
}
let target_cap = &cells[col_idx];
let full_cell = target_cap.get(0).unwrap().as_str();
let attrs = &target_cap[1];
let existing = &target_cap[2];
let final_content = match replace_mode {
"append" => format!("{existing}{new_content}"),
"prepend" => format!("{new_content}{existing}"),
_ => new_content.to_string(),
};
let new_cell = format!("<td{attrs}>{final_content}</td>");
let new_row = row.replace(full_cell, &new_cell);
let new_table = table.replace(*row, &new_row);
updated_content = updated_content.replace(*table, &new_table);
cell_updated = true;
break;
}
if cell_updated {
break;
}
}
if !cell_found {
return CallToolResult::text(format!(
"Could not find a row containing \"{row_id}\" with column \"{col_id}\" in page \"{title}\".\n\nTip: Use `get_page_storage_format` to inspect the page structure."
));
}
if !cell_updated {
return CallToolResult::text(format!(
"Found the row but could not update the cell. The table structure may be complex.\n\nTip: Use `get_page_storage_format` to inspect the exact structure and use `update_page` directly."
));
}
let new_version = version + 1;
let msg = format!("Updated {col_id} column for {row_id}");
match save_page(client, page_id, &title, &updated_content, new_version, &msg).await {
Ok(()) => CallToolResult::text(format!(
"✅ Successfully updated the \"{col_id}\" column for row \"{row_id}\" in page \"{title}\".\n\n**New Version**: {new_version}"
)),
Err(e) => CallToolResult::text(format!("Error updating table cell: {e}")),
}
}
pub async fn find_replace_in_page(client: &ConfluenceClient, args: &Value) -> CallToolResult {
let page_id = match args.get("pageId").and_then(|v| v.as_str()) {
Some(id) => id,
None => return CallToolResult::text("Error: pageId is required."),
};
let find_pattern = match args.get("findPattern").and_then(|v| v.as_str()) {
Some(p) => p,
None => return CallToolResult::text("Error: findPattern is required."),
};
let replace_with = match args.get("replaceWith").and_then(|v| v.as_str()) {
Some(r) => r,
None => return CallToolResult::text("Error: replaceWith is required."),
};
let is_regex = args
.get("isRegex")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let global_replace = args
.get("globalReplace")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let (title, body, version) = match fetch_page_for_edit(client, page_id).await {
Ok(v) => v,
Err(e) => return CallToolResult::text(format!("Error in find/replace: {e}")),
};
if body.is_empty() {
return CallToolResult::text(format!("Page \"{title}\" has no content."));
}
let match_count = if is_regex {
match Regex::new(find_pattern) {
Ok(re) => re.find_iter(&body).count(),
Err(e) => {
return CallToolResult::text(format!("Invalid regex: {e}"));
}
}
} else {
body.matches(find_pattern).count()
};
if match_count == 0 {
return CallToolResult::text(format!(
"Pattern \"{find_pattern}\" not found in page \"{title}\"."
));
}
let updated_content = if is_regex {
let re = Regex::new(find_pattern).unwrap();
if global_replace {
re.replace_all(&body, replace_with).to_string()
} else {
re.replace(&body, replace_with).to_string()
}
} else if global_replace {
body.replace(find_pattern, replace_with)
} else {
body.replacen(find_pattern, replace_with, 1)
};
let replacement_count = if global_replace { match_count } else { 1 };
let new_version = version + 1;
let truncated_pattern = if find_pattern.len() > 50 {
format!("{}...", &find_pattern[..50])
} else {
find_pattern.to_string()
};
let msg = format!("Find/replace: \"{truncated_pattern}\"");
match save_page(client, page_id, &title, &updated_content, new_version, &msg).await {
Ok(()) => CallToolResult::text(format!(
"✅ Successfully replaced {replacement_count} occurrence(s) in page \"{title}\".\n\n**New Version**: {new_version}"
)),
Err(e) => CallToolResult::text(format!("Error in find/replace: {e}")),
}
}
pub async fn insert_table_row(client: &ConfluenceClient, args: &Value) -> CallToolResult {
let page_id = match args.get("pageId").and_then(|v| v.as_str()) {
Some(id) => id,
None => return CallToolResult::text("Error: pageId is required."),
};
let table_identifier = match args.get("tableIdentifier").and_then(|v| v.as_str()) {
Some(t) => t,
None => return CallToolResult::text("Error: tableIdentifier is required."),
};
let after_row_id = args.get("afterRowIdentifier").and_then(|v| v.as_str());
let row_content = match args.get("rowContent").and_then(|v| v.as_str()) {
Some(r) => r,
None => return CallToolResult::text("Error: rowContent is required."),
};
let (title, body, version) = match fetch_page_for_edit(client, page_id).await {
Ok(v) => v,
Err(e) => return CallToolResult::text(format!("Error inserting table row: {e}")),
};
if body.is_empty() {
return CallToolResult::text(format!("Page \"{title}\" has no content."));
}
let table_re = Regex::new(r"(?is)<table[^>]*>[\s\S]*?</table>").unwrap();
let tables: Vec<&str> = table_re.find_iter(&body).map(|m| m.as_str()).collect();
if tables.is_empty() {
return CallToolResult::text(format!("No tables found in page \"{title}\"."));
}
let table_lower = table_identifier.to_lowercase();
let target_table = tables
.iter()
.find(|t| t.to_lowercase().contains(&table_lower));
let target_table = match target_table {
Some(t) => *t,
None => {
return CallToolResult::text(format!(
"Could not find a table containing \"{table_identifier}\" in page \"{title}\"."
));
}
};
let updated_table = if let Some(after_row) = after_row_id {
let row_re = Regex::new(r"(?is)<tr[^>]*>[\s\S]*?</tr>").unwrap();
let rows: Vec<&str> = row_re.find_iter(target_table).map(|m| m.as_str()).collect();
let after_lower = after_row.to_lowercase();
let mut insert_after_idx = None;
for (i, row) in rows.iter().enumerate() {
if row.to_lowercase().contains(&after_lower) {
insert_after_idx = Some(i);
}
}
match insert_after_idx {
Some(idx) => {
let target_row = rows[idx];
target_table.replace(target_row, &format!("{target_row}{row_content}"))
}
None => {
return CallToolResult::text(format!(
"Could not find a row containing \"{after_row}\" in the table."
));
}
}
} else {
if let Some(tbody_end) = target_table.rfind("</tbody>") {
format!(
"{}{}{}",
&target_table[..tbody_end],
row_content,
&target_table[tbody_end..]
)
} else if let Some(table_end) = target_table.rfind("</table>") {
format!(
"{}{}{}",
&target_table[..table_end],
row_content,
&target_table[table_end..]
)
} else {
target_table.to_string()
}
};
let updated_content = body.replace(target_table, &updated_table);
let new_version = version + 1;
match save_page(
client,
page_id,
&title,
&updated_content,
new_version,
"Added new table row",
)
.await
{
Ok(()) => CallToolResult::text(format!(
"✅ Successfully inserted new row in table in page \"{title}\".\n\n**New Version**: {new_version}"
)),
Err(e) => CallToolResult::text(format!("Error inserting table row: {e}")),
}
}