use chrono::NaiveDate;
use rmcp::model::ErrorCode;
use schemars::JsonSchema;
use serde::Deserialize;
use crate::models::NotePeriod;
use crate::vault::Vault;
fn parse_date(date_str: &str) -> Result<NaiveDate, rmcp::ErrorData> {
NaiveDate::parse_from_str(date_str, "%Y-%m-%d").map_err(|_| {
rmcp::ErrorData::new(
ErrorCode::INVALID_PARAMS,
format!("Invalid date '{date_str}'; expected YYYY-MM-DD"),
None::<serde_json::Value>,
)
})
}
#[derive(Deserialize, JsonSchema, Default)]
pub struct PeriodicParams {
pub action: String,
pub period: NotePeriod,
#[serde(default)]
pub date: Option<String>,
#[serde(default)]
pub content: Option<String>,
#[serde(default)]
pub limit: Option<usize>,
}
pub async fn periodic(vault: &Vault, params: PeriodicParams) -> Result<String, rmcp::ErrorData> {
match params.action.to_ascii_lowercase().as_str() {
"get" => {
let date = params.date.map(|s| parse_date(&s)).transpose()?;
Ok(vault.get_periodic_note(¶ms.period, date)?)
}
"create" => {
let date = params.date.map(|s| parse_date(&s)).transpose()?;
let path =
vault.create_periodic_note(¶ms.period, date, params.content.as_deref())?;
Ok(format!("Created: {}", path.display()))
}
"list" => {
let limit = params.limit.unwrap_or(10);
let paths = vault.list_recent_periodic_notes(¶ms.period, limit)?;
let items: Vec<serde_json::Value> = paths
.into_iter()
.map(|p| {
let date = p
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or_default()
.to_string();
serde_json::json!({ "path": p.to_string_lossy(), "date": date })
})
.collect();
serde_json::to_string_pretty(&items).map_err(|e| {
rmcp::ErrorData::new(
ErrorCode::INTERNAL_ERROR,
e.to_string(),
None::<serde_json::Value>,
)
})
}
other => Err(rmcp::ErrorData::new(
ErrorCode::INVALID_PARAMS,
format!("Unknown action '{other}'. Valid values: \"get\", \"create\", \"list\""),
None::<serde_json::Value>,
)),
}
}
#[cfg(test)]
mod tests {
use std::fs;
use super::*;
use crate::test_helpers::{create_test_vault, test_config};
fn setup_daily_config(dir: &std::path::Path) {
create_test_vault(dir);
let daily_dir = dir.join("Daily");
fs::create_dir_all(&daily_dir).unwrap();
fs::write(
dir.join(".obsidian/daily-notes.json"),
r#"{"format":"YYYY-MM-DD","folder":"Daily"}"#,
)
.unwrap();
}
#[tokio::test]
async fn unknown_action_returns_error() {
let dir = tempfile::tempdir().unwrap();
setup_daily_config(dir.path());
let vault = Vault::open(&test_config(dir.path())).await.unwrap();
let result = periodic(
&vault,
PeriodicParams {
action: "destroy".into(),
..Default::default()
},
)
.await;
let err = result.unwrap_err();
assert!(err.message.contains("Unknown action"));
assert!(err.message.contains("destroy"));
}
#[tokio::test]
async fn action_is_case_insensitive() {
let dir = tempfile::tempdir().unwrap();
setup_daily_config(dir.path());
let vault = Vault::open(&test_config(dir.path())).await.unwrap();
let result = periodic(
&vault,
PeriodicParams {
action: "LIST".into(),
..Default::default()
},
)
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn list_returns_empty_array() {
let dir = tempfile::tempdir().unwrap();
setup_daily_config(dir.path());
let vault = Vault::open(&test_config(dir.path())).await.unwrap();
let result = periodic(
&vault,
PeriodicParams {
action: "list".into(),
limit: Some(5),
..Default::default()
},
)
.await
.unwrap();
let items: Vec<serde_json::Value> = serde_json::from_str(&result).unwrap();
assert!(items.is_empty());
}
#[tokio::test]
async fn create_then_get() {
let dir = tempfile::tempdir().unwrap();
setup_daily_config(dir.path());
let vault = Vault::open(&test_config(dir.path())).await.unwrap();
let msg = periodic(
&vault,
PeriodicParams {
action: "create".into(),
date: Some("2026-01-15".into()),
content: Some("hello periodic".into()),
..Default::default()
},
)
.await
.unwrap();
assert!(msg.contains("Created"));
let content = periodic(
&vault,
PeriodicParams {
action: "get".into(),
date: Some("2026-01-15".into()),
..Default::default()
},
)
.await
.unwrap();
assert!(content.contains("hello periodic"));
}
}