1use rmcp::{
6 ErrorData as McpError,
7 handler::server::wrapper::Parameters,
8 model::{PromptMessage, PromptMessageRole},
9 schemars,
10 prompt, prompt_router,
11};
12use serde::Deserialize;
13
14use super::KimunHandler;
15
16#[derive(Debug, Deserialize, schemars::JsonSchema)]
21pub struct DailyReviewParams {
22 pub date: Option<String>,
24}
25
26#[derive(Debug, Deserialize, schemars::JsonSchema)]
27pub struct FindConnectionsParams {
28 pub path: String,
30}
31
32#[derive(Debug, Deserialize, schemars::JsonSchema)]
33pub struct ResearchNoteParams {
34 pub path: String,
36 pub max_results: Option<u32>,
38}
39
40#[derive(Debug, Deserialize, schemars::JsonSchema)]
41pub struct BrainstormParams {
42 pub topic: String,
44 pub max_results: Option<u32>,
46}
47
48#[derive(Debug, Deserialize, schemars::JsonSchema)]
49pub struct WeeklyReviewParams {
50 pub date: Option<String>,
52}
53
54#[derive(Debug, Deserialize, schemars::JsonSchema)]
55pub struct LinkSuggestionsParams {
56 pub path: String,
58 pub max_results: Option<u32>,
60}
61
62#[derive(Debug, Deserialize, schemars::JsonSchema)]
63pub struct ResearchTopicParams {
64 pub topic: String,
66 pub max_results: Option<u32>,
68}
69
70impl KimunHandler {
75 async fn extract_leaf_headings(
79 &self,
80 path: &kimun_core::nfs::VaultPath,
81 ) -> Result<Vec<String>, McpError> {
82 use std::collections::HashSet;
83
84 let chunks_map = self
85 .vault
86 .get_note_chunks(path)
87 .await
88 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
89
90 let mut seen: HashSet<String> = HashSet::new();
91 let mut topics: Vec<String> = Vec::new();
92 for chunks in chunks_map.values() {
93 for chunk in chunks {
94 if let Some(leaf) = chunk.breadcrumb.last() {
95 let t = leaf.trim().to_string();
96 if !t.is_empty() && seen.insert(t.clone()) {
97 topics.push(t);
98 }
99 }
100 }
101 }
102 Ok(topics)
103 }
104}
105
106#[prompt_router(vis = "pub")]
111impl KimunHandler {
112 #[prompt(description = "Load today's journal entry and ask the LLM to review the day: summarise accomplishments, identify action items, and note recurring themes.")]
113 async fn daily_review(
114 &self,
115 Parameters(p): Parameters<DailyReviewParams>,
116 ) -> Result<Vec<PromptMessage>, McpError> {
117 use kimun_core::error::{FSError, VaultError};
118
119 let date_str = match p.date.as_deref() {
120 None => chrono::Utc::now().format("%Y-%m-%d").to_string(),
121 Some(d) => {
122 if chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d").is_err() {
123 return Err(McpError::invalid_params(
124 format!("Invalid date '{}' — expected YYYY-MM-DD.", d),
125 None,
126 ));
127 }
128 d.to_string()
129 }
130 };
131
132 let journal_path = self
133 .vault
134 .journal_path()
135 .append(&kimun_core::nfs::VaultPath::note_path_from(&date_str))
136 .absolute();
137
138 let journal_text = match self.vault.get_note_text(&journal_path).await {
139 Ok(t) => t,
140 Err(VaultError::FSError(FSError::VaultPathNotFound { .. })) => {
141 return Ok(vec![PromptMessage::new_text(
142 PromptMessageRole::User,
143 format!("No journal entry found for {}.", date_str),
144 )]);
145 }
146 Err(e) => return Err(McpError::internal_error(e.to_string(), None)),
147 };
148
149 let message = format!(
150 "Here is my journal entry for {date_str}:\n\n---\n{journal_text}\n---\n\n\
151 Please review this journal entry:\n\
152 1. Summarize what was accomplished\n\
153 2. Identify any action items or follow-ups\n\
154 3. Note any open questions or concerns that need follow-up"
155 );
156
157 Ok(vec![PromptMessage::new_text(PromptMessageRole::User, message)])
158 }
159
160 #[prompt(description = "Load a note and its backlink list, then ask the LLM to identify non-obvious conceptual connections to the rest of the vault.")]
161 async fn find_connections(
162 &self,
163 Parameters(p): Parameters<FindConnectionsParams>,
164 ) -> Result<Vec<PromptMessage>, McpError> {
165 use kimun_core::error::{FSError, VaultError};
166 use kimun_core::nfs::VaultPath;
167
168 let vault_path = VaultPath::note_path_from(&p.path);
169
170 let note_text = match self.vault.get_note_text(&vault_path).await {
171 Ok(t) => t,
172 Err(VaultError::FSError(FSError::VaultPathNotFound { .. })) => {
173 return Ok(vec![PromptMessage::new_text(
174 PromptMessageRole::User,
175 format!("Note not found: {}", vault_path),
176 )]);
177 }
178 Err(e) => return Err(McpError::internal_error(e.to_string(), None)),
179 };
180
181 let backlinks = self
182 .vault
183 .get_backlinks(&vault_path)
184 .await
185 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
186
187 let backlinks_section = if backlinks.is_empty() {
188 String::new()
189 } else {
190 let paths: Vec<String> = backlinks
191 .iter()
192 .map(|(entry, _)| format!("- {}", entry.path))
193 .collect();
194 format!(
195 "\nNotes that link to this note:\n{}\n",
196 paths.join("\n")
197 )
198 };
199
200 let message = format!(
201 "Here is the note at \"{path}\":\n\n---\n{note_text}\n---\n{backlinks_section}\n\
202 Identify non-obvious conceptual connections between this note and the rest of the vault. \
203 What themes link them? What ideas are worth exploring further?\n\
204 (You can use the available vault tools to read any linked note in full.)",
205 path = vault_path,
206 );
207
208 Ok(vec![PromptMessage::new_text(PromptMessageRole::User, message)])
209 }
210
211 #[prompt(description = "Search the vault using a note's section headings as queries, then ask the LLM to synthesise what is captured and identify gaps.")]
212 async fn research_note(
213 &self,
214 Parameters(p): Parameters<ResearchNoteParams>,
215 ) -> Result<Vec<PromptMessage>, McpError> {
216 use kimun_core::error::{FSError, VaultError};
217 use kimun_core::nfs::VaultPath;
218 use std::collections::HashSet;
219
220 let vault_path = VaultPath::note_path_from(&p.path);
221 let max = p.max_results.unwrap_or(5) as usize;
222
223 let note_text = match self.vault.get_note_text(&vault_path).await {
224 Ok(t) => t,
225 Err(VaultError::FSError(FSError::VaultPathNotFound { .. })) => {
226 return Ok(vec![PromptMessage::new_text(
227 PromptMessageRole::User,
228 format!("Note not found: {}", vault_path),
229 )]);
230 }
231 Err(e) => return Err(McpError::internal_error(e.to_string(), None)),
232 };
233
234 let topics = self.extract_leaf_headings(&vault_path).await?;
235
236 let mut seen: HashSet<String> = HashSet::new();
238 seen.insert(vault_path.to_string());
239
240 let mut related_sections: Vec<String> = Vec::new();
241
242 'outer: for topic in &topics {
243 let results = self
244 .vault
245 .search_notes(topic)
246 .await
247 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
248 for (entry, _) in results {
249 let path_str = entry.path.to_string();
250 if seen.contains(&path_str) {
251 continue;
252 }
253 seen.insert(path_str.clone());
254 let text = self
255 .vault
256 .get_note_text(&entry.path)
257 .await
258 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
259 related_sections.push(format!("=== {} ===\n{}", entry.path, text));
260 if related_sections.len() >= max {
261 break 'outer;
262 }
263 }
264 }
265
266 let topics_list = if topics.is_empty() {
267 "(no sections found)".to_string()
268 } else {
269 topics.join(", ")
270 };
271
272 let related_block = if related_sections.is_empty() {
273 "No related notes found in the vault.".to_string()
274 } else {
275 related_sections.join("\n\n")
276 };
277
278 let message = format!(
279 "Here is the note at \"{path}\":\n\n---\n{note_text}\n---\n\n\
280 Related notes found by searching section topics ({topics_list}):\n\n\
281 {related_block}\n\n\
282 For each of the section topics ({topics_list}), synthesize what the vault captures \
283 and identify what is missing or unexplored. What key ideas are captured? \
284 What gaps exist? What questions remain unanswered?",
285 path = vault_path,
286 );
287
288 Ok(vec![PromptMessage::new_text(PromptMessageRole::User, message)])
289 }
290
291 #[prompt(description = "Search the vault for a topic and ask the LLM to generate new ideas that build on existing notes, with a suggested note to append them to.")]
292 async fn brainstorm(
293 &self,
294 Parameters(p): Parameters<BrainstormParams>,
295 ) -> Result<Vec<PromptMessage>, McpError> {
296 let results = self
297 .vault
298 .search_notes(&p.topic)
299 .await
300 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
301
302 let max = p.max_results.unwrap_or(5) as usize;
303 let top: Vec<_> = results.into_iter().take(max).collect();
304 let suggested_path = top.first().map(|(entry, _)| entry.path.to_string());
305
306 let mut vault_sections: Vec<String> = Vec::new();
307 for (entry, _) in &top {
308 let text = self
309 .vault
310 .get_note_text(&entry.path)
311 .await
312 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
313 vault_sections.push(format!("=== {} ===\n{}", entry.path, text));
314 }
315
316 let vault_block = if vault_sections.is_empty() {
317 String::new()
318 } else {
319 format!(
320 "Here is relevant content from my vault:\n\n{}\n\n",
321 vault_sections.join("\n\n")
322 )
323 };
324
325 let suggestion_line = match &suggested_path {
326 Some(path) => format!("3. Suggested note to append new ideas to: {}\n", path),
327 None => String::new(),
328 };
329
330 let message = format!(
331 "I want to brainstorm ideas about: \"{topic}\"\n\n\
332 {vault_block}\
333 Based on my existing notes:\n\
334 1. Generate 5–10 new ideas related to \"{topic}\" that build on what's already captured\n\
335 2. For each new idea, identify which existing note it connects to and suggest where it could be appended or linked\n\
336 {suggestion_line}",
337 topic = p.topic,
338 );
339
340 Ok(vec![PromptMessage::new_text(PromptMessageRole::User, message)])
341 }
342
343 #[prompt(description = "Load a full week of journal entries and ask the LLM to synthesise themes, accomplishments, and carry-overs.")]
344 async fn weekly_review(
345 &self,
346 Parameters(p): Parameters<WeeklyReviewParams>,
347 ) -> Result<Vec<PromptMessage>, McpError> {
348 use chrono::{Datelike, Duration, NaiveDate, Utc};
349 use kimun_core::error::{FSError, VaultError};
350 use kimun_core::nfs::VaultPath;
351
352 let anchor: NaiveDate = match p.date.as_deref() {
354 None => Utc::now().date_naive(),
355 Some(d) => match NaiveDate::parse_from_str(d, "%Y-%m-%d") {
356 Ok(date) => date,
357 Err(_) => {
358 return Err(McpError::invalid_params(
359 format!("Invalid date '{}' — expected YYYY-MM-DD.", d),
360 None,
361 ));
362 }
363 },
364 };
365
366 let days_from_monday = anchor.weekday().num_days_from_monday();
368 let monday = anchor - Duration::days(days_from_monday as i64);
369 let sunday = monday + Duration::days(6);
370
371 let day_names = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
373
374 let mut days_text = String::new();
375 for i in 0..7 {
376 let day = monday + Duration::days(i);
377 let date_str = day.format("%Y-%m-%d").to_string();
378 let journal_path = self
379 .vault
380 .journal_path()
381 .append(&VaultPath::note_path_from(&date_str))
382 .absolute();
383
384 let content = match self.vault.get_note_text(&journal_path).await {
385 Ok(text) => text,
386 Err(VaultError::FSError(FSError::VaultPathNotFound { .. })) => "(no entry)".to_string(),
387 Err(e) => return Err(McpError::internal_error(e.to_string(), None)),
388 };
389
390 days_text.push_str(&format!(
391 "{} {}:\n---\n{}\n---\n\n",
392 day_names[i as usize], date_str, content
393 ));
394 }
395
396 let message = format!(
397 "Week of {} – {}\n\n{}\
398 Please review this week:\n\
399 1. What were the main themes and accomplishments?\n\
400 2. What carried over unfinished from day to day?\n\
401 3. What patterns are worth paying attention to?\n\
402 4. What should be prioritised next week?",
403 monday.format("%Y-%m-%d"),
404 sunday.format("%Y-%m-%d"),
405 days_text
406 );
407
408 Ok(vec![PromptMessage::new_text(PromptMessageRole::User, message)])
409 }
410
411 #[prompt(description = "Search the vault for a topic, expand the search via backlinks and related headings from the results, then ask the LLM for a comprehensive overview of the topic and everything connected to it.")]
412 async fn research_topic(
413 &self,
414 Parameters(p): Parameters<ResearchTopicParams>,
415 ) -> Result<Vec<PromptMessage>, McpError> {
416 use kimun_core::nfs::VaultPath;
417 use std::collections::HashSet;
418
419 let max = p.max_results.unwrap_or(10) as usize;
420 let mut seen: HashSet<String> = HashSet::new();
421
422 let initial_results = self
424 .vault
425 .search_notes(&p.topic)
426 .await
427 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
428
429 let mut direct_notes: Vec<(VaultPath, String)> = Vec::new();
430 let mut backlink_candidates: Vec<VaultPath> = Vec::new();
431 let mut secondary_topics: Vec<String> = Vec::new();
433 let mut secondary_topics_lower: std::collections::HashSet<String> =
434 std::collections::HashSet::new();
435
436 for (entry, _) in initial_results {
437 if direct_notes.len() >= max {
438 break;
439 }
440 let path_str = entry.path.to_string();
441 if seen.contains(&path_str) {
442 continue;
443 }
444 seen.insert(path_str);
445
446 let text = self
447 .vault
448 .get_note_text(&entry.path)
449 .await
450 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
451 direct_notes.push((entry.path.clone(), text));
452
453 let backlinks = self
455 .vault
456 .get_backlinks(&entry.path)
457 .await
458 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
459 for (bl_entry, _) in backlinks {
460 let bl_str = bl_entry.path.to_string();
461 if !seen.contains(&bl_str) {
462 seen.insert(bl_str);
463 backlink_candidates.push(bl_entry.path);
464 }
465 }
466
467 let headings = self.extract_leaf_headings(&entry.path).await?;
469 for t in headings {
470 let t_lower = t.to_lowercase();
471 if t_lower != p.topic.to_lowercase()
472 && secondary_topics_lower.insert(t_lower)
473 {
474 secondary_topics.push(t);
475 }
476 }
477 }
478
479 let mut backlink_notes: Vec<(VaultPath, String)> = Vec::new();
481 for path in &backlink_candidates {
482 if direct_notes.len() + backlink_notes.len() >= max {
483 break;
484 }
485 let text = self
486 .vault
487 .get_note_text(path)
488 .await
489 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
490 backlink_notes.push((path.clone(), text));
491 }
492
493 let mut related_notes: Vec<(VaultPath, String)> = Vec::new();
495 let mut contributing_topics: Vec<String> = Vec::new();
496 'outer: for topic in &secondary_topics {
497 let results = self
498 .vault
499 .search_notes(topic)
500 .await
501 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
502 let before = related_notes.len();
503 for (entry, _) in results {
504 if direct_notes.len() + backlink_notes.len() + related_notes.len() >= max {
505 break 'outer;
506 }
507 let path_str = entry.path.to_string();
508 if seen.contains(&path_str) {
509 continue;
510 }
511 seen.insert(path_str);
512 let text = self
513 .vault
514 .get_note_text(&entry.path)
515 .await
516 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
517 related_notes.push((entry.path, text));
518 }
519 if related_notes.len() > before {
520 contributing_topics.push(topic.clone());
521 }
522 }
523
524 if direct_notes.is_empty() && backlink_notes.is_empty() && related_notes.is_empty() {
525 return Ok(vec![PromptMessage::new_text(
526 PromptMessageRole::User,
527 format!("No notes found in the vault related to \"{}\".", p.topic),
528 )]);
529 }
530
531 let mut blocks: Vec<String> = Vec::new();
532
533 if !direct_notes.is_empty() {
534 let section = direct_notes
535 .iter()
536 .map(|(path, text)| format!("=== {} ===\n{}", path, text))
537 .collect::<Vec<_>>()
538 .join("\n\n");
539 blocks.push(format!("### Notes matching \"{}\":\n\n{}", p.topic, section));
540 }
541
542 if !backlink_notes.is_empty() {
543 let section = backlink_notes
544 .iter()
545 .map(|(path, text)| format!("=== {} ===\n{}", path, text))
546 .collect::<Vec<_>>()
547 .join("\n\n");
548 blocks.push(format!("### Notes linking to the above:\n\n{}", section));
549 }
550
551 if !related_notes.is_empty() {
552 let section = related_notes
553 .iter()
554 .map(|(path, text)| format!("=== {} ===\n{}", path, text))
555 .collect::<Vec<_>>()
556 .join("\n\n");
557 let header = if contributing_topics.is_empty() {
558 "### Notes on related subtopics:".to_string()
559 } else {
560 let label = contributing_topics
561 .iter()
562 .take(5)
563 .cloned()
564 .collect::<Vec<_>>()
565 .join(", ");
566 format!("### Notes on related subtopics ({label}):")
567 };
568 blocks.push(format!("{header}\n\n{section}"));
569 }
570
571 let content_block = blocks.join("\n\n");
572
573 let message = format!(
574 "Research topic: \"{topic}\"\n\n\
575 {content_block}\n\n\
576 Using the vault content above, provide a comprehensive overview of \"{topic}\":\n\
577 1. What does the vault capture about this topic?\n\
578 2. What are the key ideas, patterns, or recurring themes?\n\
579 3. How do the related notes connect to the topic?\n\
580 4. What gaps or unexplored angles exist?",
581 topic = p.topic,
582 );
583
584 Ok(vec![PromptMessage::new_text(PromptMessageRole::User, message)])
585 }
586
587 #[prompt(description = "Find vault notes topically related to the given note but not yet linked, and ask the LLM to evaluate which connections are worth formalising.")]
588 async fn link_suggestions(
589 &self,
590 Parameters(p): Parameters<LinkSuggestionsParams>,
591 ) -> Result<Vec<PromptMessage>, McpError> {
592 use kimun_core::error::{FSError, VaultError};
593 use kimun_core::nfs::VaultPath;
594 use kimun_core::note::LinkType;
595 use std::collections::HashSet;
596
597 let vault_path = VaultPath::note_path_from(&p.path);
598 let max = p.max_results.unwrap_or(5) as usize;
599
600 let note_text = match self.vault.get_note_text(&vault_path).await {
602 Ok(t) => t,
603 Err(VaultError::FSError(FSError::VaultPathNotFound { .. })) => {
604 return Ok(vec![PromptMessage::new_text(
605 PromptMessageRole::User,
606 format!("Note not found: {}", vault_path),
607 )]);
608 }
609 Err(e) => return Err(McpError::internal_error(e.to_string(), None)),
610 };
611
612 let topics = self.extract_leaf_headings(&vault_path).await?;
613
614 let mut excluded: HashSet<String> = HashSet::new();
616 excluded.insert(vault_path.to_string());
617
618 let md_note = self
619 .vault
620 .get_markdown_and_links(&vault_path)
621 .await
622 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
623 for link in md_note.links {
624 if let LinkType::Note(linked_path) = link.ltype {
625 excluded.insert(linked_path.to_string());
626 }
627 }
628
629 let backlinks = self
630 .vault
631 .get_backlinks(&vault_path)
632 .await
633 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
634 for (entry, _) in &backlinks {
635 excluded.insert(entry.path.to_string());
636 }
637
638 let mut candidates: Vec<(VaultPath, String)> = Vec::new();
640 let mut seen: HashSet<String> = excluded.clone();
641
642 'outer: for topic in &topics {
643 let results = self
644 .vault
645 .search_notes(topic)
646 .await
647 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
648 for (entry, _) in results {
649 let path_str = entry.path.to_string();
650 if seen.contains(&path_str) {
651 continue;
652 }
653 seen.insert(path_str);
654 let text = self
655 .vault
656 .get_note_text(&entry.path)
657 .await
658 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
659 candidates.push((entry.path, text));
660 if candidates.len() >= max {
661 break 'outer;
662 }
663 }
664 }
665
666 if candidates.is_empty() {
667 return Ok(vec![PromptMessage::new_text(
668 PromptMessageRole::User,
669 format!(
670 "Here is the note at \"{}\":\n\n---\n{}\n---\n\nNo unlinked related notes found in the vault.",
671 vault_path, note_text
672 ),
673 )]);
674 }
675
676 let candidates_block: String = candidates
677 .iter()
678 .map(|(path, text)| format!("=== {} ===\n{}", path, text))
679 .collect::<Vec<_>>()
680 .join("\n\n");
681
682 let message = format!(
683 "Here is the note at \"{path}\":\n\n---\n{note_text}\n---\n\n\
684 Candidate notes not yet linked to or from this note:\n\n\
685 {candidates_block}\n\n\
686 For each candidate:\n\
687 1. Assess whether a meaningful conceptual connection exists.\n\
688 2. If yes, suggest the exact [[wikilink]] syntax to add and where in the note it fits.\n\
689 3. If no clear connection, explain briefly why it was surfaced.",
690 path = vault_path,
691 );
692
693 Ok(vec![PromptMessage::new_text(PromptMessageRole::User, message)])
694 }
695}
696
697#[cfg(test)]
698mod tests {
699 use super::*;
700 use super::super::*;
701 use tempfile::TempDir;
702 use kimun_core::NoteVault;
703
704 async fn make_handler() -> (KimunHandler, TempDir) {
705 let dir = TempDir::new().unwrap();
706 let vault = NoteVault::new(dir.path()).await.unwrap();
707 vault.validate_and_init().await.unwrap();
708 let handler = KimunHandler::new(vault);
709 (handler, dir)
710 }
711
712 fn first_text(msgs: &[PromptMessage]) -> String {
714 match msgs.first().map(|m| &m.content) {
715 Some(PromptMessageContent::Text { text }) => text.clone(),
716 _ => String::new(),
717 }
718 }
719
720 #[tokio::test]
721 async fn test_daily_review_no_entry_returns_graceful_message() {
722 let (handler, _dir) = make_handler().await;
723 let msgs = handler
724 .daily_review(Parameters(DailyReviewParams { date: None }))
725 .await
726 .unwrap();
727 assert!(!msgs.is_empty());
728 let text = first_text(&msgs);
729 assert!(
730 text.contains("No journal entry"),
731 "expected graceful message, got: {}",
732 text
733 );
734 }
735
736 #[tokio::test]
737 async fn test_daily_review_with_entry_includes_content() {
738 let (handler, _dir) = make_handler().await;
739 handler
741 .journal(Parameters(JournalParams {
742 text: "worked on unique_daily_review_content_xyz".to_string(),
743 date: None,
744 }))
745 .await
746 .unwrap();
747 let msgs = handler
748 .daily_review(Parameters(DailyReviewParams { date: None }))
749 .await
750 .unwrap();
751 assert!(!msgs.is_empty());
752 let text = first_text(&msgs);
753 assert!(
754 text.contains("unique_daily_review_content_xyz"),
755 "expected journal content in prompt: {}",
756 text
757 );
758 }
759
760 #[tokio::test]
761 async fn test_daily_review_specific_date() {
762 let (handler, _dir) = make_handler().await;
763 handler
764 .journal(Parameters(JournalParams {
765 text: "specific date entry content".to_string(),
766 date: Some("2026-01-15".to_string()),
767 }))
768 .await
769 .unwrap();
770 let msgs = handler
771 .daily_review(Parameters(DailyReviewParams {
772 date: Some("2026-01-15".to_string()),
773 }))
774 .await
775 .unwrap();
776 assert!(!msgs.is_empty());
777 let text = first_text(&msgs);
778 assert!(
779 text.contains("specific date entry content"),
780 "expected entry in prompt: {}",
781 text
782 );
783 }
784
785 #[tokio::test]
786 async fn test_daily_review_invalid_date_returns_error() {
787 let (handler, _dir) = make_handler().await;
788 let result = handler
789 .daily_review(Parameters(DailyReviewParams {
790 date: Some("not-a-date".to_string()),
791 }))
792 .await;
793 assert!(result.is_err(), "expected Err for invalid date");
794 let err = result.unwrap_err();
795 assert!(
796 err.message.contains("Invalid date"),
797 "expected error message to mention invalid date: {:?}",
798 err
799 );
800 }
801
802 #[tokio::test]
803 async fn test_find_connections_includes_note_content() {
804 let (handler, _dir) = make_handler().await;
805 handler
806 .create_note(Parameters(CreateNoteParams {
807 path: "my/note".to_string(),
808 content: "# My Note\n\nunique_connections_content_abc".to_string(),
809 }))
810 .await
811 .unwrap();
812 let msgs = handler
813 .find_connections(Parameters(FindConnectionsParams {
814 path: "my/note".to_string(),
815 }))
816 .await
817 .unwrap();
818 assert!(!msgs.is_empty());
819 let text = first_text(&msgs);
820 assert!(
821 text.contains("unique_connections_content_abc"),
822 "expected note content in prompt: {}",
823 text
824 );
825 }
826
827 #[tokio::test]
828 async fn test_find_connections_lists_backlinks() {
829 let (handler, _dir) = make_handler().await;
830 handler
831 .create_note(Parameters(CreateNoteParams {
832 path: "target".to_string(),
833 content: "# Target".to_string(),
834 }))
835 .await
836 .unwrap();
837 handler
838 .create_note(Parameters(CreateNoteParams {
839 path: "source".to_string(),
840 content: "see [[target]] for details".to_string(),
841 }))
842 .await
843 .unwrap();
844 let msgs = handler
845 .find_connections(Parameters(FindConnectionsParams {
846 path: "target".to_string(),
847 }))
848 .await
849 .unwrap();
850 let text = first_text(&msgs);
851 assert!(
852 text.contains("source"),
853 "expected backlink 'source' in prompt: {}",
854 text
855 );
856 }
857
858 #[tokio::test]
859 async fn test_find_connections_no_backlinks_omits_section() {
860 let (handler, _dir) = make_handler().await;
861 handler
862 .create_note(Parameters(CreateNoteParams {
863 path: "lone/note".to_string(),
864 content: "# Lone\n\nno links to here".to_string(),
865 }))
866 .await
867 .unwrap();
868 let msgs = handler
869 .find_connections(Parameters(FindConnectionsParams {
870 path: "lone/note".to_string(),
871 }))
872 .await
873 .unwrap();
874 assert!(!msgs.is_empty());
875 let text = first_text(&msgs);
876 assert!(text.contains("Lone"), "expected note content: {}", text);
878 assert!(
879 !text.contains("Notes that link"),
880 "should not have backlinks section: {}",
881 text
882 );
883 }
884
885 #[tokio::test]
886 async fn test_find_connections_note_not_found() {
887 let (handler, _dir) = make_handler().await;
888 let msgs = handler
889 .find_connections(Parameters(FindConnectionsParams {
890 path: "missing/note".to_string(),
891 }))
892 .await
893 .unwrap();
894 assert!(!msgs.is_empty());
895 let text = first_text(&msgs);
896 assert!(text.contains("not found"), "expected not-found message: {}", text);
897 }
898
899 #[tokio::test]
900 async fn test_research_note_includes_source_note() {
901 let (handler, _dir) = make_handler().await;
902 handler
903 .create_note(Parameters(CreateNoteParams {
904 path: "research/topic".to_string(),
905 content: "# Topic\n\n## Background\n\nunique_research_source_xyz\n\n## Open Questions\n\nwhat next?".to_string(),
906 }))
907 .await
908 .unwrap();
909 let msgs = handler
910 .research_note(Parameters(ResearchNoteParams {
911 path: "research/topic".to_string(),
912 max_results: Some(3),
913 }))
914 .await
915 .unwrap();
916 assert!(!msgs.is_empty());
917 let text = first_text(&msgs);
918 assert!(
919 text.contains("unique_research_source_xyz"),
920 "expected source note content: {}",
921 text
922 );
923 }
924
925 #[tokio::test]
926 async fn test_research_note_includes_related_notes() {
927 let (handler, _dir) = make_handler().await;
928 handler
929 .create_note(Parameters(CreateNoteParams {
930 path: "research/main".to_string(),
931 content: "# Main\n\n## Rust Programming\n\nabout rust".to_string(),
932 }))
933 .await
934 .unwrap();
935 handler
936 .create_note(Parameters(CreateNoteParams {
937 path: "research/related".to_string(),
938 content: "# Related\n\nRust Programming is great".to_string(),
939 }))
940 .await
941 .unwrap();
942 let msgs = handler
943 .research_note(Parameters(ResearchNoteParams {
944 path: "research/main".to_string(),
945 max_results: Some(5),
946 }))
947 .await
948 .unwrap();
949 let text = first_text(&msgs);
950 assert!(
951 text.contains("research/related"),
952 "expected related note in prompt: {}",
953 text
954 );
955 }
956
957 #[tokio::test]
958 async fn test_research_note_not_found() {
959 let (handler, _dir) = make_handler().await;
960 let msgs = handler
961 .research_note(Parameters(ResearchNoteParams {
962 path: "missing/note".to_string(),
963 max_results: None,
964 }))
965 .await
966 .unwrap();
967 assert!(!msgs.is_empty());
968 let text = first_text(&msgs);
969 assert!(text.contains("not found"), "expected not-found message: {}", text);
970 }
971
972 #[tokio::test]
973 async fn test_brainstorm_includes_vault_content() {
974 let (handler, _dir) = make_handler().await;
975 handler
976 .create_note(Parameters(CreateNoteParams {
977 path: "ideas/rust".to_string(),
978 content: "# Rust Ideas\n\nunique_brainstorm_rust_content_xyz".to_string(),
979 }))
980 .await
981 .unwrap();
982 let msgs = handler
983 .brainstorm(Parameters(BrainstormParams {
984 topic: "unique_brainstorm_rust_content_xyz".to_string(),
985 max_results: None,
986 }))
987 .await
988 .unwrap();
989 assert!(!msgs.is_empty());
990 let text = first_text(&msgs);
991 assert!(
992 text.contains("unique_brainstorm_rust_content_xyz"),
993 "expected vault content in prompt: {}",
994 text
995 );
996 }
997
998 #[tokio::test]
999 async fn test_brainstorm_suggests_note_to_append() {
1000 let (handler, _dir) = make_handler().await;
1001 handler
1002 .create_note(Parameters(CreateNoteParams {
1003 path: "ideas/brainstorm_target".to_string(),
1004 content: "# Brainstorm Target\n\nunique_suggest_xyz_content".to_string(),
1005 }))
1006 .await
1007 .unwrap();
1008 let msgs = handler
1009 .brainstorm(Parameters(BrainstormParams {
1010 topic: "unique_suggest_xyz_content".to_string(),
1011 max_results: None,
1012 }))
1013 .await
1014 .unwrap();
1015 let text = first_text(&msgs);
1016 assert!(
1017 text.contains("ideas/brainstorm_target"),
1018 "expected suggested note path: {}",
1019 text
1020 );
1021 }
1022
1023 #[tokio::test]
1024 async fn test_brainstorm_no_vault_content_still_returns_prompt() {
1025 let (handler, _dir) = make_handler().await;
1026 let msgs = handler
1027 .brainstorm(Parameters(BrainstormParams {
1028 topic: "completely_nonexistent_topic_zzz_999".to_string(),
1029 max_results: None,
1030 }))
1031 .await
1032 .unwrap();
1033 assert!(!msgs.is_empty());
1034 let text = first_text(&msgs);
1035 assert!(
1036 text.contains("completely_nonexistent_topic_zzz_999"),
1037 "expected topic in prompt: {}",
1038 text
1039 );
1040 assert!(
1042 !text.contains("Suggested note"),
1043 "should not suggest a note when no results: {}",
1044 text
1045 );
1046 }
1047
1048 #[tokio::test]
1049 async fn test_weekly_review_includes_entries_and_marks_missing() {
1050 let (handler, _dir) = make_handler().await;
1051 handler
1053 .journal(Parameters(JournalParams {
1054 text: "monday content unique_weekly_mon_xyz".to_string(),
1055 date: Some("2026-03-02".to_string()),
1056 }))
1057 .await
1058 .unwrap();
1059 handler
1060 .journal(Parameters(JournalParams {
1061 text: "wednesday content unique_weekly_wed_xyz".to_string(),
1062 date: Some("2026-03-04".to_string()),
1063 }))
1064 .await
1065 .unwrap();
1066 let msgs = handler
1067 .weekly_review(Parameters(WeeklyReviewParams {
1068 date: Some("2026-03-02".to_string()),
1069 }))
1070 .await
1071 .unwrap();
1072 assert!(!msgs.is_empty());
1073 let text = first_text(&msgs);
1074 assert!(text.contains("unique_weekly_mon_xyz"), "monday entry: {}", text);
1075 assert!(text.contains("unique_weekly_wed_xyz"), "wednesday entry: {}", text);
1076 assert!(text.contains("(no entry)"), "missing days: {}", text);
1078 }
1079
1080 #[tokio::test]
1081 async fn test_weekly_review_date_in_middle_of_week_uses_correct_range() {
1082 let (handler, _dir) = make_handler().await;
1083 let msgs = handler
1085 .weekly_review(Parameters(WeeklyReviewParams {
1086 date: Some("2026-03-04".to_string()),
1087 }))
1088 .await
1089 .unwrap();
1090 let text = first_text(&msgs);
1091 assert!(
1092 text.contains("2026-03-02") && text.contains("2026-03-08"),
1093 "expected Mon 2026-03-02 – Sun 2026-03-08 in: {}",
1094 text
1095 );
1096 }
1097
1098 #[tokio::test]
1099 async fn test_weekly_review_invalid_date_returns_error() {
1100 let (handler, _dir) = make_handler().await;
1101 let result = handler
1102 .weekly_review(Parameters(WeeklyReviewParams {
1103 date: Some("not-a-date".to_string()),
1104 }))
1105 .await;
1106 assert!(result.is_err(), "expected Err for invalid date");
1107 let err = result.unwrap_err();
1108 assert!(
1109 err.message.contains("Invalid date"),
1110 "expected error message to mention invalid date: {:?}",
1111 err
1112 );
1113 }
1114
1115 #[tokio::test]
1116 async fn test_link_suggestions_returns_unlinked_candidates() {
1117 let (handler, _dir) = make_handler().await;
1118 handler
1119 .create_note(Parameters(CreateNoteParams {
1120 path: "source".to_string(),
1121 content: "# Source\n\n## Rust Programming\n\nsome rust content".to_string(),
1122 }))
1123 .await
1124 .unwrap();
1125 handler
1126 .create_note(Parameters(CreateNoteParams {
1127 path: "candidate".to_string(),
1128 content: "# Candidate\n\nRust Programming is great".to_string(),
1129 }))
1130 .await
1131 .unwrap();
1132 let msgs = handler
1133 .link_suggestions(Parameters(LinkSuggestionsParams {
1134 path: "source".to_string(),
1135 max_results: Some(5),
1136 }))
1137 .await
1138 .unwrap();
1139 assert!(!msgs.is_empty());
1140 let text = first_text(&msgs);
1141 assert!(
1142 text.contains("candidate"),
1143 "expected candidate note in prompt: {}",
1144 text
1145 );
1146 }
1147
1148 #[tokio::test]
1149 async fn test_link_suggestions_excludes_already_linked_notes() {
1150 let (handler, _dir) = make_handler().await;
1151 handler
1152 .create_note(Parameters(CreateNoteParams {
1153 path: "source".to_string(),
1154 content: "# Source\n\n## Rust Programming\n\nsee [[linked-note]]".to_string(),
1155 }))
1156 .await
1157 .unwrap();
1158 handler
1159 .create_note(Parameters(CreateNoteParams {
1160 path: "linked-note".to_string(),
1161 content: "# Linked Note\n\nRust Programming is great".to_string(),
1162 }))
1163 .await
1164 .unwrap();
1165 let msgs = handler
1166 .link_suggestions(Parameters(LinkSuggestionsParams {
1167 path: "source".to_string(),
1168 max_results: Some(5),
1169 }))
1170 .await
1171 .unwrap();
1172 let text = first_text(&msgs);
1173 assert!(
1175 !text.contains("=== /linked-note") && !text.contains("=== linked-note"),
1176 "linked-note should be excluded from candidates: {}",
1177 text
1178 );
1179 }
1180
1181 #[tokio::test]
1182 async fn test_link_suggestions_empty_vault_returns_graceful_message() {
1183 let (handler, _dir) = make_handler().await;
1184 handler
1185 .create_note(Parameters(CreateNoteParams {
1186 path: "lonely".to_string(),
1187 content: "# Lonely\n\n## Some Topic\n\nalone".to_string(),
1188 }))
1189 .await
1190 .unwrap();
1191 let msgs = handler
1192 .link_suggestions(Parameters(LinkSuggestionsParams {
1193 path: "lonely".to_string(),
1194 max_results: Some(5),
1195 }))
1196 .await
1197 .unwrap();
1198 assert!(!msgs.is_empty());
1199 let text = first_text(&msgs);
1200 assert!(
1201 text.contains("No unlinked related notes"),
1202 "expected graceful no-results message: {}",
1203 text
1204 );
1205 }
1206
1207 #[tokio::test]
1210 async fn test_research_topic_no_results_returns_graceful_message() {
1211 let (handler, _dir) = make_handler().await;
1212 let msgs = handler
1213 .research_topic(Parameters(ResearchTopicParams {
1214 topic: "completely_nonexistent_topic_zzz_123".to_string(),
1215 max_results: None,
1216 }))
1217 .await
1218 .unwrap();
1219 assert!(!msgs.is_empty());
1220 let text = first_text(&msgs);
1221 assert!(
1222 text.contains("No notes found"),
1223 "expected graceful no-results message: {}",
1224 text
1225 );
1226 }
1227
1228 #[tokio::test]
1229 async fn test_research_topic_includes_direct_search_results() {
1230 let (handler, _dir) = make_handler().await;
1231 handler
1232 .create_note(Parameters(CreateNoteParams {
1233 path: "science/quantum".to_string(),
1234 content: "# Quantum Physics\n\nunique_quantum_direct_xyz".to_string(),
1235 }))
1236 .await
1237 .unwrap();
1238 let msgs = handler
1239 .research_topic(Parameters(ResearchTopicParams {
1240 topic: "unique_quantum_direct_xyz".to_string(),
1241 max_results: None,
1242 }))
1243 .await
1244 .unwrap();
1245 assert!(!msgs.is_empty());
1246 let text = first_text(&msgs);
1247 assert!(
1248 text.contains("unique_quantum_direct_xyz"),
1249 "expected direct result content in prompt: {}",
1250 text
1251 );
1252 assert!(
1253 text.contains("Notes matching"),
1254 "expected direct-results section header: {}",
1255 text
1256 );
1257 }
1258
1259 #[tokio::test]
1260 async fn test_research_topic_includes_backlinks() {
1261 let (handler, _dir) = make_handler().await;
1262 handler
1264 .create_note(Parameters(CreateNoteParams {
1265 path: "topics/target".to_string(),
1266 content: "# Target\n\nunique_backlink_target_xyz".to_string(),
1267 }))
1268 .await
1269 .unwrap();
1270 handler
1273 .create_note(Parameters(CreateNoteParams {
1274 path: "topics/linker".to_string(),
1275 content: "# Linker\n\nSee [[topics/target]] for more detail".to_string(),
1276 }))
1277 .await
1278 .unwrap();
1279 let msgs = handler
1280 .research_topic(Parameters(ResearchTopicParams {
1281 topic: "unique_backlink_target_xyz".to_string(),
1282 max_results: Some(10),
1283 }))
1284 .await
1285 .unwrap();
1286 let text = first_text(&msgs);
1287 assert!(
1288 text.contains("topics/linker"),
1289 "expected linker note to appear somewhere in the prompt: {}",
1290 text
1291 );
1292 }
1293
1294 #[tokio::test]
1295 async fn test_research_topic_includes_related_via_headings() {
1296 let (handler, _dir) = make_handler().await;
1297 handler
1299 .create_note(Parameters(CreateNoteParams {
1300 path: "topics/main".to_string(),
1301 content: "# Main\n\n## Async Runtime\n\nunique_heading_research_abc".to_string(),
1302 }))
1303 .await
1304 .unwrap();
1305 handler
1307 .create_note(Parameters(CreateNoteParams {
1308 path: "topics/related".to_string(),
1309 content: "# Related\n\nAsync Runtime is fundamental in Rust".to_string(),
1310 }))
1311 .await
1312 .unwrap();
1313 let msgs = handler
1314 .research_topic(Parameters(ResearchTopicParams {
1315 topic: "unique_heading_research_abc".to_string(),
1316 max_results: Some(10),
1317 }))
1318 .await
1319 .unwrap();
1320 let text = first_text(&msgs);
1321 assert!(
1322 text.contains("topics/related"),
1323 "expected related note via heading search: {}",
1324 text
1325 );
1326 assert!(
1327 text.contains("Notes on related subtopics"),
1328 "expected subtopics section header: {}",
1329 text
1330 );
1331 }
1332
1333 #[tokio::test]
1334 async fn test_research_topic_deduplicates_notes() {
1335 let (handler, _dir) = make_handler().await;
1336 handler
1338 .create_note(Parameters(CreateNoteParams {
1339 path: "dedup/alpha".to_string(),
1340 content: "# Alpha\n\nunique_dedup_topic_xyz\n\n## Subtopic\n\nunique_dedup_sub_xyz".to_string(),
1341 }))
1342 .await
1343 .unwrap();
1344 handler
1346 .create_note(Parameters(CreateNoteParams {
1347 path: "dedup/beta".to_string(),
1348 content: "# Beta\n\nunique_dedup_sub_xyz and more".to_string(),
1349 }))
1350 .await
1351 .unwrap();
1352 let msgs = handler
1353 .research_topic(Parameters(ResearchTopicParams {
1354 topic: "unique_dedup_topic_xyz".to_string(),
1355 max_results: Some(10),
1356 }))
1357 .await
1358 .unwrap();
1359 let text = first_text(&msgs);
1360 let count = text.matches("dedup/alpha").count();
1362 assert!(
1363 count >= 1,
1364 "expected dedup/alpha to appear at least once: {}",
1365 text
1366 );
1367 let header_count = text.matches("/dedup/alpha").count();
1369 assert!(
1370 header_count <= 2, "dedup/alpha appeared too many times ({}), suggesting duplicate inclusion: {}",
1372 header_count,
1373 text
1374 );
1375 }
1376
1377 #[tokio::test]
1378 async fn test_research_topic_respects_max_results() {
1379 let (handler, _dir) = make_handler().await;
1380 for i in 0..5 {
1382 handler
1383 .create_note(Parameters(CreateNoteParams {
1384 path: format!("limit/note{}", i),
1385 content: format!("# Note {}\n\nunique_limit_topic_xyz note number {}", i, i),
1386 }))
1387 .await
1388 .unwrap();
1389 }
1390 let msgs = handler
1391 .research_topic(Parameters(ResearchTopicParams {
1392 topic: "unique_limit_topic_xyz".to_string(),
1393 max_results: Some(2),
1394 }))
1395 .await
1396 .unwrap();
1397 let text = first_text(&msgs);
1398 let section_count = (0..5)
1400 .filter(|i| text.contains(&format!("limit/note{}", i)))
1401 .count();
1402 assert!(
1403 section_count <= 2,
1404 "expected at most 2 notes with max_results=2, found {} in: {}",
1405 section_count,
1406 text
1407 );
1408 }
1409}