use std::path::Path;
use rmcp::ErrorData;
use rmcp::model::ErrorCode;
use schemars::JsonSchema;
use serde::Deserialize;
use crate::models::{PatchOperation, PatchRequest, PatchTargetType};
use crate::vault::Vault;
#[derive(Deserialize, JsonSchema, Default)]
pub struct NoteReadParams {
pub path: String,
}
#[derive(Deserialize, JsonSchema, Default)]
pub struct NoteCreateParams {
pub path: String,
#[serde(default)]
pub content: Option<String>,
#[serde(default)]
pub frontmatter: Option<serde_json::Value>,
}
#[derive(Deserialize, JsonSchema, Default)]
pub struct NoteWriteParams {
pub path: String,
pub content: String,
}
#[derive(Deserialize, JsonSchema, Default)]
pub struct NoteAppendParams {
pub path: String,
pub content: String,
}
#[derive(Deserialize, JsonSchema, Default)]
pub struct NotePrependParams {
pub path: String,
pub content: String,
}
#[derive(Deserialize, JsonSchema)]
pub struct NotePatchParams {
pub path: String,
pub operation: PatchOperation,
pub target_type: PatchTargetType,
pub target: String,
pub content: String,
}
impl Default for NotePatchParams {
fn default() -> Self {
Self {
path: String::new(),
operation: PatchOperation::Append,
target_type: PatchTargetType::Heading,
target: String::new(),
content: String::new(),
}
}
}
#[derive(Deserialize, JsonSchema, Default)]
pub struct NoteDeleteParams {
pub path: String,
pub confirm: bool,
}
#[derive(Deserialize, JsonSchema, Default)]
pub struct NoteMoveParams {
pub from: String,
pub to: String,
}
pub async fn note_read(vault: &Vault, params: NoteReadParams) -> Result<String, ErrorData> {
Ok(vault.read_note(Path::new(¶ms.path))?)
}
pub async fn note_create(vault: &Vault, params: NoteCreateParams) -> Result<String, ErrorData> {
vault.create_note(
Path::new(¶ms.path),
params.content.as_deref().unwrap_or(""),
params.frontmatter.as_ref(),
)?;
Ok(format!("Created note: {}", params.path))
}
pub async fn note_write(vault: &Vault, params: NoteWriteParams) -> Result<String, ErrorData> {
vault.write_note(Path::new(¶ms.path), ¶ms.content)?;
Ok(format!("Written to: {}", params.path))
}
pub async fn note_append(vault: &Vault, params: NoteAppendParams) -> Result<String, ErrorData> {
vault.append_note(Path::new(¶ms.path), ¶ms.content)?;
Ok(format!("Appended to: {}", params.path))
}
pub async fn note_prepend(vault: &Vault, params: NotePrependParams) -> Result<String, ErrorData> {
vault.prepend_note(Path::new(¶ms.path), ¶ms.content)?;
Ok(format!("Prepended to: {}", params.path))
}
pub async fn note_patch(vault: &Vault, params: NotePatchParams) -> Result<String, ErrorData> {
let request = PatchRequest {
operation: params.operation,
target_type: params.target_type,
target: params.target,
content: params.content,
};
vault.patch_note(Path::new(¶ms.path), &request)?;
Ok(format!("Patched: {}", params.path))
}
pub async fn note_delete(vault: &Vault, params: NoteDeleteParams) -> Result<String, ErrorData> {
if !params.confirm {
return Err(ErrorData::new(
ErrorCode::INVALID_PARAMS,
"Deletion requires `confirm: true` as a safety check",
None::<serde_json::Value>,
));
}
vault.delete_note(Path::new(¶ms.path))?;
Ok(format!("Deleted: {}", params.path))
}
pub async fn note_move(vault: &Vault, params: NoteMoveParams) -> Result<String, ErrorData> {
let new_path = vault.move_note(Path::new(¶ms.from), Path::new(¶ms.to))?;
Ok(format!("Moved to: {}", new_path.display()))
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::*;
use crate::config::Config;
use crate::vault::Vault;
fn test_config(vault_root: &Path) -> Config {
Config {
vault_path: vault_root.to_path_buf(),
watch: false,
log_level: "error".into(),
tantivy: false,
embeddings: false,
embeddings_model: String::new(),
hybrid_alpha: 0.25,
}
}
fn create_test_vault(dir: &Path) {
std::fs::create_dir_all(dir.join(".obsidian")).unwrap();
}
#[tokio::test]
async fn read_existing_note() {
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("hello.md"), "# Hello\nWorld")
.unwrap();
let content = note_read(
&vault,
NoteReadParams {
path: "hello.md".into(),
},
)
.await
.unwrap();
assert_eq!(content, "# Hello\nWorld");
}
#[tokio::test]
async fn read_nonexistent_note_errors() {
let dir = tempfile::tempdir().unwrap();
create_test_vault(dir.path());
let vault = Vault::open(&test_config(dir.path())).await.unwrap();
let result = note_read(
&vault,
NoteReadParams {
path: "missing.md".into(),
},
)
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn create_new_note() {
let dir = tempfile::tempdir().unwrap();
create_test_vault(dir.path());
let vault = Vault::open(&test_config(dir.path())).await.unwrap();
let msg = note_create(
&vault,
NoteCreateParams {
path: "new.md".into(),
content: Some("body".into()),
frontmatter: Some(serde_json::json!({"status": "draft"})),
},
)
.await
.unwrap();
assert!(msg.contains("new.md"));
let content = vault.read_note(Path::new("new.md")).unwrap();
assert!(content.contains("body"));
assert!(content.contains("status"));
}
#[tokio::test]
async fn create_duplicate_note_errors() {
let dir = tempfile::tempdir().unwrap();
create_test_vault(dir.path());
let vault = Vault::open(&test_config(dir.path())).await.unwrap();
note_create(
&vault,
NoteCreateParams {
path: "dup.md".into(),
..Default::default()
},
)
.await
.unwrap();
let result = note_create(
&vault,
NoteCreateParams {
path: "dup.md".into(),
..Default::default()
},
)
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn write_overwrites_content() {
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("note.md"), "old content")
.unwrap();
note_write(
&vault,
NoteWriteParams {
path: "note.md".into(),
content: "new content".into(),
},
)
.await
.unwrap();
let content = vault.read_note(Path::new("note.md")).unwrap();
assert_eq!(content, "new content");
}
#[tokio::test]
async fn append_adds_to_end() {
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("note.md"), "start").unwrap();
note_append(
&vault,
NoteAppendParams {
path: "note.md".into(),
content: "\nmore".into(),
},
)
.await
.unwrap();
let content = vault.read_note(Path::new("note.md")).unwrap();
assert!(content.ends_with("more"));
assert!(content.starts_with("start"));
}
#[tokio::test]
async fn prepend_inserts_after_frontmatter() {
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("note.md"), "---\ntags: [a]\n---\n# Heading\n")
.unwrap();
note_prepend(
&vault,
NotePrependParams {
path: "note.md".into(),
content: "injected\n".into(),
},
)
.await
.unwrap();
let content = vault.read_note(Path::new("note.md")).unwrap();
assert!(content.starts_with("---\ntags:"));
assert!(content.contains("injected"));
assert!(content.contains("# Heading"));
}
#[tokio::test]
async fn patch_heading_append() {
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("patched.md"), "# Title\nBody\n## Sub\nSub body\n")
.unwrap();
note_patch(
&vault,
NotePatchParams {
path: "patched.md".into(),
operation: PatchOperation::Append,
target_type: PatchTargetType::Heading,
target: "Sub".into(),
content: "appended\n".into(),
},
)
.await
.unwrap();
let content = vault.read_note(Path::new("patched.md")).unwrap();
assert!(content.contains("Sub body"));
assert!(content.contains("appended"));
}
#[tokio::test]
async fn delete_requires_confirm() {
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("note.md"), "content").unwrap();
let result = note_delete(
&vault,
NoteDeleteParams {
path: "note.md".into(),
confirm: false,
},
)
.await;
assert!(result.is_err());
assert!(vault.read_note(Path::new("note.md")).is_ok());
}
#[tokio::test]
async fn delete_with_confirm_succeeds() {
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("note.md"), "content").unwrap();
note_delete(
&vault,
NoteDeleteParams {
path: "note.md".into(),
confirm: true,
},
)
.await
.unwrap();
assert!(vault.read_note(Path::new("note.md")).is_err());
}
#[tokio::test]
async fn move_renames_note() {
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("old.md"), "content").unwrap();
let msg = note_move(
&vault,
NoteMoveParams {
from: "old.md".into(),
to: "new.md".into(),
},
)
.await
.unwrap();
assert!(msg.contains("new.md"));
assert!(vault.read_note(Path::new("old.md")).is_err());
assert_eq!(vault.read_note(Path::new("new.md")).unwrap(), "content");
}
}