use std::path::{Path, PathBuf};
use rmcp::model::{CallToolResult, Content, ErrorCode};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::error::VaultError;
use crate::models::{FileStat, Heading, WikiLink};
use crate::vault::Vault;
#[derive(Deserialize, JsonSchema, Default)]
pub struct NoteInspectParams {
pub path: String,
pub view: Option<String>,
}
#[derive(Serialize, JsonSchema)]
struct NoteMetadataOutput {
path: PathBuf,
title: String,
tags: Vec<String>,
frontmatter: Option<serde_json::Value>,
headings: Vec<Heading>,
outgoing_links: Vec<WikiLink>,
block_refs: Vec<String>,
backlinks_count: usize,
stat: FileStat,
}
pub async fn note_inspect(
vault: &Vault,
params: NoteInspectParams,
) -> Result<CallToolResult, rmcp::ErrorData> {
let view = params.view.as_deref().unwrap_or("metadata");
if view.eq_ignore_ascii_case("metadata") {
note_inspect_metadata(vault, ¶ms.path).await
} else if view.eq_ignore_ascii_case("targets") {
note_inspect_targets(vault, ¶ms.path).await
} else {
Err(rmcp::ErrorData::new(
ErrorCode::INVALID_PARAMS,
format!("Unknown view '{view}'. Valid values: \"metadata\", \"targets\""),
None::<serde_json::Value>,
))
}
}
async fn note_inspect_metadata(
vault: &Vault,
note_path: &str,
) -> Result<CallToolResult, rmcp::ErrorData> {
let path = Path::new(note_path);
let meta = vault.get_note_metadata(path)?;
let backlinks = vault.backlinks(path)?;
let output = NoteMetadataOutput {
path: meta.path,
title: meta.title,
tags: meta.tags,
frontmatter: meta.frontmatter,
headings: meta.headings,
outgoing_links: meta.links,
block_refs: meta.block_refs,
backlinks_count: backlinks.len(),
stat: meta.stat,
};
let value = serde_json::to_value(output)
.map_err(|e| VaultError::Other(format!("serialization error: {e}")))?;
Ok(CallToolResult::structured(value))
}
async fn note_inspect_targets(
vault: &Vault,
note_path: &str,
) -> Result<CallToolResult, rmcp::ErrorData> {
let path = Path::new(note_path);
let map = vault.get_document_map(path)?;
let value = serde_json::to_value(map)
.map_err(|e| VaultError::Other(format!("serialization error: {e}")))?;
Ok(CallToolResult::structured(value))
}
#[derive(Deserialize, JsonSchema, Default)]
pub struct FrontmatterParams {
pub action: String,
pub path: String,
pub key: Option<String>,
pub value: Option<serde_json::Value>,
}
pub async fn frontmatter(
vault: &Vault,
params: FrontmatterParams,
) -> Result<CallToolResult, rmcp::ErrorData> {
let path = Path::new(¶ms.path);
if params.action.eq_ignore_ascii_case("get") {
let fm = vault.get_frontmatter(path)?;
match fm {
Some(value) => Ok(CallToolResult::structured(value)),
None => Ok(CallToolResult::success(vec![Content::text("null")])),
}
} else if params.action.eq_ignore_ascii_case("set") {
let key = params.key.as_deref().ok_or_else(|| {
rmcp::ErrorData::new(
ErrorCode::INVALID_PARAMS,
"'key' is required for action 'set'",
None::<serde_json::Value>,
)
})?;
let value = params.value.ok_or_else(|| {
rmcp::ErrorData::new(
ErrorCode::INVALID_PARAMS,
"'value' is required for action 'set'",
None::<serde_json::Value>,
)
})?;
vault.set_frontmatter_field(path, key, value)?;
Ok(CallToolResult::success(vec![Content::text(format!(
"Set frontmatter field '{key}' on '{}'",
params.path
))]))
} else if params.action.eq_ignore_ascii_case("remove") {
let key = params.key.as_deref().ok_or_else(|| {
rmcp::ErrorData::new(
ErrorCode::INVALID_PARAMS,
"'key' is required for action 'remove'",
None::<serde_json::Value>,
)
})?;
vault.remove_frontmatter_field(path, key)?;
Ok(CallToolResult::success(vec![Content::text(format!(
"Removed frontmatter field '{key}' from '{}'",
params.path
))]))
} else {
Err(rmcp::ErrorData::new(
ErrorCode::INVALID_PARAMS,
format!(
"Unknown action '{}'. Valid values: \"get\", \"set\", \"remove\"",
params.action
),
None::<serde_json::Value>,
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers::{create_test_vault, test_config};
#[tokio::test]
async fn note_inspect_metadata_returns_all_fields() {
let dir = tempfile::tempdir().unwrap();
create_test_vault(dir.path());
let vault = Vault::open(&test_config(dir.path())).await.unwrap();
vault
.write_note(
Path::new("test.md"),
"---\ntags: [rust]\nstatus: draft\n---\n# Heading\n## Sub\n[[other]] #inline\n^block1\n",
)
.unwrap();
vault
.write_note(Path::new("other.md"), "# Other\n[[test]]\n")
.unwrap();
let result = note_inspect(
&vault,
NoteInspectParams {
path: "test.md".into(),
view: None,
},
)
.await
.unwrap();
let v = result.structured_content.unwrap();
assert_eq!(v["title"], "test");
assert!(
v["tags"]
.as_array()
.unwrap()
.contains(&serde_json::json!("rust"))
);
assert!(v["frontmatter"].is_object());
assert!(!v["headings"].as_array().unwrap().is_empty());
assert!(!v["outgoing_links"].as_array().unwrap().is_empty());
assert!(
v["block_refs"]
.as_array()
.unwrap()
.contains(&serde_json::json!("block1"))
);
assert_eq!(v["backlinks_count"], 1);
assert!(v["stat"].is_object());
}
#[tokio::test]
async fn note_inspect_not_found() {
let dir = tempfile::tempdir().unwrap();
create_test_vault(dir.path());
let vault = Vault::open(&test_config(dir.path())).await.unwrap();
let result = note_inspect(
&vault,
NoteInspectParams {
path: "nonexistent.md".into(),
view: None,
},
)
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn note_inspect_targets_lists_targets() {
let dir = tempfile::tempdir().unwrap();
create_test_vault(dir.path());
let vault = Vault::open(&test_config(dir.path())).await.unwrap();
vault
.write_note(
Path::new("mapped.md"),
"---\ntags: [rust]\ndate: 2026-01-01\n---\n# Heading\n## Sub\nText ^block1\n",
)
.unwrap();
let result = note_inspect(
&vault,
NoteInspectParams {
path: "mapped.md".into(),
view: Some("targets".into()),
},
)
.await
.unwrap();
let v = result.structured_content.unwrap();
let headings = v["headings"].as_array().unwrap();
assert!(
headings
.iter()
.any(|h| h.as_str().unwrap().contains("Heading"))
);
assert!(headings.iter().any(|h| h.as_str().unwrap().contains("Sub")));
assert!(
v["block_refs"]
.as_array()
.unwrap()
.contains(&serde_json::json!("block1"))
);
assert!(
v["frontmatter_fields"]
.as_array()
.unwrap()
.contains(&serde_json::json!("tags"))
);
assert!(
v["frontmatter_fields"]
.as_array()
.unwrap()
.contains(&serde_json::json!("date"))
);
}
#[tokio::test]
async fn note_inspect_invalid_view() {
let dir = tempfile::tempdir().unwrap();
create_test_vault(dir.path());
let vault = Vault::open(&test_config(dir.path())).await.unwrap();
vault.write_note(Path::new("test.md"), "# Note\n").unwrap();
let result = note_inspect(
&vault,
NoteInspectParams {
path: "test.md".into(),
view: Some("invalid".into()),
},
)
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn frontmatter_roundtrip() {
let dir = tempfile::tempdir().unwrap();
create_test_vault(dir.path());
let vault = Vault::open(&test_config(dir.path())).await.unwrap();
vault
.write_note(Path::new("fm.md"), "# Note\nBody\n")
.unwrap();
let result = frontmatter(
&vault,
FrontmatterParams {
action: "get".into(),
path: "fm.md".into(),
..Default::default()
},
)
.await
.unwrap();
assert!(result.structured_content.is_none());
let text = result.content[0].as_text().expect("expected text content");
assert_eq!(text.text, "null");
frontmatter(
&vault,
FrontmatterParams {
action: "set".into(),
path: "fm.md".into(),
key: Some("status".into()),
value: Some(serde_json::json!("draft")),
},
)
.await
.unwrap();
let result = frontmatter(
&vault,
FrontmatterParams {
action: "get".into(),
path: "fm.md".into(),
..Default::default()
},
)
.await
.unwrap();
let fm = result.structured_content.unwrap();
assert_eq!(fm["status"], "draft");
frontmatter(
&vault,
FrontmatterParams {
action: "set".into(),
path: "fm.md".into(),
key: Some("tags".into()),
value: Some(serde_json::json!(["rust", "mcp"])),
},
)
.await
.unwrap();
let result = frontmatter(
&vault,
FrontmatterParams {
action: "GET".into(),
path: "fm.md".into(),
..Default::default()
},
)
.await
.unwrap();
let fm = result.structured_content.unwrap();
assert_eq!(fm["status"], "draft");
assert_eq!(fm["tags"], serde_json::json!(["rust", "mcp"]));
frontmatter(
&vault,
FrontmatterParams {
action: "remove".into(),
path: "fm.md".into(),
key: Some("status".into()),
value: None,
},
)
.await
.unwrap();
let result = frontmatter(
&vault,
FrontmatterParams {
action: "get".into(),
path: "fm.md".into(),
..Default::default()
},
)
.await
.unwrap();
let fm = result.structured_content.unwrap();
assert!(fm.get("status").is_none());
assert_eq!(fm["tags"], serde_json::json!(["rust", "mcp"]));
}
#[tokio::test]
async fn frontmatter_invalid_action() {
let dir = tempfile::tempdir().unwrap();
create_test_vault(dir.path());
let vault = Vault::open(&test_config(dir.path())).await.unwrap();
vault.write_note(Path::new("fm.md"), "# Note\n").unwrap();
let result = frontmatter(
&vault,
FrontmatterParams {
action: "invalid".into(),
path: "fm.md".into(),
..Default::default()
},
)
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn frontmatter_set_missing_key() {
let dir = tempfile::tempdir().unwrap();
create_test_vault(dir.path());
let vault = Vault::open(&test_config(dir.path())).await.unwrap();
vault.write_note(Path::new("fm.md"), "# Note\n").unwrap();
let result = frontmatter(
&vault,
FrontmatterParams {
action: "set".into(),
path: "fm.md".into(),
key: None,
value: Some(serde_json::json!("val")),
},
)
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn frontmatter_set_missing_value() {
let dir = tempfile::tempdir().unwrap();
create_test_vault(dir.path());
let vault = Vault::open(&test_config(dir.path())).await.unwrap();
vault.write_note(Path::new("fm.md"), "# Note\n").unwrap();
let result = frontmatter(
&vault,
FrontmatterParams {
action: "set".into(),
path: "fm.md".into(),
key: Some("k".into()),
value: None,
},
)
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn frontmatter_remove_missing_key() {
let dir = tempfile::tempdir().unwrap();
create_test_vault(dir.path());
let vault = Vault::open(&test_config(dir.path())).await.unwrap();
vault.write_note(Path::new("fm.md"), "# Note\n").unwrap();
let result = frontmatter(
&vault,
FrontmatterParams {
action: "remove".into(),
path: "fm.md".into(),
key: None,
value: None,
},
)
.await;
assert!(result.is_err());
}
}