Skip to main content

kimun_notes/cli/commands/mcp/
mod.rs

1// tui/src/cli/commands/mcp/mod.rs
2//
3// MCP server handler for kimun — exposes vault operations as MCP tools.
4
5pub mod prompts;
6
7use std::path::PathBuf;
8use std::sync::Arc;
9
10use color_eyre::eyre::{Result, eyre};
11use kimun_core::{NoteVault, nfs::VaultPath};
12use rmcp::{
13    ErrorData as McpError, RoleServer, ServerHandler, ServiceExt,
14    handler::server::{
15        router::{prompt::PromptRouter, tool::ToolRouter},
16        wrapper::Parameters,
17    },
18    model::*,
19    prompt_handler, schemars,
20    service::RequestContext,
21    tool, tool_handler, tool_router,
22    transport::stdio,
23};
24use serde::Deserialize;
25
26// ---------------------------------------------------------------------------
27// Parameter structs
28// ---------------------------------------------------------------------------
29
30#[derive(Debug, Deserialize, schemars::JsonSchema)]
31pub struct CreateNoteParams {
32    pub path: String,
33    pub content: String,
34}
35
36#[derive(Debug, Deserialize, schemars::JsonSchema)]
37pub struct AppendNoteParams {
38    pub path: String,
39    pub content: String,
40}
41
42#[derive(Debug, Deserialize, schemars::JsonSchema)]
43pub struct ShowNoteParams {
44    pub path: String,
45}
46
47#[derive(Debug, Deserialize, schemars::JsonSchema)]
48pub struct SearchNotesParams {
49    pub query: String,
50}
51
52#[derive(Debug, Deserialize, schemars::JsonSchema)]
53pub struct ListNotesParams {
54    pub path: Option<String>,
55}
56
57#[derive(Debug, Deserialize, schemars::JsonSchema)]
58pub struct JournalParams {
59    pub text: String,
60    pub date: Option<String>,
61}
62
63#[derive(Debug, Deserialize, schemars::JsonSchema)]
64pub struct BacklinksParams {
65    pub path: String,
66}
67
68#[derive(Debug, Deserialize, schemars::JsonSchema)]
69pub struct ChunksParams {
70    pub path: String,
71}
72
73#[derive(Debug, Deserialize, schemars::JsonSchema)]
74pub struct OutlinksParams {
75    pub path: String,
76}
77
78#[derive(Debug, Deserialize, schemars::JsonSchema)]
79pub struct RenameNoteParams {
80    pub path: String,
81    /// New filename stem — no extension, no path separator
82    pub new_name: String,
83}
84
85#[derive(Debug, Deserialize, schemars::JsonSchema)]
86pub struct MoveNoteParams {
87    pub path: String,
88    pub new_path: String,
89}
90
91#[derive(Debug, Deserialize, schemars::JsonSchema)]
92pub struct QuickNoteParams {
93    /// Text content for the quick note
94    pub content: String,
95}
96
97#[derive(Debug, Deserialize, schemars::JsonSchema)]
98pub struct OverwriteNoteParams {
99    pub path: String,
100    pub content: String,
101}
102
103#[derive(Debug, Deserialize, schemars::JsonSchema)]
104pub struct ReplaceInNoteParams {
105    pub path: String,
106    /// Text to find
107    pub old: String,
108    /// Replacement text (may use $1/${name} capture references when regex is true)
109    pub new: String,
110    /// Replace every occurrence instead of requiring a unique match
111    pub replace_all: Option<bool>,
112    /// Treat `old` as a regular expression instead of a literal substring
113    pub regex: Option<bool>,
114    /// Preview the resulting content without writing the note (dry run)
115    pub preview: Option<bool>,
116}
117
118#[derive(Debug, Deserialize, schemars::JsonSchema)]
119pub struct DeleteNoteParams {
120    pub path: String,
121}
122
123// ---------------------------------------------------------------------------
124// Handler struct
125// ---------------------------------------------------------------------------
126
127#[derive(Clone)]
128pub struct KimunHandler {
129    vault: Arc<NoteVault>,
130    // Read at runtime by the `#[tool_handler]` / `#[prompt_handler]` generated
131    // impls (see below); clippy can't see through the macro expansion, so it
132    // flags these as unread despite being the live tool/prompt dispatch tables.
133    #[allow(dead_code)]
134    tool_router: ToolRouter<KimunHandler>,
135    #[allow(dead_code)]
136    prompt_router: PromptRouter<KimunHandler>,
137}
138
139// ---------------------------------------------------------------------------
140// Tool implementations
141// ---------------------------------------------------------------------------
142
143/// Map a vault error to the result a note-operation handler returns: the core
144/// user-facing message as a tool error the model can react to, or an internal
145/// protocol error. The message and the recoverable/internal split both come from
146/// core (`VaultError::user_message`), so the MCP server and the CLI render
147/// identical wording and a new error variant flows automatically.
148fn vault_err(e: kimun_core::error::VaultError) -> Result<CallToolResult, McpError> {
149    match e.user_message() {
150        Some(msg) => Ok(CallToolResult::error(vec![Content::text(msg)])),
151        None => Err(McpError::internal_error(e.to_string(), None)),
152    }
153}
154
155#[tool_router]
156impl KimunHandler {
157    pub fn new(vault: NoteVault) -> Self {
158        Self {
159            vault: Arc::new(vault),
160            tool_router: Self::tool_router(),
161            prompt_router: Self::prompt_router(),
162        }
163    }
164
165    fn resolve_path(path: &str) -> VaultPath {
166        VaultPath::note_path_from(path)
167    }
168
169    #[tool(
170        description = "Create a new note at the given vault path with the given markdown content. Fails if the note already exists."
171    )]
172    async fn create_note(
173        &self,
174        Parameters(p): Parameters<CreateNoteParams>,
175    ) -> Result<CallToolResult, McpError> {
176        let vault_path = Self::resolve_path(&p.path);
177        match self.vault.create_note(&vault_path, &p.content).await {
178            Ok(_) => Ok(CallToolResult::success(vec![Content::text(format!(
179                "Note created: {}",
180                vault_path
181            ))])),
182            Err(e) => vault_err(e),
183        }
184    }
185
186    #[tool(description = "Append text to an existing note. Creates the note if it does not exist.")]
187    async fn append_note(
188        &self,
189        Parameters(p): Parameters<AppendNoteParams>,
190    ) -> Result<CallToolResult, McpError> {
191        let vault_path = Self::resolve_path(&p.path);
192        self.vault
193            .append_to_note(&vault_path, &p.content, None)
194            .await
195            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
196        Ok(CallToolResult::success(vec![Content::text(format!(
197            "Note saved: {}",
198            vault_path
199        ))]))
200    }
201
202    #[tool(
203        description = "Replace a note's entire content with new markdown. The previous content is backed up first. Destructive.",
204        annotations(destructive_hint = true)
205    )]
206    async fn overwrite_note(
207        &self,
208        Parameters(p): Parameters<OverwriteNoteParams>,
209    ) -> Result<CallToolResult, McpError> {
210        let vault_path = Self::resolve_path(&p.path);
211        if p.content.is_empty() {
212            return Ok(CallToolResult::error(vec![Content::text(
213                "Refusing to overwrite with empty content (this would wipe the note); pass content, or use delete_note to remove it",
214            )]));
215        }
216        match self.vault.save_note(&vault_path, &p.content).await {
217            Ok(_) => Ok(CallToolResult::success(vec![Content::text(format!(
218                "Note saved: {}",
219                vault_path
220            ))])),
221            Err(e) => vault_err(e),
222        }
223    }
224
225    #[tool(
226        description = "Replace text in a note. `old` is a literal substring by default; set regex=true to treat it as a regular expression, in which case `new` may reference capture groups ($1, ${name}; $$ for a literal $). The match must be unique unless replace_all is true. Set preview=true to get the resulting content back without writing (dry run). The previous content is backed up first. Destructive.",
227        annotations(destructive_hint = true)
228    )]
229    async fn replace_in_note(
230        &self,
231        Parameters(p): Parameters<ReplaceInNoteParams>,
232    ) -> Result<CallToolResult, McpError> {
233        let vault_path = Self::resolve_path(&p.path);
234        let all = p.replace_all.unwrap_or(false);
235        let regex = p.regex.unwrap_or(false);
236
237        if p.preview.unwrap_or(false) {
238            return match self
239                .vault
240                .preview_replace(&vault_path, &p.old, &p.new, all, regex)
241                .await
242            {
243                Ok(pv) => Ok(CallToolResult::success(vec![Content::text(format!(
244                    "{} occurrence(s) would be replaced in {} (preview — not written). Resulting content:\n\n{}",
245                    pv.count, vault_path, pv.content
246                ))])),
247                Err(e) => vault_err(e),
248            };
249        }
250
251        match self
252            .vault
253            .replace_in_note(&vault_path, &p.old, &p.new, all, regex)
254            .await
255        {
256            Ok(n) => Ok(CallToolResult::success(vec![Content::text(format!(
257                "Replaced {} occurrence(s) in {}",
258                n, vault_path
259            ))])),
260            Err(e) => vault_err(e),
261        }
262    }
263
264    #[tool(
265        description = "Delete a note. The content is backed up first. Destructive.",
266        annotations(destructive_hint = true)
267    )]
268    async fn delete_note(
269        &self,
270        Parameters(p): Parameters<DeleteNoteParams>,
271    ) -> Result<CallToolResult, McpError> {
272        let vault_path = Self::resolve_path(&p.path);
273        match self.vault.delete_note(&vault_path).await {
274            Ok(()) => Ok(CallToolResult::success(vec![Content::text(format!(
275                "Note deleted: {}",
276                vault_path
277            ))])),
278            Err(e) => vault_err(e),
279        }
280    }
281
282    #[tool(description = "Return the full markdown content of a note.")]
283    async fn show_note(
284        &self,
285        Parameters(p): Parameters<ShowNoteParams>,
286    ) -> Result<CallToolResult, McpError> {
287        let vault_path = Self::resolve_path(&p.path);
288        match self.vault.get_note_text(&vault_path).await {
289            Ok(text) => Ok(CallToolResult::success(vec![Content::text(text)])),
290            Err(e) => vault_err(e),
291        }
292    }
293
294    #[tool(
295        description = "Search notes by query. Supports =name (or name:name) to match by note name, @heading (or in:heading), /path prefix, #label (or lb:label) for hashtag-derived labels, <note (or lk:note) for notes that link to the given note (its backlinks), >note (or fwd:note) for the notes the given note links to (its forward links), and - prefix for exclusion (e.g. -term, -#label, -lb:label, -=name, -@heading, -/path, -<note, -lk:note, ->note, -fwd:note). The link filters match by note name (the .md extension is optional, case-insensitive); a bare name matches a linked note in any folder, a path like <dir/note disambiguates, and * wildcards are allowed (<proj*). Hashtag labels (#label) are extracted from note body text only — hashtags inside YAML/TOML frontmatter, fenced code blocks, inline code, HTML, markdown link bodies, and [[wikilinks]] are not indexed. Label names are ASCII [A-Za-z0-9_]+ and matched case-insensitively. Long queries are truncated at 8 KB."
296    )]
297    async fn search_notes(
298        &self,
299        Parameters(p): Parameters<SearchNotesParams>,
300    ) -> Result<CallToolResult, McpError> {
301        let results = self
302            .vault
303            .search_notes(&p.query)
304            .await
305            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
306        if results.is_empty() {
307            return Ok(CallToolResult::success(vec![Content::text(
308                "No results found.",
309            )]));
310        }
311        let lines: Vec<String> = results
312            .iter()
313            .map(|(entry, content)| format!("{} — {}", entry.path, content.title))
314            .collect();
315        Ok(CallToolResult::success(vec![Content::text(
316            lines.join("\n"),
317        )]))
318    }
319
320    #[tool(description = "List all notes in the vault, optionally filtered by path prefix.")]
321    async fn list_notes(
322        &self,
323        Parameters(p): Parameters<ListNotesParams>,
324    ) -> Result<CallToolResult, McpError> {
325        let all = self
326            .vault
327            .get_all_notes()
328            .await
329            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
330        let filtered: Vec<_> = match &p.path {
331            None => all,
332            Some(prefix) => {
333                let norm = prefix.trim_matches('/');
334                all.into_iter()
335                    .filter(|(entry, _)| {
336                        let mut p = entry.path.clone();
337                        p.to_relative();
338                        p.to_string().starts_with(norm)
339                    })
340                    .collect()
341            }
342        };
343        if filtered.is_empty() {
344            return Ok(CallToolResult::success(vec![Content::text(
345                "No notes found.",
346            )]));
347        }
348        let lines: Vec<String> = filtered
349            .iter()
350            .map(|(entry, content)| format!("{} — {}", entry.path, content.title))
351            .collect();
352        Ok(CallToolResult::success(vec![Content::text(
353            lines.join("\n"),
354        )]))
355    }
356
357    #[tool(
358        description = "Append text to today's journal entry (or a specific date). Creates the entry if absent."
359    )]
360    async fn journal(
361        &self,
362        Parameters(p): Parameters<JournalParams>,
363    ) -> Result<CallToolResult, McpError> {
364        // Validate and resolve the date
365        let date_str = match p.date.as_deref() {
366            None => chrono::Utc::now().format("%Y-%m-%d").to_string(),
367            Some(d) => {
368                if chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d").is_err() {
369                    return Ok(CallToolResult::error(vec![Content::text(format!(
370                        "Invalid date '{}' — expected YYYY-MM-DD",
371                        d
372                    ))]));
373                }
374                d.to_string()
375            }
376        };
377
378        // Both today and a specific date resolve to journal/<date>; append under
379        // the per-note lock so concurrent journal writes can't lose an entry.
380        let vault_path = self
381            .vault
382            .journal_path()
383            .append(&VaultPath::note_path_from(&date_str))
384            .absolute();
385        self.vault
386            .append_to_note(&vault_path, &p.text, Some(format!("# {}\n\n", date_str)))
387            .await
388            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
389
390        Ok(CallToolResult::success(vec![Content::text(format!(
391            "Note saved: {}",
392            vault_path
393        ))]))
394    }
395
396    #[tool(description = "Return the list of notes that link to the given note (backlinks).")]
397    async fn get_backlinks(
398        &self,
399        Parameters(p): Parameters<BacklinksParams>,
400    ) -> Result<CallToolResult, McpError> {
401        let vault_path = Self::resolve_path(&p.path);
402        let backlinks = self
403            .vault
404            .get_backlinks(&vault_path)
405            .await
406            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
407        if backlinks.is_empty() {
408            return Ok(CallToolResult::success(vec![Content::text(
409                "No backlinks found.",
410            )]));
411        }
412        let lines: Vec<String> = backlinks
413            .iter()
414            .map(|(entry, content)| format!("{} — {}", entry.path, content.title))
415            .collect();
416        Ok(CallToolResult::success(vec![Content::text(
417            lines.join("\n"),
418        )]))
419    }
420
421    #[tool(description = "Return the content chunks (sections) of a note as JSON.")]
422    async fn get_chunks(
423        &self,
424        Parameters(p): Parameters<ChunksParams>,
425    ) -> Result<CallToolResult, McpError> {
426        let vault_path = Self::resolve_path(&p.path);
427        let chunks_map = self
428            .vault
429            .get_note_chunks(&vault_path)
430            .await
431            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
432
433        let mut lines: Vec<String> = Vec::new();
434        for chunks in chunks_map.values() {
435            for chunk in chunks {
436                let breadcrumb = chunk
437                    .breadcrumb
438                    .replace(kimun_core::note::BREADCRUMB_SEP, " > ");
439                lines.push(format!("[{}] {}", breadcrumb, chunk.text.trim()));
440            }
441        }
442
443        if lines.is_empty() {
444            return Ok(CallToolResult::success(vec![Content::text(
445                "No chunks found.",
446            )]));
447        }
448        Ok(CallToolResult::success(vec![Content::text(
449            lines.join("\n\n"),
450        )]))
451    }
452
453    #[tool(description = "Return the list of notes that this note links to (outgoing wikilinks).")]
454    async fn get_outlinks(
455        &self,
456        Parameters(p): Parameters<OutlinksParams>,
457    ) -> Result<CallToolResult, McpError> {
458        use kimun_core::note::{LinkType, NoteDetails};
459
460        let vault_path = Self::resolve_path(&p.path);
461
462        let md_note = match self.vault.get_markdown_and_links(&vault_path).await {
463            Ok(n) => n,
464            Err(e) => return vault_err(e),
465        };
466
467        let note_links: Vec<_> = md_note
468            .links
469            .into_iter()
470            .filter_map(|link| {
471                if let LinkType::Note(path) = link.ltype {
472                    Some(path)
473                } else {
474                    None
475                }
476            })
477            .collect();
478
479        if note_links.is_empty() {
480            return Ok(CallToolResult::success(vec![Content::text(
481                "No outlinks found.",
482            )]));
483        }
484
485        let mut lines: Vec<String> = Vec::new();
486        for path in note_links {
487            let title = match self.vault.get_note_text(&path).await {
488                Ok(text) => {
489                    let t = NoteDetails::get_title_from_text(&text);
490                    if t.is_empty() {
491                        path.get_clean_name()
492                    } else {
493                        t
494                    }
495                }
496                Err(_) => path.get_clean_name(),
497            };
498            lines.push(format!("{} — {}", path, title));
499        }
500
501        Ok(CallToolResult::success(vec![Content::text(
502            lines.join("\n"),
503        )]))
504    }
505
506    #[tool(
507        description = "Rename a note within its current directory (filename only). Use move_note to change the directory."
508    )]
509    async fn rename_note(
510        &self,
511        Parameters(p): Parameters<RenameNoteParams>,
512    ) -> Result<CallToolResult, McpError> {
513        if p.new_name.contains('/') {
514            return Ok(CallToolResult::error(vec![Content::text(
515                "new_name must not contain '/'. Use move_note to change a note's directory.",
516            )]));
517        }
518
519        let from = Self::resolve_path(&p.path);
520        let (parent, _) = from.get_parent_path();
521        let to = parent
522            .append(&VaultPath::note_path_from(&p.new_name))
523            .absolute();
524
525        match self.vault.rename_note(&from, &to).await {
526            Ok(()) => Ok(CallToolResult::success(vec![Content::text(format!(
527                "Note renamed: {} → {}",
528                from, to
529            ))])),
530            Err(e) if e.is_user_error() => Ok(CallToolResult::error(vec![Content::text(format!(
531                "Note not found or destination already exists: {} → {}",
532                from, to
533            ))])),
534            Err(e) => Err(McpError::internal_error(e.to_string(), None)),
535        }
536    }
537
538    #[tool(
539        description = "Move a note to a new vault path (different directory and/or name). Backlinks in other notes are updated automatically."
540    )]
541    async fn move_note(
542        &self,
543        Parameters(p): Parameters<MoveNoteParams>,
544    ) -> Result<CallToolResult, McpError> {
545        let from = Self::resolve_path(&p.path);
546        let to = Self::resolve_path(&p.new_path);
547
548        match self.vault.rename_note(&from, &to).await {
549            Ok(()) => Ok(CallToolResult::success(vec![Content::text(format!(
550                "Note moved: {} → {}",
551                from, to
552            ))])),
553            Err(e) if e.is_user_error() => Ok(CallToolResult::error(vec![Content::text(format!(
554                "Note not found or destination already exists: {} → {}",
555                from, to
556            ))])),
557            Err(e) => Err(McpError::internal_error(e.to_string(), None)),
558        }
559    }
560
561    #[tool(
562        description = "Quickly capture a thought into a timestamped note in the inbox directory. Returns the path of the created note."
563    )]
564    async fn quick_note(
565        &self,
566        Parameters(p): Parameters<QuickNoteParams>,
567    ) -> Result<CallToolResult, McpError> {
568        if p.content.trim().is_empty() {
569            return Ok(CallToolResult::error(vec![Content::text(
570                "Content cannot be empty.",
571            )]));
572        }
573        match self.vault.quick_note(&p.content).await {
574            Ok(details) => Ok(CallToolResult::success(vec![Content::text(format!(
575                "Note saved: {}",
576                details.path
577            ))])),
578            Err(e) => Err(McpError::internal_error(e.to_string(), None)),
579        }
580    }
581}
582
583// ---------------------------------------------------------------------------
584// ServerHandler implementation
585// ---------------------------------------------------------------------------
586
587#[tool_handler]
588#[prompt_handler]
589impl ServerHandler for KimunHandler {
590    fn get_info(&self) -> ServerInfo {
591        ServerInfo::new(
592            ServerCapabilities::builder()
593                .enable_tools()
594                .enable_resources()
595                .enable_prompts()
596                .build(),
597        )
598        .with_instructions(
599            "Kimun notes MCP server — read and write vault notes via tools. \
600             Search, listing, backlinks, and labels are served from an index that \
601             these tools keep in sync automatically. If vault files are modified \
602             outside Kimün (e.g. edited directly with sed, another editor, or a sync \
603             tool), the index goes stale and results may be wrong until the workspace \
604             is reindexed — run `kimun workspace reindex` and reconnect to this server.",
605        )
606    }
607
608    async fn list_resources(
609        &self,
610        _request: Option<PaginatedRequestParams>,
611        _context: rmcp::service::RequestContext<rmcp::RoleServer>,
612    ) -> Result<ListResourcesResult, McpError> {
613        let notes = self
614            .vault
615            .get_all_notes()
616            .await
617            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
618
619        let resources: Vec<Resource> = notes
620            .into_iter()
621            .map(|(entry, content)| {
622                // Build URI: note://{relative_path_with_ext}
623                let mut rel_path = entry.path.clone();
624                rel_path.to_relative();
625                let uri = format!("note://{}", rel_path.to_string_with_ext());
626
627                // Name: title from NoteContentData, or stem of filename if title empty
628                let name = if content.title.is_empty() {
629                    entry.path.get_clean_name()
630                } else {
631                    content.title.clone()
632                };
633
634                RawResource::new(uri, name)
635                    .with_mime_type("text/markdown")
636                    .no_annotation()
637            })
638            .collect();
639
640        Ok(ListResourcesResult {
641            resources,
642            next_cursor: None,
643            meta: None,
644        })
645    }
646
647    async fn read_resource(
648        &self,
649        request: ReadResourceRequestParams,
650        _context: rmcp::service::RequestContext<rmcp::RoleServer>,
651    ) -> Result<ReadResourceResult, McpError> {
652        let uri = &request.uri;
653
654        // Validate URI scheme
655        let path_with_ext = uri.strip_prefix("note://").ok_or_else(|| {
656            McpError::invalid_params(
657                format!("invalid URI scheme — expected note://, got: {}", uri),
658                None,
659            )
660        })?;
661
662        let vault_path = VaultPath::note_path_from(path_with_ext);
663
664        // Fetch note text
665        match self.vault.get_note_text(&vault_path).await {
666            Ok(text) => Ok(ReadResourceResult::new(vec![ResourceContents::text(
667                text,
668                uri.clone(),
669            )])),
670            Err(kimun_core::error::VaultError::FSError(
671                kimun_core::error::FSError::VaultPathNotFound { .. },
672            )) => Err(McpError::invalid_params(
673                format!("note not found: {}", uri),
674                None,
675            )),
676            Err(e) => Err(McpError::internal_error(e.to_string(), None)),
677        }
678    }
679
680    async fn list_resource_templates(
681        &self,
682        _request: Option<PaginatedRequestParams>,
683        _context: rmcp::service::RequestContext<rmcp::RoleServer>,
684    ) -> Result<ListResourceTemplatesResult, McpError> {
685        Ok(ListResourceTemplatesResult {
686            resource_templates: vec![],
687            next_cursor: None,
688            meta: None,
689        })
690    }
691}
692
693// ---------------------------------------------------------------------------
694// Entry point
695// ---------------------------------------------------------------------------
696
697pub async fn run(config_path: Option<PathBuf>) -> Result<()> {
698    use crate::cli::helpers::create_and_init_vault;
699    let (vault, _) = create_and_init_vault(config_path).await?;
700    let handler = KimunHandler::new(vault);
701    let service = handler.serve(stdio()).await.map_err(|e| eyre!("{e}"))?;
702    service.waiting().await.map_err(|e| eyre!("{e}"))?;
703    Ok(())
704}
705
706// ---------------------------------------------------------------------------
707// Tests
708// ---------------------------------------------------------------------------
709
710#[cfg(test)]
711mod tests {
712    use super::*;
713    use kimun_core::{NoteVault, VaultConfig};
714    use tempfile::TempDir;
715
716    async fn make_handler() -> (KimunHandler, TempDir) {
717        let dir = TempDir::new().unwrap();
718        let vault = NoteVault::new(VaultConfig::new(dir.path())).await.unwrap();
719        vault.validate_and_init().await.unwrap();
720        let handler = KimunHandler::new(vault);
721        (handler, dir)
722    }
723
724    fn is_success(result: &CallToolResult) -> bool {
725        result.is_error != Some(true)
726    }
727
728    fn result_text(result: &CallToolResult) -> String {
729        serde_json::to_string(&result.content).unwrap_or_default()
730    }
731
732    #[tokio::test]
733    async fn test_create_note_succeeds() {
734        let (handler, _dir) = make_handler().await;
735        let result = handler
736            .create_note(Parameters(CreateNoteParams {
737                path: "test/hello".to_string(),
738                content: "# Hello\n\nworld".to_string(),
739            }))
740            .await
741            .unwrap();
742        assert!(
743            is_success(&result),
744            "expected success, got: {:?}",
745            result_text(&result)
746        );
747        assert!(result_text(&result).contains("test/hello"));
748    }
749
750    #[tokio::test]
751    async fn test_create_note_fails_if_exists() {
752        let (handler, _dir) = make_handler().await;
753        handler
754            .create_note(Parameters(CreateNoteParams {
755                path: "test/hello".to_string(),
756                content: "first".to_string(),
757            }))
758            .await
759            .unwrap();
760        let result = handler
761            .create_note(Parameters(CreateNoteParams {
762                path: "test/hello".to_string(),
763                content: "second".to_string(),
764            }))
765            .await
766            .unwrap();
767        assert_eq!(result.is_error, Some(true));
768    }
769
770    #[tokio::test]
771    async fn test_overwrite_note_replaces_whole_body() {
772        let (handler, _dir) = make_handler().await;
773        handler
774            .create_note(Parameters(CreateNoteParams {
775                path: "n".to_string(),
776                content: "old body".to_string(),
777            }))
778            .await
779            .unwrap();
780
781        let result = handler
782            .overwrite_note(Parameters(OverwriteNoteParams {
783                path: "n".to_string(),
784                content: "new body".to_string(),
785            }))
786            .await
787            .unwrap();
788        assert!(is_success(&result), "got: {:?}", result_text(&result));
789
790        let shown = handler
791            .show_note(Parameters(ShowNoteParams {
792                path: "n".to_string(),
793            }))
794            .await
795            .unwrap();
796        assert!(result_text(&shown).contains("new body"));
797        assert!(!result_text(&shown).contains("old body"));
798    }
799
800    #[tokio::test]
801    async fn test_replace_in_note_unique_match() {
802        let (handler, _dir) = make_handler().await;
803        handler
804            .create_note(Parameters(CreateNoteParams {
805                path: "n".to_string(),
806                content: "hello world".to_string(),
807            }))
808            .await
809            .unwrap();
810
811        let result = handler
812            .replace_in_note(Parameters(ReplaceInNoteParams {
813                path: "n".to_string(),
814                old: "world".to_string(),
815                new: "there".to_string(),
816                replace_all: None,
817                regex: None,
818                preview: None,
819            }))
820            .await
821            .unwrap();
822        assert!(is_success(&result), "got: {:?}", result_text(&result));
823
824        let shown = handler
825            .show_note(Parameters(ShowNoteParams {
826                path: "n".to_string(),
827            }))
828            .await
829            .unwrap();
830        assert!(result_text(&shown).contains("hello there"));
831    }
832
833    #[tokio::test]
834    async fn test_replace_in_note_non_unique_is_error() {
835        let (handler, _dir) = make_handler().await;
836        handler
837            .create_note(Parameters(CreateNoteParams {
838                path: "n".to_string(),
839                content: "a a".to_string(),
840            }))
841            .await
842            .unwrap();
843
844        let result = handler
845            .replace_in_note(Parameters(ReplaceInNoteParams {
846                path: "n".to_string(),
847                old: "a".to_string(),
848                new: "b".to_string(),
849                replace_all: None,
850                regex: None,
851                preview: None,
852            }))
853            .await
854            .unwrap();
855        assert_eq!(result.is_error, Some(true));
856    }
857
858    #[tokio::test]
859    async fn test_delete_note_removes_it() {
860        let (handler, _dir) = make_handler().await;
861        handler
862            .create_note(Parameters(CreateNoteParams {
863                path: "n".to_string(),
864                content: "x".to_string(),
865            }))
866            .await
867            .unwrap();
868
869        let result = handler
870            .delete_note(Parameters(DeleteNoteParams {
871                path: "n".to_string(),
872            }))
873            .await
874            .unwrap();
875        assert!(is_success(&result), "got: {:?}", result_text(&result));
876
877        let shown = handler
878            .show_note(Parameters(ShowNoteParams {
879                path: "n".to_string(),
880            }))
881            .await
882            .unwrap();
883        assert_eq!(shown.is_error, Some(true));
884    }
885
886    #[tokio::test]
887    async fn test_show_note_returns_content() {
888        let (handler, _dir) = make_handler().await;
889        handler
890            .create_note(Parameters(CreateNoteParams {
891                path: "show/me".to_string(),
892                content: "# Show me\n\nsome content".to_string(),
893            }))
894            .await
895            .unwrap();
896        let result = handler
897            .show_note(Parameters(ShowNoteParams {
898                path: "show/me".to_string(),
899            }))
900            .await
901            .unwrap();
902        assert!(is_success(&result));
903        assert!(result_text(&result).contains("some content"));
904    }
905
906    #[tokio::test]
907    async fn test_show_note_not_found_returns_error_result() {
908        let (handler, _dir) = make_handler().await;
909        let result = handler
910            .show_note(Parameters(ShowNoteParams {
911                path: "missing/note".to_string(),
912            }))
913            .await
914            .unwrap();
915        assert_eq!(result.is_error, Some(true));
916    }
917
918    #[tokio::test]
919    async fn test_append_note_creates_if_absent() {
920        let (handler, _dir) = make_handler().await;
921        let result = handler
922            .append_note(Parameters(AppendNoteParams {
923                path: "new/note".to_string(),
924                content: "appended text".to_string(),
925            }))
926            .await
927            .unwrap();
928        assert!(is_success(&result));
929        let show = handler
930            .show_note(Parameters(ShowNoteParams {
931                path: "new/note".to_string(),
932            }))
933            .await
934            .unwrap();
935        assert!(result_text(&show).contains("appended text"));
936    }
937
938    #[tokio::test]
939    async fn test_append_note_appends_to_existing() {
940        let (handler, _dir) = make_handler().await;
941        handler
942            .create_note(Parameters(CreateNoteParams {
943                path: "exist/note".to_string(),
944                content: "original".to_string(),
945            }))
946            .await
947            .unwrap();
948        handler
949            .append_note(Parameters(AppendNoteParams {
950                path: "exist/note".to_string(),
951                content: "added".to_string(),
952            }))
953            .await
954            .unwrap();
955        let show = handler
956            .show_note(Parameters(ShowNoteParams {
957                path: "exist/note".to_string(),
958            }))
959            .await
960            .unwrap();
961        let text = result_text(&show);
962        assert!(text.contains("original"), "missing 'original' in: {}", text);
963        assert!(text.contains("added"), "missing 'added' in: {}", text);
964        let orig_pos = text.find("original").expect("original not found");
965        let added_pos = text.find("added").expect("added not found");
966        assert!(orig_pos < added_pos, "original should appear before added");
967    }
968
969    #[tokio::test]
970    async fn test_search_notes_finds_match() {
971        let (handler, _dir) = make_handler().await;
972        handler
973            .create_note(Parameters(CreateNoteParams {
974                path: "alpha/one".to_string(),
975                content: "# Alpha\n\ncontains unique_keyword_xyz".to_string(),
976            }))
977            .await
978            .unwrap();
979        let result = handler
980            .search_notes(Parameters(SearchNotesParams {
981                query: "unique_keyword_xyz".to_string(),
982            }))
983            .await
984            .unwrap();
985        assert!(
986            is_success(&result),
987            "expected success: {}",
988            result_text(&result)
989        );
990        assert!(
991            result_text(&result).contains("alpha/one"),
992            "search result did not include 'alpha/one': {}",
993            result_text(&result)
994        );
995    }
996
997    #[tokio::test]
998    async fn test_search_notes_returns_empty_for_no_match() {
999        let (handler, _dir) = make_handler().await;
1000        let result = handler
1001            .search_notes(Parameters(SearchNotesParams {
1002                query: "nonexistent_zzz_123".to_string(),
1003            }))
1004            .await
1005            .unwrap();
1006        assert!(is_success(&result));
1007    }
1008
1009    #[tokio::test]
1010    async fn test_list_notes_returns_all() {
1011        let (handler, _dir) = make_handler().await;
1012        handler
1013            .create_note(Parameters(CreateNoteParams {
1014                path: "folder/a".to_string(),
1015                content: "note a".to_string(),
1016            }))
1017            .await
1018            .unwrap();
1019        handler
1020            .create_note(Parameters(CreateNoteParams {
1021                path: "folder/b".to_string(),
1022                content: "note b".to_string(),
1023            }))
1024            .await
1025            .unwrap();
1026        let result = handler
1027            .list_notes(Parameters(ListNotesParams { path: None }))
1028            .await
1029            .unwrap();
1030        assert!(is_success(&result));
1031        let text = result_text(&result);
1032        assert!(text.contains("folder/a"), "missing 'folder/a': {}", text);
1033        assert!(text.contains("folder/b"), "missing 'folder/b': {}", text);
1034    }
1035
1036    #[tokio::test]
1037    async fn test_journal_appends_to_today() {
1038        let (handler, _dir) = make_handler().await;
1039        let result = handler
1040            .journal(Parameters(JournalParams {
1041                text: "Today's thought".to_string(),
1042                date: None,
1043            }))
1044            .await
1045            .unwrap();
1046        assert!(
1047            is_success(&result),
1048            "expected success: {}",
1049            result_text(&result)
1050        );
1051        assert!(
1052            result_text(&result).contains("saved"),
1053            "expected 'saved' in result: {}",
1054            result_text(&result)
1055        );
1056    }
1057
1058    #[tokio::test]
1059    async fn test_journal_with_explicit_date() {
1060        let (handler, _dir) = make_handler().await;
1061        let result = handler
1062            .journal(Parameters(JournalParams {
1063                text: "Entry for specific date".to_string(),
1064                date: Some("2026-01-15".to_string()),
1065            }))
1066            .await
1067            .unwrap();
1068        assert!(
1069            is_success(&result),
1070            "expected success: {}",
1071            result_text(&result)
1072        );
1073    }
1074
1075    #[tokio::test]
1076    async fn test_journal_invalid_date_returns_error() {
1077        let (handler, _dir) = make_handler().await;
1078        let result = handler
1079            .journal(Parameters(JournalParams {
1080                text: "bad date".to_string(),
1081                date: Some("not-a-date".to_string()),
1082            }))
1083            .await
1084            .unwrap();
1085        assert_eq!(
1086            result.is_error,
1087            Some(true),
1088            "expected error for invalid date"
1089        );
1090    }
1091
1092    #[tokio::test]
1093    async fn test_get_backlinks_empty_for_no_links() {
1094        let (handler, _dir) = make_handler().await;
1095        handler
1096            .create_note(Parameters(CreateNoteParams {
1097                path: "standalone".to_string(),
1098                content: "# Standalone\n\nNo links here.".to_string(),
1099            }))
1100            .await
1101            .unwrap();
1102        let result = handler
1103            .get_backlinks(Parameters(BacklinksParams {
1104                path: "standalone".to_string(),
1105            }))
1106            .await
1107            .unwrap();
1108        assert!(is_success(&result));
1109    }
1110
1111    #[tokio::test]
1112    async fn test_get_backlinks_finds_linking_note() {
1113        let (handler, _dir) = make_handler().await;
1114        handler
1115            .create_note(Parameters(CreateNoteParams {
1116                path: "target".to_string(),
1117                content: "# Target".to_string(),
1118            }))
1119            .await
1120            .unwrap();
1121        handler
1122            .create_note(Parameters(CreateNoteParams {
1123                path: "source".to_string(),
1124                content: "links to [[target]]".to_string(),
1125            }))
1126            .await
1127            .unwrap();
1128        let result = handler
1129            .get_backlinks(Parameters(BacklinksParams {
1130                path: "target".to_string(),
1131            }))
1132            .await
1133            .unwrap();
1134        assert!(is_success(&result));
1135        assert!(
1136            result_text(&result).contains("source"),
1137            "expected 'source' in backlinks: {}",
1138            result_text(&result)
1139        );
1140    }
1141
1142    #[tokio::test]
1143    async fn test_get_chunks_returns_sections() {
1144        let (handler, _dir) = make_handler().await;
1145        handler
1146            .create_note(Parameters(CreateNoteParams {
1147                path: "chunked".to_string(),
1148                content: "# Title\n\n## Section One\n\nparagraph\n\n## Section Two\n\nmore"
1149                    .to_string(),
1150            }))
1151            .await
1152            .unwrap();
1153        let result = handler
1154            .get_chunks(Parameters(ChunksParams {
1155                path: "chunked".to_string(),
1156            }))
1157            .await
1158            .unwrap();
1159        assert!(is_success(&result));
1160        assert!(
1161            result_text(&result).contains("Section"),
1162            "expected section in chunks: {}",
1163            result_text(&result)
1164        );
1165    }
1166
1167    #[tokio::test]
1168    async fn test_get_chunks_missing_note_returns_gracefully() {
1169        let (handler, _dir) = make_handler().await;
1170        // get_note_chunks on a missing note may return empty map or an error —
1171        // either way it should not panic.
1172        let result = handler
1173            .get_chunks(Parameters(ChunksParams {
1174                path: "missing/note".to_string(),
1175            }))
1176            .await;
1177        // Just verify it returned something without panicking
1178        let _ = result;
1179    }
1180
1181    // ---- Resource tests ----
1182    //
1183    // `list_resources` and `read_resource` require a `RequestContext<RoleServer>`,
1184    // which in turn requires a `Peer<R>` constructed via `Peer::new` — a
1185    // `pub(crate)` function not accessible outside rmcp.  There is no public
1186    // test constructor or `Default` impl, so these tests are marked `#[ignore]`
1187    // until rmcp exposes a test helper.  The implementations themselves are
1188    // correct and covered by the integration smoke test.
1189
1190    #[tokio::test]
1191    #[ignore = "RequestContext<RoleServer> cannot be constructed outside rmcp (Peer::new is pub(crate))"]
1192    async fn test_list_resources_returns_notes() {
1193        let (handler, _dir) = make_handler().await;
1194        handler
1195            .create_note(Parameters(CreateNoteParams {
1196                path: "res/alpha".to_string(),
1197                content: "# Alpha Note".to_string(),
1198            }))
1199            .await
1200            .unwrap();
1201        // Cannot call handler.list_resources(None, ctx) — ctx requires Peer which
1202        // is not constructable from outside rmcp.
1203        // The assertion below would be:
1204        //   assert!(result.resources.iter().any(|r| r.uri.contains("res/alpha")));
1205        unreachable!("test is ignored");
1206    }
1207
1208    #[tokio::test]
1209    #[ignore = "RequestContext<RoleServer> cannot be constructed outside rmcp (Peer::new is pub(crate))"]
1210    async fn test_read_resource_returns_content() {
1211        let (handler, _dir) = make_handler().await;
1212        handler
1213            .create_note(Parameters(CreateNoteParams {
1214                path: "res/beta".to_string(),
1215                content: "# Beta\n\nbeta content".to_string(),
1216            }))
1217            .await
1218            .unwrap();
1219        // Would call: handler.read_resource(ReadResourceRequestParams::new("note://res/beta.md"), ctx)
1220        // and assert content_json.contains("beta content")
1221        unreachable!("test is ignored");
1222    }
1223
1224    #[tokio::test]
1225    #[ignore = "RequestContext<RoleServer> cannot be constructed outside rmcp (Peer::new is pub(crate))"]
1226    async fn test_read_resource_not_found_returns_error() {
1227        let (handler, _dir) = make_handler().await;
1228        // Would call: handler.read_resource(ReadResourceRequestParams::new("note://missing/note.md"), ctx)
1229        // and assert result.is_err()
1230        let _ = &handler;
1231        unreachable!("test is ignored");
1232    }
1233
1234    #[tokio::test]
1235    #[ignore = "RequestContext<RoleServer> cannot be constructed outside rmcp (Peer::new is pub(crate))"]
1236    async fn test_read_resource_invalid_scheme_returns_error() {
1237        let (handler, _dir) = make_handler().await;
1238        // Would call: handler.read_resource(ReadResourceRequestParams::new("file:///etc/passwd"), ctx)
1239        // and assert result.is_err()
1240        let _ = &handler;
1241        unreachable!("test is ignored");
1242    }
1243
1244    #[tokio::test]
1245    async fn test_get_outlinks_returns_linked_notes() {
1246        let (handler, _dir) = make_handler().await;
1247        handler
1248            .create_note(Parameters(CreateNoteParams {
1249                path: "source".to_string(),
1250                content: "# Source\n\nSee [[target]] for more.".to_string(),
1251            }))
1252            .await
1253            .unwrap();
1254        handler
1255            .create_note(Parameters(CreateNoteParams {
1256                path: "target".to_string(),
1257                content: "# Target\n\nContent here.".to_string(),
1258            }))
1259            .await
1260            .unwrap();
1261        let result = handler
1262            .get_outlinks(Parameters(OutlinksParams {
1263                path: "source".to_string(),
1264            }))
1265            .await
1266            .unwrap();
1267        assert!(
1268            is_success(&result),
1269            "expected success: {}",
1270            result_text(&result)
1271        );
1272        assert!(
1273            result_text(&result).contains("target"),
1274            "expected 'target' in outlinks: {}",
1275            result_text(&result)
1276        );
1277    }
1278
1279    #[tokio::test]
1280    async fn test_get_outlinks_no_links_returns_empty_message() {
1281        let (handler, _dir) = make_handler().await;
1282        handler
1283            .create_note(Parameters(CreateNoteParams {
1284                path: "no-links".to_string(),
1285                content: "# No Links\n\nJust text, no wikilinks.".to_string(),
1286            }))
1287            .await
1288            .unwrap();
1289        let result = handler
1290            .get_outlinks(Parameters(OutlinksParams {
1291                path: "no-links".to_string(),
1292            }))
1293            .await
1294            .unwrap();
1295        assert!(is_success(&result));
1296        assert!(
1297            result_text(&result).contains("No outlinks found"),
1298            "expected empty message: {}",
1299            result_text(&result)
1300        );
1301    }
1302
1303    #[tokio::test]
1304    async fn test_get_outlinks_note_not_found_returns_error() {
1305        let (handler, _dir) = make_handler().await;
1306        let result = handler
1307            .get_outlinks(Parameters(OutlinksParams {
1308                path: "missing/note".to_string(),
1309            }))
1310            .await
1311            .unwrap();
1312        assert_eq!(result.is_error, Some(true));
1313    }
1314
1315    #[tokio::test]
1316    async fn test_rename_note_succeeds() {
1317        let (handler, _dir) = make_handler().await;
1318        handler
1319            .create_note(Parameters(CreateNoteParams {
1320                path: "old-name".to_string(),
1321                content: "# Old\n\nunique_rename_content_xyz".to_string(),
1322            }))
1323            .await
1324            .unwrap();
1325        let result = handler
1326            .rename_note(Parameters(RenameNoteParams {
1327                path: "old-name".to_string(),
1328                new_name: "new-name".to_string(),
1329            }))
1330            .await
1331            .unwrap();
1332        assert!(
1333            is_success(&result),
1334            "expected success: {}",
1335            result_text(&result)
1336        );
1337        let show = handler
1338            .show_note(Parameters(ShowNoteParams {
1339                path: "new-name".to_string(),
1340            }))
1341            .await
1342            .unwrap();
1343        assert!(is_success(&show), "new path should be readable");
1344        assert!(result_text(&show).contains("unique_rename_content_xyz"));
1345        let old = handler
1346            .show_note(Parameters(ShowNoteParams {
1347                path: "old-name".to_string(),
1348            }))
1349            .await
1350            .unwrap();
1351        assert_eq!(old.is_error, Some(true), "old path should be gone");
1352    }
1353
1354    #[tokio::test]
1355    async fn test_rename_note_rejects_slash_in_name() {
1356        let (handler, _dir) = make_handler().await;
1357        handler
1358            .create_note(Parameters(CreateNoteParams {
1359                path: "some/note".to_string(),
1360                content: "content".to_string(),
1361            }))
1362            .await
1363            .unwrap();
1364        let result = handler
1365            .rename_note(Parameters(RenameNoteParams {
1366                path: "some/note".to_string(),
1367                new_name: "other/dir".to_string(),
1368            }))
1369            .await
1370            .unwrap();
1371        assert_eq!(result.is_error, Some(true));
1372        assert!(
1373            result_text(&result).contains("move_note"),
1374            "hint should mention move_note: {}",
1375            result_text(&result)
1376        );
1377    }
1378
1379    #[tokio::test]
1380    async fn test_rename_note_updates_backlinks() {
1381        let (handler, _dir) = make_handler().await;
1382        handler
1383            .create_note(Parameters(CreateNoteParams {
1384                path: "target".to_string(),
1385                content: "# Target".to_string(),
1386            }))
1387            .await
1388            .unwrap();
1389        handler
1390            .create_note(Parameters(CreateNoteParams {
1391                path: "linker".to_string(),
1392                content: "see [[target]] for details".to_string(),
1393            }))
1394            .await
1395            .unwrap();
1396        handler
1397            .rename_note(Parameters(RenameNoteParams {
1398                path: "target".to_string(),
1399                new_name: "renamed-target".to_string(),
1400            }))
1401            .await
1402            .unwrap();
1403        let show = handler
1404            .show_note(Parameters(ShowNoteParams {
1405                path: "linker".to_string(),
1406            }))
1407            .await
1408            .unwrap();
1409        assert!(
1410            result_text(&show).contains("renamed-target"),
1411            "backlink should be updated: {}",
1412            result_text(&show)
1413        );
1414    }
1415
1416    #[tokio::test]
1417    async fn test_move_note_succeeds() {
1418        let (handler, _dir) = make_handler().await;
1419        handler
1420            .create_note(Parameters(CreateNoteParams {
1421                path: "original".to_string(),
1422                content: "# Original\n\nunique_move_content_xyz".to_string(),
1423            }))
1424            .await
1425            .unwrap();
1426        let result = handler
1427            .move_note(Parameters(MoveNoteParams {
1428                path: "original".to_string(),
1429                new_path: "folder/moved".to_string(),
1430            }))
1431            .await
1432            .unwrap();
1433        assert!(
1434            is_success(&result),
1435            "expected success: {}",
1436            result_text(&result)
1437        );
1438        let show = handler
1439            .show_note(Parameters(ShowNoteParams {
1440                path: "folder/moved".to_string(),
1441            }))
1442            .await
1443            .unwrap();
1444        assert!(is_success(&show));
1445        assert!(result_text(&show).contains("unique_move_content_xyz"));
1446        let old = handler
1447            .show_note(Parameters(ShowNoteParams {
1448                path: "original".to_string(),
1449            }))
1450            .await
1451            .unwrap();
1452        assert_eq!(old.is_error, Some(true), "old path should be gone");
1453    }
1454
1455    #[tokio::test]
1456    async fn test_move_note_fails_if_destination_exists() {
1457        let (handler, _dir) = make_handler().await;
1458        handler
1459            .create_note(Parameters(CreateNoteParams {
1460                path: "src".to_string(),
1461                content: "source".to_string(),
1462            }))
1463            .await
1464            .unwrap();
1465        handler
1466            .create_note(Parameters(CreateNoteParams {
1467                path: "dst".to_string(),
1468                content: "destination".to_string(),
1469            }))
1470            .await
1471            .unwrap();
1472        let result = handler
1473            .move_note(Parameters(MoveNoteParams {
1474                path: "src".to_string(),
1475                new_path: "dst".to_string(),
1476            }))
1477            .await
1478            .unwrap();
1479        assert_eq!(result.is_error, Some(true));
1480    }
1481
1482    #[tokio::test]
1483    async fn test_list_notes_filters_by_prefix() {
1484        let (handler, _dir) = make_handler().await;
1485        handler
1486            .create_note(Parameters(CreateNoteParams {
1487                path: "projects/foo".to_string(),
1488                content: "foo".to_string(),
1489            }))
1490            .await
1491            .unwrap();
1492        handler
1493            .create_note(Parameters(CreateNoteParams {
1494                path: "journal/2026-01-01".to_string(),
1495                content: "journal".to_string(),
1496            }))
1497            .await
1498            .unwrap();
1499        let result = handler
1500            .list_notes(Parameters(ListNotesParams {
1501                path: Some("projects".to_string()),
1502            }))
1503            .await
1504            .unwrap();
1505        assert!(is_success(&result));
1506        let text = result_text(&result);
1507        assert!(
1508            text.contains("projects/foo"),
1509            "missing projects/foo: {}",
1510            text
1511        );
1512        assert!(
1513            !text.contains("journal/2026"),
1514            "should not include journal: {}",
1515            text
1516        );
1517    }
1518}