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    schemars,
10    prompt, prompt_router,
11};
12use serde::Deserialize;
13
14use super::KimunHandler;
15
16// ---------------------------------------------------------------------------
17// Parameter structs
18// ---------------------------------------------------------------------------
19
20#[derive(Debug, Deserialize, schemars::JsonSchema)]
21pub struct DailyReviewParams {
22    /// Date in YYYY-MM-DD format; defaults to today
23    pub date: Option<String>,
24}
25
26#[derive(Debug, Deserialize, schemars::JsonSchema)]
27pub struct FindConnectionsParams {
28    /// Vault-relative path to the note, e.g. "projects/my-note"
29    pub path: String,
30}
31
32#[derive(Debug, Deserialize, schemars::JsonSchema)]
33pub struct ResearchNoteParams {
34    /// Vault-relative path to the note
35    pub path: String,
36    /// Maximum number of related notes to include (default 5)
37    pub max_results: Option<u32>,
38}
39
40#[derive(Debug, Deserialize, schemars::JsonSchema)]
41pub struct BrainstormParams {
42    /// Topic to brainstorm ideas about
43    pub topic: String,
44    /// Maximum number of vault notes to include as context (default 5)
45    pub max_results: Option<u32>,
46}
47
48#[derive(Debug, Deserialize, schemars::JsonSchema)]
49pub struct WeeklyReviewParams {
50    /// Any date within the target week in YYYY-MM-DD format; defaults to today
51    pub date: Option<String>,
52}
53
54#[derive(Debug, Deserialize, schemars::JsonSchema)]
55pub struct LinkSuggestionsParams {
56    /// Vault-relative path to the note
57    pub path: String,
58    /// Maximum number of candidate notes to include (default 5)
59    pub max_results: Option<u32>,
60}
61
62#[derive(Debug, Deserialize, schemars::JsonSchema)]
63pub struct ResearchTopicParams {
64    /// Topic or keyword to research across the vault
65    pub topic: String,
66    /// Maximum total number of notes to include (default 10)
67    pub max_results: Option<u32>,
68}
69
70// ---------------------------------------------------------------------------
71// Helpers
72// ---------------------------------------------------------------------------
73
74impl KimunHandler {
75    /// Return the unique leaf heading strings from a note's chunk tree, in
76    /// insertion order.  Used by several prompts to derive secondary search
77    /// terms from a note's section outline.
78    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// ---------------------------------------------------------------------------
107// Prompt implementations
108// ---------------------------------------------------------------------------
109
110#[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        // Search each topic; deduplicate results; cap at max
237        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        // Parse or default to today
353        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        // Compute Monday and Sunday of the week
367        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        // Day names for formatting
372        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        // Step 1: Direct search for the topic
423        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        // secondary_topics: insertion-ordered, deduplicated case-insensitively
432        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            // Step 2a: Collect backlinks for this note
454            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            // Step 2b: Extract leaf headings for secondary search (case-insensitive dedup)
468            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        // Step 3: Load backlink notes (within remaining budget)
480        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        // Step 4: Secondary search using headings extracted from the initial results
494        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        // Load source note
601        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        // Build exclusion set: outlinks + backlinks + source itself
615        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        // Search each heading; collect, deduplicate, filter, cap
639        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    /// Extract the text from the first PromptMessage's content.
713    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        // Create today's entry via the journal tool
740        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        // Note content should be present; backlinks section should be absent
877        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        // No suggestion line when no results
1041        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        // Create entries for Monday and Wednesday of a known week (2026-03-02 is a Monday)
1052        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        // Days without entries should show (no entry)
1077        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        // 2026-03-04 is a Wednesday — should resolve to Mon 2026-03-02 – Sun 2026-03-08
1084        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        // The already-linked note should not appear as a candidate
1174        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    // ── research_topic tests ────────────────────────────────────────────────
1208
1209    #[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        // Note that will be a direct search hit
1263        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        // Note that links to target — should appear somewhere in the output (via backlinks
1271        // if the index is warm, or via heading-based secondary search otherwise)
1272        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        // Direct hit with a heading that becomes a secondary search term
1298        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        // Note that matches the heading "Async Runtime"
1306        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        // Note matches both direct search and would be a backlink from itself — must appear once
1337        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        // Second note that matches on the subtopic heading
1345        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        // Count occurrences of "dedup/alpha" — should appear exactly once
1361        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        // The prompt message should only contain one === /dedup/alpha === block
1368        let header_count = text.matches("/dedup/alpha").count();
1369        assert!(
1370            header_count <= 2, // path may appear in section header and content
1371            "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        // Create 5 notes all matching the same topic
1381        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        // At most 2 note sections should be present
1399        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}