Skip to main content

kimun_notes/cli/commands/mcp/
prompts.rs

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