Skip to main content

subcog/mcp/
resources.rs

1//! MCP resource handlers.
2//!
3//! Provides resource access for the Model Context Protocol.
4//! Resources are accessed via URN scheme.
5//!
6//! # URN Format Specification
7//!
8//! ```text
9//! subcog://{domain}/{resource-type}[/{resource-id}]
10//! ```
11//!
12//! ## Components
13//!
14//! | Component | Format | Description |
15//! |-----------|--------|-------------|
16//! | `domain` | `_` \| `project` \| `user` \| `org/{name}` | Scope for resolution |
17//! | `resource-type` | `help` \| `memory` \| `search` \| `topics` \| `namespaces` \| `summaries` | Type of resource |
18//! | `resource-id` | alphanumeric with `-`, `_` | Optional identifier |
19//!
20//! ## Domain Scopes
21//!
22//! | Domain | Description |
23//! |--------|-------------|
24//! | `_` | Wildcard - all domains combined |
25//! | `project` | Current project/repository (default) |
26//! | `user` | User-specific (e.g., `<user-data>/subcog/`) |
27//! | `org/{name}` | Organization namespace |
28//!
29//! # Resource Types
30//!
31//! ## Help Resources
32//! - `subcog://help` - Help index with all available topics
33//! - `subcog://help/{topic}` - Topic-specific help (setup, concepts, capture, recall, etc.)
34//!
35//! ## Memory Resources
36//! - `subcog://_` - All memories across all domains
37//! - `subcog://_/{namespace}` - All memories in a namespace (e.g., `subcog://_/learnings`)
38//! - `subcog://memory/{id}` - Get a specific memory by its unique ID
39//! - `subcog://project/decisions/{id}` - Fully-qualified memory URN
40//!
41//! ## Search & Topic Resources
42//! - `subcog://search/{query}` - Search memories with a query (URL-encoded)
43//! - `subcog://topics` - List all indexed topics with memory counts
44//! - `subcog://topics/{topic}` - Get memories for a specific topic
45//! - `subcog://namespaces` - List all namespaces with descriptions and signal words
46//!
47//! ## Summary Resources
48//! - `subcog://summaries` - List all consolidated memory summaries
49//! - `subcog://summaries/{id}` - Get a specific summary with its source memories
50//!
51//! ## Domain-Scoped Resources
52//! - `subcog://project/_` - Project-scoped memories only
53//! - `subcog://org/{org}/_` - Organization-scoped memories
54//! - `subcog://user/_` - User-scoped memories
55//!
56//! # Examples
57//!
58//! ```text
59//! subcog://help/capture          # Get capture help
60//! subcog://_/decisions           # All decisions across domains
61//! subcog://project/learnings     # Project learnings only
62//! subcog://memory/abc123         # Specific memory by ID
63//! subcog://search/postgres       # Search for "postgres"
64//! subcog://topics/authentication # Memories about authentication
65//! subcog://summaries             # List all consolidated summaries
66//! subcog://summaries/summary_123 # Get specific summary with sources
67//! ```
68//!
69//! For advanced filtering and discovery, use `subcog_recall` with the `filter`
70//! argument to refine by namespace, tags, time, source, and status.
71
72use super::help_content;
73use crate::Namespace;
74use crate::models::SearchMode;
75use crate::services::{PromptService, RecallService, TopicIndexService};
76use crate::storage::index::DomainScope;
77use crate::{Error, Result, SearchFilter};
78use serde::{Deserialize, Serialize};
79use std::collections::HashMap;
80
81/// Handler for MCP resources (URN scheme).
82pub struct ResourceHandler {
83    /// Help content by category.
84    help_content: HashMap<String, HelpCategory>,
85    /// Optional recall service for memory browsing.
86    recall_service: Option<RecallService>,
87    /// Topic index for topic-based resource access.
88    topic_index: Option<TopicIndexService>,
89    /// Optional prompt service for prompt resources.
90    prompt_service: Option<PromptService>,
91}
92
93impl ResourceHandler {
94    /// Creates a new resource handler.
95    #[must_use]
96    pub fn new() -> Self {
97        let mut help_content = HashMap::new();
98
99        // Setup category
100        help_content.insert(
101            "setup".to_string(),
102            HelpCategory {
103                name: "setup".to_string(),
104                title: "Getting Started with Subcog".to_string(),
105                description: "Installation and initial configuration guide".to_string(),
106                content: help_content::SETUP.to_string(),
107            },
108        );
109
110        // Concepts category
111        help_content.insert(
112            "concepts".to_string(),
113            HelpCategory {
114                name: "concepts".to_string(),
115                title: "Core Concepts".to_string(),
116                description: "Understanding namespaces, domains, URNs, and memory lifecycle"
117                    .to_string(),
118                content: help_content::CONCEPTS.to_string(),
119            },
120        );
121
122        // Capture category
123        help_content.insert(
124            "capture".to_string(),
125            HelpCategory {
126                name: "capture".to_string(),
127                title: "Capturing Memories".to_string(),
128                description: "How to capture and store memories effectively".to_string(),
129                content: help_content::CAPTURE.to_string(),
130            },
131        );
132
133        // Search category
134        help_content.insert(
135            "search".to_string(),
136            HelpCategory {
137                name: "search".to_string(),
138                title: "Searching Memories".to_string(),
139                description: "Using hybrid search to find relevant memories".to_string(),
140                content: help_content::SEARCH.to_string(),
141            },
142        );
143
144        // Workflows category
145        help_content.insert(
146            "workflows".to_string(),
147            HelpCategory {
148                name: "workflows".to_string(),
149                title: "Integration Workflows".to_string(),
150                description: "Hooks, MCP server, and IDE integration".to_string(),
151                content: help_content::WORKFLOWS.to_string(),
152            },
153        );
154
155        // Troubleshooting category
156        help_content.insert(
157            "troubleshooting".to_string(),
158            HelpCategory {
159                name: "troubleshooting".to_string(),
160                title: "Troubleshooting".to_string(),
161                description: "Common issues and solutions".to_string(),
162                content: help_content::TROUBLESHOOTING.to_string(),
163            },
164        );
165
166        // Advanced category
167        help_content.insert(
168            "advanced".to_string(),
169            HelpCategory {
170                name: "advanced".to_string(),
171                title: "Advanced Features".to_string(),
172                description: "LLM integration, consolidation, and optimization".to_string(),
173                content: help_content::ADVANCED.to_string(),
174            },
175        );
176
177        // Prompts category
178        help_content.insert(
179            "prompts".to_string(),
180            HelpCategory {
181                name: "prompts".to_string(),
182                title: "User-Defined Prompts".to_string(),
183                description: "Save, manage, and run prompt templates with variables".to_string(),
184                content: help_content::PROMPTS.to_string(),
185            },
186        );
187
188        Self {
189            help_content,
190            recall_service: None,
191            topic_index: None,
192            prompt_service: None,
193        }
194    }
195
196    /// Adds a prompt service to the resource handler.
197    #[must_use]
198    pub fn with_prompt_service(mut self, prompt_service: PromptService) -> Self {
199        self.prompt_service = Some(prompt_service);
200        self
201    }
202
203    /// Adds a recall service to the resource handler.
204    #[must_use]
205    pub fn with_recall_service(mut self, recall_service: RecallService) -> Self {
206        self.recall_service = Some(recall_service);
207        self
208    }
209
210    /// Adds a topic index to the resource handler.
211    #[must_use]
212    pub fn with_topic_index(mut self, topic_index: TopicIndexService) -> Self {
213        self.topic_index = Some(topic_index);
214        self
215    }
216
217    /// Builds and refreshes the topic index from the recall service.
218    ///
219    /// # Errors
220    ///
221    /// Returns an error if the topic index cannot be built.
222    pub fn refresh_topic_index(&mut self) -> Result<()> {
223        let recall = self.recall_service.as_ref().ok_or_else(|| {
224            Error::InvalidInput("Topic indexing requires RecallService".to_string())
225        })?;
226
227        let topic_index = self.topic_index.get_or_insert_with(TopicIndexService::new);
228        topic_index.build_index(recall)
229    }
230
231    /// Lists all available resources.
232    ///
233    /// Returns resources organized by type:
234    /// - Help topics
235    /// - Memory browsing patterns
236    ///
237    /// For advanced filtering, use `subcog_recall` with the `filter` argument.
238    #[must_use]
239    pub fn list_resources(&self) -> Vec<ResourceDefinition> {
240        let mut resources = Vec::new();
241        resources.extend(self.help_resource_definitions());
242        resources.extend(self.memory_resource_definitions());
243        resources.extend(Self::domain_resource_definitions());
244        resources.extend(Self::search_topic_resource_definitions());
245        resources.extend(Self::summary_resource_definitions());
246        resources.extend(Self::prompt_resource_definitions());
247        resources
248    }
249
250    fn build_resource(
251        uri: &str,
252        name: &str,
253        description: &str,
254        mime_type: &str,
255    ) -> ResourceDefinition {
256        ResourceDefinition {
257            uri: uri.to_string(),
258            name: name.to_string(),
259            description: Some(description.to_string()),
260            mime_type: Some(mime_type.to_string()),
261        }
262    }
263
264    fn help_resource_definitions(&self) -> Vec<ResourceDefinition> {
265        let mut resources = vec![Self::build_resource(
266            "subcog://help",
267            "Help Index",
268            "Help index with all available topics",
269            "text/markdown",
270        )];
271
272        resources.extend(self.help_content.values().map(|cat| ResourceDefinition {
273            uri: format!("subcog://help/{}", cat.name),
274            name: cat.title.clone(),
275            description: Some(cat.description.clone()),
276            mime_type: Some("text/markdown".to_string()),
277        }));
278
279        resources
280    }
281
282    fn memory_resource_definitions(&self) -> Vec<ResourceDefinition> {
283        let mut resources = vec![Self::build_resource(
284            "subcog://_",
285            "All Memories",
286            "All memories across all domains",
287            "application/json",
288        )];
289
290        for ns in Namespace::user_namespaces() {
291            let ns_str = ns.as_str();
292            resources.push(Self::build_resource(
293                &format!("subcog://_/{ns_str}"),
294                &format!("{ns_str} memories"),
295                &format!("All memories in {ns_str} namespace"),
296                "application/json",
297            ));
298        }
299
300        resources.push(Self::build_resource(
301            "subcog://memory/{id}",
302            "Memory by ID",
303            "Fetch a specific memory by ID",
304            "application/json",
305        ));
306
307        resources
308    }
309
310    fn domain_resource_definitions() -> Vec<ResourceDefinition> {
311        let items = [
312            (
313                "subcog://project",
314                "Project Memories",
315                "Project-scoped memories",
316            ),
317            (
318                "subcog://project/_",
319                "Project Memories (All Namespaces)",
320                "Project memories, all namespaces",
321            ),
322            (
323                "subcog://project/{namespace}",
324                "Project Namespace",
325                "Project memories by namespace",
326            ),
327            (
328                "subcog://project/{namespace}/{id}",
329                "Project Memory",
330                "Fetch a project memory by ID",
331            ),
332            ("subcog://user", "User Memories", "User-scoped memories"),
333            (
334                "subcog://user/_",
335                "User Memories (All Namespaces)",
336                "User memories, all namespaces",
337            ),
338            (
339                "subcog://user/{namespace}",
340                "User Namespace",
341                "User memories by namespace",
342            ),
343            (
344                "subcog://user/{namespace}/{id}",
345                "User Memory",
346                "Fetch a user memory by ID",
347            ),
348            (
349                "subcog://org",
350                "Org Memories",
351                "Organization-scoped memories",
352            ),
353            (
354                "subcog://org/_",
355                "Org Memories (All Namespaces)",
356                "Org memories, all namespaces",
357            ),
358            (
359                "subcog://org/{namespace}",
360                "Org Namespace",
361                "Org memories by namespace",
362            ),
363            (
364                "subcog://org/{namespace}/{id}",
365                "Org Memory",
366                "Fetch an org memory by ID",
367            ),
368        ];
369
370        items
371            .iter()
372            .map(|(uri, name, desc)| Self::build_resource(uri, name, desc, "application/json"))
373            .collect()
374    }
375
376    fn search_topic_resource_definitions() -> Vec<ResourceDefinition> {
377        let mut resources = vec![
378            Self::build_resource(
379                "subcog://search/{query}",
380                "Search Memories",
381                "Search memories with a query (replace {query})",
382                "application/json",
383            ),
384            Self::build_resource(
385                "subcog://topics",
386                "All Topics",
387                "List all indexed topics with memory counts",
388                "application/json",
389            ),
390            Self::build_resource(
391                "subcog://topics/{topic}",
392                "Topic Memories",
393                "Get memories for a specific topic (replace {topic})",
394                "application/json",
395            ),
396        ];
397
398        resources.push(Self::build_resource(
399            "subcog://namespaces",
400            "All Namespaces",
401            "List all memory namespaces with descriptions and signal words",
402            "application/json",
403        ));
404
405        resources
406    }
407
408    fn summary_resource_definitions() -> Vec<ResourceDefinition> {
409        vec![
410            Self::build_resource(
411                "subcog://summaries",
412                "All Summaries",
413                "List all consolidated memory summaries",
414                "application/json",
415            ),
416            Self::build_resource(
417                "subcog://summaries/{id}",
418                "Summary by ID",
419                "Get a specific summary node with its source memories (replace {id})",
420                "application/json",
421            ),
422        ]
423    }
424
425    fn prompt_resource_definitions() -> Vec<ResourceDefinition> {
426        let items = [
427            (
428                "subcog://_prompts",
429                "All Prompts",
430                "Aggregate prompts from all domains (project, user, org)",
431            ),
432            (
433                "subcog://project/_prompts",
434                "Project Prompts",
435                "List all prompts in the project scope",
436            ),
437            (
438                "subcog://user/_prompts",
439                "User Prompts",
440                "List all prompts in the user scope",
441            ),
442            (
443                "subcog://project/_prompts/{name}",
444                "Project Prompt",
445                "Get a specific prompt by name from project scope",
446            ),
447            (
448                "subcog://user/_prompts/{name}",
449                "User Prompt",
450                "Get a specific prompt by name from user scope",
451            ),
452            (
453                "subcog://org/_prompts",
454                "Org Prompts",
455                "List all prompts in the org scope",
456            ),
457            (
458                "subcog://org/_prompts/{name}",
459                "Org Prompt",
460                "Get a specific prompt by name from org scope",
461            ),
462        ];
463
464        items
465            .iter()
466            .map(|(uri, name, desc)| Self::build_resource(uri, name, desc, "application/json"))
467            .collect()
468    }
469
470    /// Gets a resource by URI.
471    ///
472    /// Supported URI patterns:
473    /// - `subcog://help` - Help index
474    /// - `subcog://help/{topic}` - Help topic
475    /// - `subcog://_` - All memories across all domains
476    /// - `subcog://_/{namespace}` - All memories in a namespace
477    /// - `subcog://memory/{id}` - Get specific memory by ID
478    /// - `subcog://project/_` - Project-scoped memories (alias for `subcog://_`)
479    /// - `subcog://search/{query}` - Search memories with a query
480    /// - `subcog://topics` - List all indexed topics
481    /// - `subcog://topics/{topic}` - Get memories for a specific topic
482    /// - `subcog://summaries` - List all consolidated summaries
483    /// - `subcog://summaries/{id}` - Get a specific summary with source memories
484    ///
485    /// For advanced filtering, use `subcog_recall` with the `filter` argument.
486    ///
487    /// # Errors
488    ///
489    /// Returns an error if the resource is not found.
490    pub fn get_resource(&mut self, uri: &str) -> Result<ResourceContent> {
491        let uri = uri.trim();
492
493        if !uri.starts_with("subcog://") {
494            return Err(Error::InvalidInput(format!("Invalid URI scheme: {uri}")));
495        }
496
497        let path = &uri["subcog://".len()..];
498        let parts: Vec<&str> = path.split('/').collect();
499
500        if parts.is_empty() {
501            return Err(Error::InvalidInput("Empty resource path".to_string()));
502        }
503
504        match parts[0] {
505            "help" => self.get_help_resource(uri, &parts),
506            "_" => self.get_all_memories_resource(uri, &parts),
507            "project" => self.get_domain_scoped_resource(uri, &parts, DomainScope::Project),
508            "user" => self.get_domain_scoped_resource(uri, &parts, DomainScope::User),
509            "org" => self.get_domain_scoped_resource(uri, &parts, DomainScope::Org),
510            "memory" => self.get_memory_resource(uri, &parts),
511            "search" => self.get_search_resource(uri, &parts),
512            "topics" => self.get_topics_resource(uri, &parts),
513            "namespaces" => self.get_namespaces_resource(uri),
514            "summaries" => self.get_summaries_resource(uri, &parts),
515            "_prompts" => self.get_aggregate_prompts_resource(uri),
516            _ => Err(Error::InvalidInput(format!(
517                "Unknown resource type: {}. Valid: _, help, memory, project, user, org, search, topics, namespaces, summaries, _prompts",
518                parts[0]
519            ))),
520        }
521    }
522
523    /// Gets a help resource.
524    fn get_help_resource(&self, uri: &str, parts: &[&str]) -> Result<ResourceContent> {
525        if parts.len() == 1 {
526            // Return help index
527            return Ok(ResourceContent {
528                uri: uri.to_string(),
529                mime_type: Some("text/markdown".to_string()),
530                text: Some(self.get_help_index()),
531                blob: None,
532            });
533        }
534
535        let category = parts[1];
536        let content = self
537            .help_content
538            .get(category)
539            .ok_or_else(|| Error::InvalidInput(format!("Unknown help category: {category}")))?;
540
541        Ok(ResourceContent {
542            uri: uri.to_string(),
543            mime_type: Some("text/markdown".to_string()),
544            text: Some(format!("# {}\n\n{}", content.title, content.content)),
545            blob: None,
546        })
547    }
548
549    /// Gets all memories resource with optional namespace filter.
550    ///
551    /// URI patterns:
552    /// - `subcog://_` - All memories across all domains
553    /// - `subcog://_/{namespace}` - All memories in a namespace
554    /// - `subcog://project/_` - Alias for `subcog://_` (project-scoped, future domain filter)
555    ///
556    /// For advanced filtering, use `subcog_recall` with the `filter` argument.
557    fn get_all_memories_resource(&self, uri: &str, parts: &[&str]) -> Result<ResourceContent> {
558        // Parse namespace filter from URI
559        // subcog://_ -> no filter
560        // subcog://_/learnings -> filter by namespace
561        // subcog://project/_ -> no filter (legacy)
562        let namespace_filter = if parts[0] == "_" && parts.len() >= 2 {
563            Some(parts[1])
564        } else {
565            None
566        };
567
568        // Build filter
569        let mut filter = SearchFilter::new();
570        if let Some(ns_str) = namespace_filter {
571            let ns = Namespace::parse(ns_str)
572                .ok_or_else(|| Error::InvalidInput(format!("Unknown namespace: {ns_str}")))?;
573            filter = filter.with_namespace(ns);
574        }
575        self.list_memories(uri, &filter)
576    }
577
578    /// Gets a specific memory by ID with full content (cross-domain lookup).
579    ///
580    /// This is the targeted fetch endpoint - returns complete memory data.
581    /// Use `subcog://memory/{id}` for cross-domain lookups when ID is known.
582    fn get_memory_resource(&self, uri: &str, parts: &[&str]) -> Result<ResourceContent> {
583        use crate::models::MemoryId;
584
585        if parts.len() < 2 {
586            return Err(Error::InvalidInput(
587                "Memory ID required: subcog://memory/{id}".to_string(),
588            ));
589        }
590
591        let memory_id = parts[1];
592        let recall = self.recall_service.as_ref().ok_or_else(|| {
593            Error::InvalidInput("Memory browsing requires RecallService".to_string())
594        })?;
595
596        // Direct fetch by ID - returns full content
597        let memory = recall
598            .get_by_id(&MemoryId::new(memory_id))?
599            .ok_or_else(|| Error::InvalidInput(format!("Memory not found: {memory_id}")))?;
600
601        self.format_memory_response(uri, &memory)
602    }
603
604    /// Gets memories scoped to a namespace.
605    fn get_namespace_memories_resource(
606        &self,
607        uri: &str,
608        namespace: &str,
609    ) -> Result<ResourceContent> {
610        let ns = Namespace::parse(namespace)
611            .ok_or_else(|| Error::InvalidInput(format!("Unknown namespace: {namespace}")))?;
612        let filter = SearchFilter::new().with_namespace(ns);
613        self.list_memories(uri, &filter)
614    }
615
616    /// Gets a specific memory by ID with namespace validation.
617    fn get_scoped_memory_resource(
618        &self,
619        uri: &str,
620        namespace: &str,
621        memory_id: &str,
622    ) -> Result<ResourceContent> {
623        use crate::models::MemoryId;
624
625        let recall = self.recall_service.as_ref().ok_or_else(|| {
626            Error::InvalidInput("Memory browsing requires RecallService".to_string())
627        })?;
628
629        let memory = recall
630            .get_by_id(&MemoryId::new(memory_id))?
631            .ok_or_else(|| Error::InvalidInput(format!("Memory not found: {memory_id}")))?;
632
633        if memory.namespace.as_str() != namespace {
634            return Err(Error::InvalidInput(format!(
635                "Memory {memory_id} is in namespace {}, not {namespace}",
636                memory.namespace.as_str()
637            )));
638        }
639
640        self.format_memory_response(uri, &memory)
641    }
642
643    /// Formats a memory as a JSON response.
644    fn format_memory_response(
645        &self,
646        uri: &str,
647        memory: &crate::models::Memory,
648    ) -> Result<ResourceContent> {
649        let response = serde_json::json!({
650            "id": memory.id.as_str(),
651            "namespace": memory.namespace.as_str(),
652            "domain": memory.domain.to_string(),
653            "content": memory.content,
654            "tags": memory.tags,
655            "source": memory.source,
656            "status": memory.status.as_str(),
657            "created_at": memory.created_at,
658            "updated_at": memory.updated_at,
659        });
660
661        Ok(ResourceContent {
662            uri: uri.to_string(),
663            mime_type: Some("application/json".to_string()),
664            text: Some(serde_json::to_string_pretty(&response).unwrap_or_default()),
665            blob: None,
666        })
667    }
668
669    fn list_memories(&self, uri: &str, filter: &SearchFilter) -> Result<ResourceContent> {
670        let recall = self.recall_service.as_ref().ok_or_else(|| {
671            Error::InvalidInput("Memory browsing requires RecallService".to_string())
672        })?;
673
674        let results = recall.list_all(filter, 500)?;
675
676        // Bare minimum for informed selection: id, ns, tags, uri
677        let memories: Vec<serde_json::Value> = results
678            .memories
679            .iter()
680            .map(|hit| {
681                serde_json::json!({
682                    "id": hit.memory.id.as_str(),
683                    "ns": hit.memory.namespace.as_str(),
684                    "tags": hit.memory.tags,
685                    "uri": format!("subcog://memory/{}", hit.memory.id.as_str()),
686                })
687            })
688            .collect();
689
690        let response = serde_json::json!({
691            "count": memories.len(),
692            "memories": memories,
693        });
694
695        Ok(ResourceContent {
696            uri: uri.to_string(),
697            mime_type: Some("application/json".to_string()),
698            text: Some(serde_json::to_string_pretty(&response).unwrap_or_default()),
699            blob: None,
700        })
701    }
702
703    /// Searches memories and returns results.
704    ///
705    /// URI: `subcog://search/{query}`
706    fn get_search_resource(&self, uri: &str, parts: &[&str]) -> Result<ResourceContent> {
707        if parts.len() < 2 {
708            return Err(Error::InvalidInput(
709                "Search query required: subcog://search/<query>".to_string(),
710            ));
711        }
712
713        let recall = self
714            .recall_service
715            .as_ref()
716            .ok_or_else(|| Error::InvalidInput("Search requires RecallService".to_string()))?;
717
718        // URL-decode the query (simple: replace + with space, handle %20)
719        let query = parts[1..].join("/");
720        let query = decode_uri_component(&query);
721
722        // Perform search with hybrid mode
723        let filter = SearchFilter::new();
724        let results = recall.search(&query, SearchMode::Hybrid, &filter, 20)?;
725
726        // Build response
727        let memories: Vec<serde_json::Value> = results
728            .memories
729            .iter()
730            .map(|hit| {
731                serde_json::json!({
732                    "id": hit.memory.id.as_str(),
733                    "namespace": hit.memory.namespace.as_str(),
734                    "score": hit.score,
735                    "tags": hit.memory.tags,
736                    "content_preview": truncate_content(&hit.memory.content, 200),
737                    "uri": format!("subcog://memory/{}", hit.memory.id.as_str()),
738                })
739            })
740            .collect();
741
742        let response = serde_json::json!({
743            "query": query,
744            "count": memories.len(),
745            "mode": "hybrid",
746            "memories": memories,
747        });
748
749        Ok(ResourceContent {
750            uri: uri.to_string(),
751            mime_type: Some("application/json".to_string()),
752            text: Some(serde_json::to_string_pretty(&response).unwrap_or_default()),
753            blob: None,
754        })
755    }
756
757    /// Gets topics resource (list or specific topic).
758    ///
759    /// URIs:
760    /// - `subcog://topics` - List all topics
761    /// - `subcog://topics/{topic}` - Get memories for a topic
762    fn get_topics_resource(&self, uri: &str, parts: &[&str]) -> Result<ResourceContent> {
763        let topic_index = self
764            .topic_index
765            .as_ref()
766            .ok_or_else(|| Error::InvalidInput("Topic index not initialized".to_string()))?;
767
768        if parts.len() == 1 {
769            // List all topics
770            let topics = topic_index.list_topics()?;
771
772            let topics_json: Vec<serde_json::Value> = topics
773                .iter()
774                .map(|t| {
775                    serde_json::json!({
776                        "name": t.name,
777                        "memory_count": t.memory_count,
778                        "namespaces": t.namespaces.iter().map(Namespace::as_str).collect::<Vec<_>>(),
779                        "uri": format!("subcog://topics/{}", t.name),
780                    })
781                })
782                .collect();
783
784            let response = serde_json::json!({
785                "count": topics_json.len(),
786                "topics": topics_json,
787            });
788
789            Ok(ResourceContent {
790                uri: uri.to_string(),
791                mime_type: Some("application/json".to_string()),
792                text: Some(serde_json::to_string_pretty(&response).unwrap_or_default()),
793                blob: None,
794            })
795        } else {
796            // Get memories for specific topic
797            let topic = parts[1..].join("/");
798            let topic = decode_uri_component(&topic);
799
800            let memory_ids = topic_index.get_topic_memories(&topic)?;
801
802            if memory_ids.is_empty() {
803                return Err(Error::InvalidInput(format!("Topic not found: {topic}")));
804            }
805
806            // Get topic info
807            let topic_info = topic_index.get_topic_info(&topic)?;
808
809            // Fetch full memories if recall service available
810            let memories: Vec<serde_json::Value> = if let Some(recall) = &self.recall_service {
811                memory_ids
812                    .iter()
813                    .filter_map(|id| recall.get_by_id(id).ok().flatten())
814                    .map(|m| format_memory_preview(&m))
815                    .collect()
816            } else {
817                // Return just IDs if no recall service
818                memory_ids.iter().map(format_memory_id_only).collect()
819            };
820
821            let response = serde_json::json!({
822                "topic": topic,
823                "memory_count": topic_info.as_ref().map_or(memory_ids.len(), |i| i.memory_count),
824                "namespaces": topic_info.as_ref().map_or_else(Vec::new, |i| {
825                    i.namespaces.iter().map(Namespace::as_str).collect::<Vec<_>>()
826                }),
827                "memories": memories,
828            });
829
830            Ok(ResourceContent {
831                uri: uri.to_string(),
832                mime_type: Some("application/json".to_string()),
833                text: Some(serde_json::to_string_pretty(&response).unwrap_or_default()),
834                blob: None,
835            })
836        }
837    }
838
839    /// Gets namespaces resource listing all available namespaces.
840    ///
841    /// URI: `subcog://namespaces`
842    ///
843    /// Returns namespace definitions with descriptions and signal words.
844    fn get_namespaces_resource(&self, uri: &str) -> Result<ResourceContent> {
845        use crate::cli::get_all_namespaces;
846
847        let namespaces = get_all_namespaces();
848
849        let namespaces_json: Vec<serde_json::Value> = namespaces
850            .iter()
851            .map(|ns| {
852                serde_json::json!({
853                    "namespace": ns.namespace,
854                    "description": ns.description,
855                    "signal_words": ns.signal_words,
856                })
857            })
858            .collect();
859
860        let response = serde_json::json!({
861            "count": namespaces_json.len(),
862            "namespaces": namespaces_json,
863        });
864
865        Ok(ResourceContent {
866            uri: uri.to_string(),
867            mime_type: Some("application/json".to_string()),
868            text: Some(serde_json::to_string_pretty(&response).unwrap_or_default()),
869            blob: None,
870        })
871    }
872
873    /// Gets summaries resource (list or specific summary).
874    ///
875    /// URIs:
876    /// - `subcog://summaries` - List all summary nodes
877    /// - `subcog://summaries/{id}` - Get a specific summary with source memories
878    fn get_summaries_resource(&self, uri: &str, parts: &[&str]) -> Result<ResourceContent> {
879        let recall = self.recall_service.as_ref().ok_or_else(|| {
880            Error::InvalidInput("Summary browsing requires RecallService".to_string())
881        })?;
882
883        if parts.len() == 1 {
884            // List all summaries
885            let filter = SearchFilter::new();
886            let results = recall.list_all(&filter, 1000)?;
887
888            // Filter to only summary nodes
889            let summaries: Vec<serde_json::Value> = results
890                .memories
891                .iter()
892                .filter(|hit| hit.memory.is_summary)
893                .map(|hit| {
894                    serde_json::json!({
895                        "id": hit.memory.id.as_str(),
896                        "namespace": hit.memory.namespace.as_str(),
897                        "tags": hit.memory.tags,
898                        "content_preview": truncate_content(&hit.memory.content, 200),
899                        "source_count": hit.memory.source_memory_ids.as_ref().map_or(0, Vec::len),
900                        "consolidation_timestamp": hit.memory.consolidation_timestamp,
901                        "uri": format!("subcog://summaries/{}", hit.memory.id.as_str()),
902                    })
903                })
904                .collect();
905
906            let response = serde_json::json!({
907                "count": summaries.len(),
908                "summaries": summaries,
909            });
910
911            Ok(ResourceContent {
912                uri: uri.to_string(),
913                mime_type: Some("application/json".to_string()),
914                text: Some(serde_json::to_string_pretty(&response).unwrap_or_default()),
915                blob: None,
916            })
917        } else {
918            // Get specific summary by ID
919            let summary_id = parts[1..].join("/");
920            let summary_id = decode_uri_component(&summary_id);
921
922            self.get_specific_summary_resource(uri, recall, &summary_id)
923        }
924    }
925
926    /// Gets a specific summary node with its source memories.
927    ///
928    /// Returns the summary content and linked source memories.
929    fn get_specific_summary_resource(
930        &self,
931        uri: &str,
932        recall: &RecallService,
933        summary_id: &str,
934    ) -> Result<ResourceContent> {
935        use crate::models::MemoryId;
936
937        // Get the summary memory
938        let summary = recall
939            .get_by_id(&MemoryId::new(summary_id))?
940            .ok_or_else(|| Error::InvalidInput(format!("Summary not found: {summary_id}")))?;
941
942        // Verify it's actually a summary
943        if !summary.is_summary {
944            return Err(Error::InvalidInput(format!(
945                "Memory {summary_id} is not a summary node"
946            )));
947        }
948
949        // Get source memory IDs
950        let source_ids = summary
951            .source_memory_ids
952            .as_ref()
953            .map_or_else(Vec::new, Clone::clone);
954
955        // Fetch source memories
956        let source_memories: Vec<serde_json::Value> = source_ids
957            .iter()
958            .filter_map(|id| recall.get_by_id(id).ok().flatten())
959            .map(|m| {
960                serde_json::json!({
961                    "id": m.id.as_str(),
962                    "namespace": m.namespace.as_str(),
963                    "tags": m.tags,
964                    "content_preview": truncate_content(&m.content, 150),
965                    "uri": format!("subcog://memory/{}", m.id.as_str()),
966                })
967            })
968            .collect();
969
970        let response = serde_json::json!({
971            "id": summary.id.as_str(),
972            "namespace": summary.namespace.as_str(),
973            "domain": summary.domain.to_string(),
974            "content": summary.content,
975            "tags": summary.tags,
976            "consolidation_timestamp": summary.consolidation_timestamp,
977            "source_count": source_ids.len(),
978            "source_memories": source_memories,
979            "created_at": summary.created_at,
980            "updated_at": summary.updated_at,
981        });
982
983        Ok(ResourceContent {
984            uri: uri.to_string(),
985            mime_type: Some("application/json".to_string()),
986            text: Some(serde_json::to_string_pretty(&response).unwrap_or_default()),
987            blob: None,
988        })
989    }
990
991    /// Gets aggregate prompts resource listing prompts from all domains.
992    ///
993    /// URI: `subcog://_prompts`
994    ///
995    /// Returns prompts aggregated from project, user, and org domains.
996    /// Prompts are deduplicated by name, with project scope taking priority.
997    fn get_aggregate_prompts_resource(&mut self, uri: &str) -> Result<ResourceContent> {
998        use crate::services::PromptFilter;
999        use std::collections::HashSet;
1000
1001        let prompt_service = self.prompt_service.as_mut().ok_or_else(|| {
1002            Error::InvalidInput("Prompt browsing requires PromptService".to_string())
1003        })?;
1004
1005        // Collect prompts from all domains, deduplicating by name
1006        // Priority: project > user > org (first seen wins)
1007        let mut seen_names: HashSet<String> = HashSet::new();
1008        let mut prompts_json: Vec<serde_json::Value> = Vec::new();
1009
1010        let domains = [
1011            (DomainScope::Project, "project"),
1012            (DomainScope::User, "user"),
1013            (DomainScope::Org, "org"),
1014        ];
1015
1016        for (domain, domain_name) in domains {
1017            let filter = PromptFilter::new().with_domain(domain);
1018            let prompts = prompt_service.list(&filter).unwrap_or_default();
1019            Self::collect_unique_prompts(&mut seen_names, &mut prompts_json, prompts, domain_name);
1020        }
1021
1022        let response = serde_json::json!({
1023            "count": prompts_json.len(),
1024            "prompts": prompts_json,
1025        });
1026
1027        Ok(ResourceContent {
1028            uri: uri.to_string(),
1029            mime_type: Some("application/json".to_string()),
1030            text: Some(serde_json::to_string_pretty(&response).unwrap_or_default()),
1031            blob: None,
1032        })
1033    }
1034
1035    /// Collects unique prompts, skipping those already seen.
1036    fn collect_unique_prompts(
1037        seen: &mut std::collections::HashSet<String>,
1038        output: &mut Vec<serde_json::Value>,
1039        prompts: Vec<crate::models::PromptTemplate>,
1040        domain_name: &str,
1041    ) {
1042        for p in prompts {
1043            if seen.contains(&p.name) {
1044                continue;
1045            }
1046            seen.insert(p.name.clone());
1047            output.push(serde_json::json!({
1048                "name": p.name,
1049                "description": p.description,
1050                "domain": domain_name,
1051                "tags": p.tags,
1052                "usage_count": p.usage_count,
1053                "variables": p.variables.iter().map(|v| v.name.clone()).collect::<Vec<_>>(),
1054            }));
1055        }
1056    }
1057
1058    /// Gets domain-scoped resources (prompts or memories).
1059    ///
1060    /// URIs:
1061    /// - `subcog://{domain}/_prompts` - List prompts in domain
1062    /// - `subcog://{domain}/_prompts/{name}` - Get specific prompt by name
1063    /// - `subcog://{domain}/_` - List memories in domain (alias)
1064    fn get_domain_scoped_resource(
1065        &mut self,
1066        uri: &str,
1067        parts: &[&str],
1068        domain: DomainScope,
1069    ) -> Result<ResourceContent> {
1070        // Check if requesting prompts
1071        if parts.len() >= 2 && parts[1] == "_prompts" {
1072            // Check for specific prompt name
1073            if parts.len() >= 3 {
1074                let name = parts[2..].join("/");
1075                let name = decode_uri_component(&name);
1076                return self.get_single_prompt_resource(uri, domain, &name);
1077            }
1078            return self.get_prompts_resource(uri, domain);
1079        }
1080
1081        if parts.len() == 1 {
1082            return self.get_all_memories_resource(uri, parts);
1083        }
1084
1085        let namespace = decode_uri_component(parts[1]);
1086        if namespace == "_" {
1087            return self.get_all_memories_resource(uri, parts);
1088        }
1089
1090        if parts.len() >= 3 {
1091            let memory_id = decode_uri_component(&parts[2..].join("/"));
1092            return self.get_scoped_memory_resource(uri, &namespace, &memory_id);
1093        }
1094
1095        self.get_namespace_memories_resource(uri, &namespace)
1096    }
1097
1098    /// Gets prompts for a specific domain scope.
1099    fn get_prompts_resource(&mut self, uri: &str, domain: DomainScope) -> Result<ResourceContent> {
1100        use crate::services::PromptFilter;
1101
1102        let prompt_service = self.prompt_service.as_mut().ok_or_else(|| {
1103            Error::InvalidInput("Prompt browsing requires PromptService".to_string())
1104        })?;
1105
1106        // Build filter for the specific domain
1107        let filter = PromptFilter::new().with_domain(domain);
1108        let prompts = prompt_service.list(&filter)?;
1109
1110        let prompts_json: Vec<serde_json::Value> = prompts
1111            .iter()
1112            .map(|p| {
1113                serde_json::json!({
1114                    "name": p.name,
1115                    "description": p.description,
1116                    "tags": p.tags,
1117                    "usage_count": p.usage_count,
1118                    "variables": p.variables.iter().map(|v| v.name.clone()).collect::<Vec<_>>(),
1119                })
1120            })
1121            .collect();
1122
1123        let response = serde_json::json!({
1124            "domain": domain.as_str(),
1125            "count": prompts_json.len(),
1126            "prompts": prompts_json,
1127        });
1128
1129        Ok(ResourceContent {
1130            uri: uri.to_string(),
1131            mime_type: Some("application/json".to_string()),
1132            text: Some(serde_json::to_string_pretty(&response).unwrap_or_default()),
1133            blob: None,
1134        })
1135    }
1136
1137    /// Gets a specific prompt by name from a domain scope.
1138    fn get_single_prompt_resource(
1139        &mut self,
1140        uri: &str,
1141        domain: DomainScope,
1142        name: &str,
1143    ) -> Result<ResourceContent> {
1144        let prompt_service = self.prompt_service.as_mut().ok_or_else(|| {
1145            Error::InvalidInput("Prompt browsing requires PromptService".to_string())
1146        })?;
1147
1148        // Get the prompt from the specific domain
1149        let prompt = prompt_service.get(name, Some(domain))?.ok_or_else(|| {
1150            Error::InvalidInput(format!(
1151                "Prompt not found: {} in {} scope",
1152                name,
1153                domain.as_str()
1154            ))
1155        })?;
1156
1157        // Build full response with all prompt details
1158        let variables_json: Vec<serde_json::Value> = prompt
1159            .variables
1160            .iter()
1161            .map(|v| {
1162                serde_json::json!({
1163                    "name": v.name,
1164                    "description": v.description,
1165                    "required": v.required,
1166                    "default": v.default,
1167                })
1168            })
1169            .collect();
1170
1171        let response = serde_json::json!({
1172            "name": prompt.name,
1173            "description": prompt.description,
1174            "content": prompt.content,
1175            "domain": domain.as_str(),
1176            "tags": prompt.tags,
1177            "usage_count": prompt.usage_count,
1178            "variables": variables_json,
1179            "created_at": prompt.created_at,
1180            "updated_at": prompt.updated_at,
1181        });
1182
1183        Ok(ResourceContent {
1184            uri: uri.to_string(),
1185            mime_type: Some("application/json".to_string()),
1186            text: Some(serde_json::to_string_pretty(&response).unwrap_or_default()),
1187            blob: None,
1188        })
1189    }
1190
1191    /// Gets the help index listing all categories.
1192    fn get_help_index(&self) -> String {
1193        let mut index = "# Subcog Help\n\nWelcome to Subcog, the persistent memory system for AI coding assistants.\n\n## Available Topics\n\n".to_string();
1194
1195        for cat in self.help_content.values() {
1196            index.push_str(&format!(
1197                "- **[{}](subcog://help/{})**: {}\n",
1198                cat.title, cat.name, cat.description
1199            ));
1200        }
1201
1202        index.push_str("\n## Quick Start (MCP Tools)\n\n");
1203        index
1204            .push_str("1. **Capture**: Use `subcog_capture` tool with `namespace` and `content`\n");
1205        index.push_str("2. **Search**: Use `subcog_recall` tool with `query` parameter\n");
1206        index.push_str("3. **Status**: Use `subcog_status` tool\n");
1207        index.push_str(
1208            "4. **Browse**: Use `subcog prompt run subcog_browse` or `subcog://project/_` resource\n",
1209        );
1210
1211        index
1212    }
1213
1214    /// Gets a list of help categories.
1215    #[must_use]
1216    pub fn list_categories(&self) -> Vec<&HelpCategory> {
1217        self.help_content.values().collect()
1218    }
1219}
1220
1221impl Default for ResourceHandler {
1222    fn default() -> Self {
1223        Self::new()
1224    }
1225}
1226
1227/// Definition of an MCP resource.
1228#[derive(Debug, Clone, Serialize, Deserialize)]
1229pub struct ResourceDefinition {
1230    /// Resource URI.
1231    pub uri: String,
1232    /// Human-readable name.
1233    pub name: String,
1234    /// Optional description.
1235    pub description: Option<String>,
1236    /// MIME type of the resource.
1237    pub mime_type: Option<String>,
1238}
1239
1240/// Content of an MCP resource.
1241#[derive(Debug, Clone, Serialize, Deserialize)]
1242pub struct ResourceContent {
1243    /// Resource URI.
1244    pub uri: String,
1245    /// MIME type.
1246    pub mime_type: Option<String>,
1247    /// Text content (for text resources).
1248    pub text: Option<String>,
1249    /// Binary content as base64 (for binary resources).
1250    pub blob: Option<String>,
1251}
1252
1253/// Help category definition.
1254#[derive(Debug, Clone)]
1255pub struct HelpCategory {
1256    /// Category identifier.
1257    pub name: String,
1258    /// Human-readable title.
1259    pub title: String,
1260    /// Short description.
1261    pub description: String,
1262    /// Full content in Markdown.
1263    pub content: String,
1264}
1265
1266/// Formats a memory as a JSON preview for topic listings.
1267fn format_memory_preview(m: &crate::models::Memory) -> serde_json::Value {
1268    serde_json::json!({
1269        "id": m.id.as_str(),
1270        "namespace": m.namespace.as_str(),
1271        "content_preview": truncate_content(&m.content, 200),
1272        "tags": m.tags,
1273        "uri": format!("subcog://memory/{}", m.id.as_str()),
1274    })
1275}
1276
1277/// Formats a memory ID as a minimal JSON object.
1278fn format_memory_id_only(id: &crate::models::MemoryId) -> serde_json::Value {
1279    serde_json::json!({
1280        "id": id.as_str(),
1281        "uri": format!("subcog://memory/{}", id.as_str()),
1282    })
1283}
1284
1285/// Simple URL decoding for URI components.
1286///
1287/// Handles common escape sequences: %20 (space), %2F (/), etc.
1288fn decode_uri_component(s: &str) -> String {
1289    let mut result = String::with_capacity(s.len());
1290    let mut chars = s.chars();
1291
1292    while let Some(c) = chars.next() {
1293        match c {
1294            '%' => {
1295                let hex: String = chars.by_ref().take(2).collect();
1296                let decoded = (hex.len() == 2)
1297                    .then(|| u8::from_str_radix(&hex, 16).ok())
1298                    .flatten()
1299                    .map(char::from);
1300
1301                if let Some(ch) = decoded {
1302                    result.push(ch);
1303                } else {
1304                    result.push('%');
1305                    result.push_str(&hex);
1306                }
1307            },
1308            '+' => result.push(' '),
1309            _ => result.push(c),
1310        }
1311    }
1312
1313    result
1314}
1315
1316/// Truncates content to a maximum length, breaking at word boundaries.
1317fn truncate_content(content: &str, max_len: usize) -> String {
1318    if content.len() <= max_len {
1319        return content.to_string();
1320    }
1321
1322    let truncated = &content[..max_len];
1323    truncated.rfind(' ').map_or_else(
1324        || format!("{truncated}..."),
1325        |last_space| format!("{}...", &truncated[..last_space]),
1326    )
1327}
1328
1329#[cfg(test)]
1330mod tests {
1331    use super::*;
1332    use crate::models::{Domain, Memory, MemoryId, MemoryStatus};
1333    use crate::services::RecallService;
1334    use crate::storage::index::SqliteBackend;
1335    use crate::storage::traits::IndexBackend;
1336
1337    fn build_handler_with_memories() -> ResourceHandler {
1338        let index = SqliteBackend::in_memory().expect("in-memory index");
1339        let now = 1_700_000_000;
1340        let memory = Memory {
1341            id: MemoryId::new("decisions-1"),
1342            content: "Decision content".to_string(),
1343            namespace: Namespace::Decisions,
1344            domain: Domain::new(),
1345            project_id: None,
1346            branch: None,
1347            file_path: None,
1348            status: MemoryStatus::Active,
1349            created_at: now,
1350            updated_at: now,
1351            tombstoned_at: None,
1352            expires_at: None,
1353            embedding: None,
1354            tags: vec!["alpha".to_string()],
1355            #[cfg(feature = "group-scope")]
1356            group_id: None,
1357            source: None,
1358            is_summary: false,
1359            source_memory_ids: None,
1360            consolidation_timestamp: None,
1361        };
1362        let other = Memory {
1363            id: MemoryId::new("patterns-1"),
1364            content: "Pattern content".to_string(),
1365            namespace: Namespace::Patterns,
1366            domain: Domain::new(),
1367            project_id: None,
1368            branch: None,
1369            file_path: None,
1370            status: MemoryStatus::Active,
1371            created_at: now,
1372            updated_at: now,
1373            tombstoned_at: None,
1374            expires_at: None,
1375            embedding: None,
1376            tags: vec!["beta".to_string()],
1377            #[cfg(feature = "group-scope")]
1378            group_id: None,
1379            source: None,
1380            is_summary: false,
1381            source_memory_ids: None,
1382            consolidation_timestamp: None,
1383        };
1384
1385        index.index(&memory).expect("index memory");
1386        index.index(&other).expect("index memory");
1387
1388        let recall = RecallService::with_index(index);
1389        ResourceHandler::new().with_recall_service(recall)
1390    }
1391
1392    #[test]
1393    fn test_resource_handler_creation() {
1394        let handler = ResourceHandler::new();
1395        let resources = handler.list_resources();
1396
1397        assert!(!resources.is_empty());
1398        assert!(resources.iter().any(|r| r.uri.contains("setup")));
1399        assert!(resources.iter().any(|r| r.uri.contains("concepts")));
1400    }
1401
1402    #[test]
1403    fn test_get_help_index() {
1404        let mut handler = ResourceHandler::new();
1405        let result = handler.get_resource("subcog://help").unwrap();
1406
1407        assert!(result.text.is_some());
1408        let text = result.text.unwrap();
1409        assert!(text.contains("Subcog Help"));
1410        assert!(text.contains("Quick Start"));
1411    }
1412
1413    #[test]
1414    fn test_get_help_category() {
1415        let mut handler = ResourceHandler::new();
1416
1417        let result = handler.get_resource("subcog://help/setup").unwrap();
1418        assert!(result.text.is_some());
1419        assert!(result.text.unwrap().contains("MCP Server Configuration"));
1420
1421        let result = handler.get_resource("subcog://help/concepts").unwrap();
1422        assert!(result.text.is_some());
1423        assert!(result.text.unwrap().contains("Namespaces"));
1424    }
1425
1426    #[test]
1427    fn test_invalid_uri() {
1428        let mut handler = ResourceHandler::new();
1429
1430        let result = handler.get_resource("http://example.com");
1431        assert!(result.is_err());
1432
1433        let result = handler.get_resource("subcog://unknown");
1434        assert!(result.is_err());
1435    }
1436
1437    #[test]
1438    fn test_unknown_category() {
1439        let mut handler = ResourceHandler::new();
1440
1441        let result = handler.get_resource("subcog://help/nonexistent");
1442        assert!(result.is_err());
1443    }
1444
1445    #[test]
1446    fn test_list_categories() {
1447        let handler = ResourceHandler::new();
1448        let categories = handler.list_categories();
1449
1450        assert_eq!(categories.len(), 8); // Including prompts
1451    }
1452
1453    #[test]
1454    fn test_prompts_help_category() {
1455        let mut handler = ResourceHandler::new();
1456
1457        // Should be able to get the prompts help resource
1458        let result = handler.get_resource("subcog://help/prompts");
1459        assert!(result.is_ok());
1460
1461        let content = result.unwrap();
1462        assert!(content.text.is_some());
1463        let text = content.text.unwrap();
1464        assert!(text.contains("User-Defined Prompts"));
1465        assert!(text.contains("prompt_save"));
1466        assert!(text.contains("Variable Syntax"));
1467    }
1468
1469    #[test]
1470    fn test_decode_uri_component() {
1471        assert_eq!(decode_uri_component("hello%20world"), "hello world");
1472        assert_eq!(decode_uri_component("hello+world"), "hello world");
1473        assert_eq!(decode_uri_component("rust%2Ferror"), "rust/error");
1474        assert_eq!(decode_uri_component("no%change"), "no%change"); // Invalid hex
1475        assert_eq!(decode_uri_component("plain"), "plain");
1476    }
1477
1478    #[test]
1479    fn test_truncate_content() {
1480        assert_eq!(truncate_content("short", 100), "short");
1481        assert_eq!(
1482            truncate_content("this is a longer sentence with words", 20),
1483            "this is a longer..."
1484        );
1485        assert_eq!(truncate_content("nospaces", 4), "nosp...");
1486    }
1487
1488    #[test]
1489    fn test_list_resources_includes_search_and_topics() {
1490        let handler = ResourceHandler::new();
1491        let resources = handler.list_resources();
1492
1493        assert!(resources.iter().any(|r| r.uri.contains("search")));
1494        assert!(resources.iter().any(|r| r.uri.contains("topics")));
1495    }
1496
1497    #[test]
1498    fn test_list_resources_includes_help_and_domain_templates() {
1499        let handler = ResourceHandler::new();
1500        let resources = handler.list_resources();
1501
1502        assert!(resources.iter().any(|r| r.uri == "subcog://help"));
1503        assert!(resources.iter().any(|r| r.uri == "subcog://memory/{id}"));
1504        assert!(
1505            resources
1506                .iter()
1507                .any(|r| r.uri == "subcog://project/{namespace}")
1508        );
1509        assert!(resources.iter().any(|r| r.uri == "subcog://org/_prompts"));
1510    }
1511
1512    #[test]
1513    fn test_search_resource_requires_recall_service() {
1514        let mut handler = ResourceHandler::new();
1515        let result = handler.get_resource("subcog://search/test");
1516        assert!(result.is_err());
1517        assert!(result.unwrap_err().to_string().contains("RecallService"));
1518    }
1519
1520    #[test]
1521    fn test_topics_resource_requires_topic_index() {
1522        let mut handler = ResourceHandler::new();
1523        let result = handler.get_resource("subcog://topics");
1524        assert!(result.is_err());
1525        assert!(
1526            result
1527                .unwrap_err()
1528                .to_string()
1529                .contains("Topic index not initialized")
1530        );
1531    }
1532
1533    #[test]
1534    fn test_search_resource_requires_query() {
1535        let mut handler = ResourceHandler::new();
1536        let result = handler.get_resource("subcog://search/");
1537        // Empty query after search/ is still valid parts
1538        // Just need recall service
1539        assert!(result.is_err());
1540    }
1541
1542    #[test]
1543    fn test_project_namespace_listing_filters() {
1544        let mut handler = build_handler_with_memories();
1545        let result = handler.get_resource("subcog://project/decisions").unwrap();
1546        let body = result.text.unwrap();
1547        let value: serde_json::Value = serde_json::from_str(&body).unwrap();
1548        assert_eq!(value["count"].as_u64(), Some(1));
1549        assert_eq!(value["memories"][0]["id"], "decisions-1");
1550        assert_eq!(value["memories"][0]["ns"], "decisions");
1551    }
1552
1553    #[test]
1554    fn test_project_namespace_memory_fetch() {
1555        let mut handler = build_handler_with_memories();
1556        let result = handler
1557            .get_resource("subcog://project/decisions/decisions-1")
1558            .unwrap();
1559        let body = result.text.unwrap();
1560        let value: serde_json::Value = serde_json::from_str(&body).unwrap();
1561        assert_eq!(value["id"], "decisions-1");
1562        assert_eq!(value["namespace"], "decisions");
1563    }
1564
1565    #[test]
1566    fn test_project_namespace_memory_fetch_rejects_mismatch() {
1567        let mut handler = build_handler_with_memories();
1568        let result = handler.get_resource("subcog://project/decisions/patterns-1");
1569        assert!(result.is_err());
1570    }
1571
1572    #[test]
1573    fn test_namespaces_resource() {
1574        let mut handler = ResourceHandler::new();
1575        let result = handler.get_resource("subcog://namespaces").unwrap();
1576
1577        assert!(result.text.is_some());
1578        let text = result.text.unwrap();
1579        let value: serde_json::Value = serde_json::from_str(&text).unwrap();
1580
1581        assert_eq!(value["count"].as_u64(), Some(11));
1582        assert!(value["namespaces"].is_array());
1583
1584        let namespaces = value["namespaces"].as_array().unwrap();
1585        assert!(namespaces.iter().any(|ns| ns["namespace"] == "decisions"));
1586        assert!(namespaces.iter().any(|ns| ns["namespace"] == "patterns"));
1587        assert!(namespaces.iter().any(|ns| ns["namespace"] == "learnings"));
1588
1589        // Verify signal words are included
1590        let decisions = namespaces
1591            .iter()
1592            .find(|ns| ns["namespace"] == "decisions")
1593            .unwrap();
1594        assert!(decisions["signal_words"].is_array());
1595        assert!(!decisions["signal_words"].as_array().unwrap().is_empty());
1596    }
1597
1598    #[test]
1599    fn test_list_resources_includes_namespaces() {
1600        let handler = ResourceHandler::new();
1601        let resources = handler.list_resources();
1602
1603        assert!(resources.iter().any(|r| r.uri == "subcog://namespaces"));
1604    }
1605
1606    #[test]
1607    fn test_list_resources_includes_aggregate_prompts() {
1608        let handler = ResourceHandler::new();
1609        let resources = handler.list_resources();
1610
1611        assert!(resources.iter().any(|r| r.uri == "subcog://_prompts"));
1612    }
1613
1614    #[test]
1615    fn test_aggregate_prompts_requires_prompt_service() {
1616        let mut handler = ResourceHandler::new();
1617        let result = handler.get_resource("subcog://_prompts");
1618        assert!(result.is_err());
1619        assert!(result.unwrap_err().to_string().contains("PromptService"));
1620    }
1621
1622    #[test]
1623    fn test_list_resources_includes_summaries() {
1624        let handler = ResourceHandler::new();
1625        let resources = handler.list_resources();
1626
1627        assert!(resources.iter().any(|r| r.uri == "subcog://summaries"));
1628        assert!(resources.iter().any(|r| r.uri == "subcog://summaries/{id}"));
1629    }
1630
1631    #[test]
1632    fn test_summaries_resource_requires_recall_service() {
1633        let mut handler = ResourceHandler::new();
1634        let result = handler.get_resource("subcog://summaries");
1635        assert!(result.is_err());
1636        assert!(result.unwrap_err().to_string().contains("RecallService"));
1637    }
1638
1639    #[test]
1640    fn test_summaries_list_filters_only_summary_nodes() {
1641        let index = SqliteBackend::in_memory().expect("in-memory index");
1642        let now = 1_700_000_000;
1643
1644        // Create a regular memory
1645        let regular = Memory {
1646            id: MemoryId::new("regular-1"),
1647            content: "Regular memory content".to_string(),
1648            namespace: Namespace::Decisions,
1649            domain: Domain::new(),
1650            project_id: None,
1651            branch: None,
1652            file_path: None,
1653            status: MemoryStatus::Active,
1654            created_at: now,
1655            updated_at: now,
1656            tombstoned_at: None,
1657            expires_at: None,
1658            embedding: None,
1659            tags: vec!["tag1".to_string()],
1660            #[cfg(feature = "group-scope")]
1661            group_id: None,
1662            source: None,
1663            is_summary: false,
1664            source_memory_ids: None,
1665            consolidation_timestamp: None,
1666        };
1667
1668        // Create a summary memory
1669        let summary = Memory {
1670            id: MemoryId::new("summary-1"),
1671            content: "Summary of related memories".to_string(),
1672            namespace: Namespace::Decisions,
1673            domain: Domain::new(),
1674            project_id: None,
1675            branch: None,
1676            file_path: None,
1677            status: MemoryStatus::Active,
1678            created_at: now,
1679            updated_at: now,
1680            tombstoned_at: None,
1681            expires_at: None,
1682            embedding: None,
1683            tags: vec!["tag1".to_string()],
1684            #[cfg(feature = "group-scope")]
1685            group_id: None,
1686            source: Some("consolidation".to_string()),
1687            is_summary: true,
1688            source_memory_ids: Some(vec![MemoryId::new("regular-1")]),
1689            consolidation_timestamp: Some(now),
1690        };
1691
1692        index.index(&regular).expect("index regular memory");
1693        index.index(&summary).expect("index summary memory");
1694
1695        let recall = RecallService::with_index(index);
1696        let mut handler = ResourceHandler::new().with_recall_service(recall);
1697
1698        let result = handler.get_resource("subcog://summaries").unwrap();
1699        let body = result.text.unwrap();
1700        let value: serde_json::Value = serde_json::from_str(&body).unwrap();
1701
1702        // Should only include the summary, not the regular memory
1703        assert_eq!(value["count"].as_u64(), Some(1));
1704        assert_eq!(value["summaries"][0]["id"], "summary-1");
1705        assert_eq!(value["summaries"][0]["source_count"], 1);
1706        assert!(value["summaries"][0]["consolidation_timestamp"].is_u64());
1707    }
1708
1709    #[test]
1710    fn test_get_specific_summary_with_sources() {
1711        let index = SqliteBackend::in_memory().expect("in-memory index");
1712        let now = 1_700_000_000;
1713
1714        // Create source memories
1715        let source1 = Memory {
1716            id: MemoryId::new("source-1"),
1717            content: "First source memory with detailed content".to_string(),
1718            namespace: Namespace::Decisions,
1719            domain: Domain::new(),
1720            project_id: None,
1721            branch: None,
1722            file_path: None,
1723            status: MemoryStatus::Active,
1724            created_at: now,
1725            updated_at: now,
1726            tombstoned_at: None,
1727            expires_at: None,
1728            embedding: None,
1729            tags: vec!["db".to_string()],
1730            #[cfg(feature = "group-scope")]
1731            group_id: None,
1732            source: None,
1733            is_summary: false,
1734            source_memory_ids: None,
1735            consolidation_timestamp: None,
1736        };
1737
1738        let source2 = Memory {
1739            id: MemoryId::new("source-2"),
1740            content: "Second source memory with more details".to_string(),
1741            namespace: Namespace::Decisions,
1742            domain: Domain::new(),
1743            project_id: None,
1744            branch: None,
1745            file_path: None,
1746            status: MemoryStatus::Active,
1747            created_at: now,
1748            updated_at: now,
1749            tombstoned_at: None,
1750            expires_at: None,
1751            embedding: None,
1752            tags: vec!["cache".to_string()],
1753            #[cfg(feature = "group-scope")]
1754            group_id: None,
1755            source: None,
1756            is_summary: false,
1757            source_memory_ids: None,
1758            consolidation_timestamp: None,
1759        };
1760
1761        // Create summary memory
1762        let summary = Memory {
1763            id: MemoryId::new("summary-1"),
1764            content: "Consolidated summary of database and caching decisions".to_string(),
1765            namespace: Namespace::Decisions,
1766            domain: Domain::new(),
1767            project_id: None,
1768            branch: None,
1769            file_path: None,
1770            status: MemoryStatus::Active,
1771            created_at: now,
1772            updated_at: now,
1773            tombstoned_at: None,
1774            expires_at: None,
1775            embedding: None,
1776            tags: vec!["db".to_string(), "cache".to_string()],
1777            #[cfg(feature = "group-scope")]
1778            group_id: None,
1779            source: Some("consolidation".to_string()),
1780            is_summary: true,
1781            source_memory_ids: Some(vec![MemoryId::new("source-1"), MemoryId::new("source-2")]),
1782            consolidation_timestamp: Some(now),
1783        };
1784
1785        index.index(&source1).expect("index source1");
1786        index.index(&source2).expect("index source2");
1787        index.index(&summary).expect("index summary");
1788
1789        let recall = RecallService::with_index(index);
1790        let mut handler = ResourceHandler::new().with_recall_service(recall);
1791
1792        let result = handler
1793            .get_resource("subcog://summaries/summary-1")
1794            .unwrap();
1795        let body = result.text.unwrap();
1796        let value: serde_json::Value = serde_json::from_str(&body).unwrap();
1797
1798        assert_eq!(value["id"], "summary-1");
1799        assert_eq!(value["namespace"], "decisions");
1800        assert_eq!(value["source_count"], 2);
1801        assert_eq!(
1802            value["content"],
1803            "Consolidated summary of database and caching decisions"
1804        );
1805
1806        // Verify source memories are included
1807        let source_arr = value["source_memories"].as_array().unwrap();
1808        assert_eq!(source_arr.len(), 2);
1809        assert!(source_arr.iter().any(|s| s["id"] == "source-1"));
1810        assert!(source_arr.iter().any(|s| s["id"] == "source-2"));
1811    }
1812
1813    #[test]
1814    fn test_get_summary_rejects_non_summary() {
1815        let index = SqliteBackend::in_memory().expect("in-memory index");
1816        let now = 1_700_000_000;
1817
1818        // Create a regular memory (not a summary)
1819        let regular = Memory {
1820            id: MemoryId::new("regular-1"),
1821            content: "Regular memory".to_string(),
1822            namespace: Namespace::Decisions,
1823            domain: Domain::new(),
1824            project_id: None,
1825            branch: None,
1826            file_path: None,
1827            status: MemoryStatus::Active,
1828            created_at: now,
1829            updated_at: now,
1830            tombstoned_at: None,
1831            expires_at: None,
1832            embedding: None,
1833            tags: vec![],
1834            #[cfg(feature = "group-scope")]
1835            group_id: None,
1836            source: None,
1837            is_summary: false,
1838            source_memory_ids: None,
1839            consolidation_timestamp: None,
1840        };
1841
1842        index.index(&regular).expect("index memory");
1843
1844        let recall = RecallService::with_index(index);
1845        let mut handler = ResourceHandler::new().with_recall_service(recall);
1846
1847        let result = handler.get_resource("subcog://summaries/regular-1");
1848        assert!(result.is_err());
1849        assert!(
1850            result
1851                .unwrap_err()
1852                .to_string()
1853                .contains("not a summary node")
1854        );
1855    }
1856
1857    #[test]
1858    fn test_get_summary_not_found() {
1859        let index = SqliteBackend::in_memory().expect("in-memory index");
1860        let recall = RecallService::with_index(index);
1861        let mut handler = ResourceHandler::new().with_recall_service(recall);
1862
1863        let result = handler.get_resource("subcog://summaries/nonexistent");
1864        assert!(result.is_err());
1865        assert!(result.unwrap_err().to_string().contains("not found"));
1866    }
1867}