thoughts_tool/mcp/
mod.rs

1use agentic_logging::{CallTimer, LogWriter, ToolCallRecord};
2use schemars::JsonSchema;
3use serde::{Deserialize, Serialize};
4use universal_tool_core::mcp::{McpFormatter, ServiceExt};
5use universal_tool_core::prelude::*;
6
7mod templates;
8
9use crate::config::validation::{canonical_reference_key, validate_reference_url_https_only};
10use crate::config::{
11    ReferenceEntry, ReferenceMount, RepoConfigManager, RepoMappingManager,
12    extract_org_repo_from_url,
13};
14#[cfg(test)]
15use crate::documents::DocumentInfo;
16use crate::documents::{
17    ActiveDocuments, DocumentType, WriteDocumentOk, active_logs_dir,
18    list_documents as lib_list_documents, write_document as lib_write_document,
19};
20use crate::git::utils::get_control_repo_root;
21use crate::mount::auto_mount::update_active_mounts;
22use crate::mount::get_mount_manager;
23use crate::platform::detect_platform;
24
25/// Helper to log a tool call. Returns the LogWriter if logging is available.
26fn log_tool_call(
27    timer: &CallTimer,
28    tool: &str,
29    request: serde_json::Value,
30    success: bool,
31    error: Option<String>,
32    summary: Option<serde_json::Value>,
33) {
34    let writer = match active_logs_dir() {
35        Ok(dir) => LogWriter::new(dir),
36        Err(_) => return, // Logging unavailable (e.g., branch lockout)
37    };
38
39    let (completed_at, duration_ms) = timer.finish();
40    let record = ToolCallRecord {
41        call_id: timer.call_id.clone(),
42        server: "thoughts_tool".into(),
43        tool: tool.into(),
44        started_at: timer.started_at,
45        completed_at,
46        duration_ms,
47        request,
48        response_file: None,
49        success,
50        error,
51        model: None,
52        token_usage: None,
53        summary,
54    };
55
56    if let Err(e) = writer.append_jsonl(&record) {
57        tracing::warn!("Failed to append JSONL log: {}", e);
58    }
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
62#[serde(rename_all = "snake_case")]
63pub enum TemplateType {
64    Research,
65    Plan,
66    Requirements,
67    PrDescription,
68}
69
70impl TemplateType {
71    pub fn label(&self) -> &'static str {
72        match self {
73            TemplateType::Research => "research",
74            TemplateType::Plan => "plan",
75            TemplateType::Requirements => "requirements",
76            TemplateType::PrDescription => "pr_description",
77        }
78    }
79    pub fn content(&self) -> &'static str {
80        match self {
81            TemplateType::Research => templates::RESEARCH_TEMPLATE_MD,
82            TemplateType::Plan => templates::PLAN_TEMPLATE_MD,
83            TemplateType::Requirements => templates::REQUIREMENTS_TEMPLATE_MD,
84            TemplateType::PrDescription => templates::PR_DESCRIPTION_TEMPLATE_MD,
85        }
86    }
87    pub fn guidance(&self) -> &'static str {
88        match self {
89            TemplateType::Research => templates::RESEARCH_GUIDANCE,
90            TemplateType::Plan => templates::PLAN_GUIDANCE,
91            TemplateType::Requirements => templates::REQUIREMENTS_GUIDANCE,
92            TemplateType::PrDescription => templates::PR_DESCRIPTION_GUIDANCE,
93        }
94    }
95}
96
97// Helper for human-readable sizes
98fn human_size(bytes: u64) -> String {
99    match bytes {
100        0 => "0 B".into(),
101        1..=1023 => format!("{} B", bytes),
102        1024..=1048575 => format!("{:.1} KB", (bytes as f64) / 1024.0),
103        _ => format!("{:.1} MB", (bytes as f64) / (1024.0 * 1024.0)),
104    }
105}
106
107// MCP text formatting implementations for library types
108
109impl McpFormatter for WriteDocumentOk {
110    fn mcp_format_text(&self) -> String {
111        format!(
112            "✓ Created {}\n  Size: {}",
113            self.path,
114            human_size(self.bytes_written)
115        )
116    }
117}
118
119impl McpFormatter for ActiveDocuments {
120    fn mcp_format_text(&self) -> String {
121        if self.files.is_empty() {
122            return format!(
123                "Active base: {}\nFiles (relative to base):\n<none>",
124                self.base
125            );
126        }
127        let mut out = format!("Active base: {}\nFiles (relative to base):", self.base);
128        for f in &self.files {
129            let rel = f
130                .path
131                .strip_prefix(&format!("{}/", self.base))
132                .unwrap_or(&f.path);
133            let ts = match chrono::DateTime::parse_from_rfc3339(&f.modified) {
134                Ok(dt) => dt
135                    .with_timezone(&chrono::Utc)
136                    .format("%Y-%m-%d %H:%M UTC")
137                    .to_string(),
138                Err(_) => f.modified.clone(),
139            };
140            out.push_str(&format!("\n{} @ {}", rel, ts));
141        }
142        out
143    }
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
147pub struct ReferenceItem {
148    pub path: String,
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub description: Option<String>,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
154pub struct ReferencesList {
155    pub base: String,
156    pub entries: Vec<ReferenceItem>,
157}
158
159impl McpFormatter for ReferencesList {
160    fn mcp_format_text(&self) -> String {
161        if self.entries.is_empty() {
162            return format!("References base: {}\n<none>", self.base);
163        }
164        let mut out = format!("References base: {}", self.base);
165        for e in &self.entries {
166            let rel = e
167                .path
168                .strip_prefix(&format!("{}/", self.base))
169                .unwrap_or(&e.path);
170            match &e.description {
171                Some(desc) if !desc.trim().is_empty() => {
172                    out.push_str(&format!("\n{} — {}", rel, desc));
173                }
174                _ => {
175                    out.push_str(&format!("\n{}", rel));
176                }
177            }
178        }
179        out
180    }
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
184pub struct AddReferenceOk {
185    pub url: String,
186    pub org: String,
187    pub repo: String,
188    pub mount_path: String,
189    pub mount_target: String,
190    #[serde(skip_serializing_if = "Option::is_none")]
191    pub mapping_path: Option<String>,
192    pub already_existed: bool,
193    pub config_updated: bool,
194    pub cloned: bool,
195    pub mounted: bool,
196    #[serde(default)]
197    pub warnings: Vec<String>,
198}
199
200impl McpFormatter for AddReferenceOk {
201    fn mcp_format_text(&self) -> String {
202        let mut out = String::new();
203        if self.already_existed {
204            out.push_str("✓ Reference already exists (idempotent)\n");
205        } else {
206            out.push_str("✓ Added reference\n");
207        }
208        out.push_str(&format!(
209            "  URL: {}\n  Org/Repo: {}/{}\n  Mount: {}\n  Target: {}",
210            self.url, self.org, self.repo, self.mount_path, self.mount_target
211        ));
212        if let Some(mp) = &self.mapping_path {
213            out.push_str(&format!("\n  Mapping: {}", mp));
214        } else {
215            out.push_str("\n  Mapping: <none>");
216        }
217        out.push_str(&format!(
218            "\n  Config updated: {}\n  Cloned: {}\n  Mounted: {}",
219            self.config_updated, self.cloned, self.mounted
220        ));
221        if !self.warnings.is_empty() {
222            out.push_str("\nWarnings:");
223            for w in &self.warnings {
224                out.push_str(&format!("\n- {}", w));
225            }
226        }
227        out
228    }
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
232pub struct TemplateResponse {
233    pub template_type: TemplateType,
234}
235
236impl McpFormatter for TemplateResponse {
237    fn mcp_format_text(&self) -> String {
238        let ty = self.template_type.label();
239        let content = self.template_type.content();
240        let guidance = self.template_type.guidance();
241        format!(
242            "Here is the {} template:\n\n```markdown\n{}\n```\n\n{}",
243            ty, content, guidance
244        )
245    }
246}
247
248// Tool implementation
249
250#[derive(Clone, Default)]
251pub struct ThoughtsMcpTools;
252
253#[universal_tool_router(mcp(name = "thoughts_tool", version = "0.3.0"))]
254impl ThoughtsMcpTools {
255    /// Write markdown to active work directory (research/plans/artifacts/logs)
256    #[universal_tool(
257        description = "Write markdown to the active work directory",
258        mcp(destructive = false, output = "text")
259    )]
260    pub async fn write_document(
261        &self,
262        doc_type: DocumentType,
263        filename: String,
264        content: String,
265    ) -> Result<WriteDocumentOk, ToolError> {
266        let timer = CallTimer::start();
267        let req_json = serde_json::json!({
268            "doc_type": doc_type.singular_label(),
269            "filename": &filename,
270        });
271
272        let result = lib_write_document(doc_type, &filename, &content);
273
274        match &result {
275            Ok(ok) => {
276                let summary = serde_json::json!({
277                    "path": &ok.path,
278                    "bytes_written": ok.bytes_written,
279                });
280                log_tool_call(
281                    &timer,
282                    "write_document",
283                    req_json,
284                    true,
285                    None,
286                    Some(summary),
287                );
288            }
289            Err(e) => {
290                log_tool_call(
291                    &timer,
292                    "write_document",
293                    req_json,
294                    false,
295                    Some(e.to_string()),
296                    None,
297                );
298            }
299        }
300
301        result.map_err(|e| ToolError::new(ErrorCode::IoError, e.to_string()))
302    }
303
304    /// List files in current active work directory
305    #[universal_tool(
306        description = "List files in the current active work directory",
307        mcp(read_only = true, idempotent = true, output = "text")
308    )]
309    pub async fn list_active_documents(
310        &self,
311        subdir: Option<DocumentType>,
312    ) -> Result<ActiveDocuments, ToolError> {
313        let timer = CallTimer::start();
314        let req_json = serde_json::json!({
315            "subdir": subdir.as_ref().map(|d| format!("{:?}", d).to_lowercase()),
316        });
317
318        let result = lib_list_documents(subdir);
319
320        match &result {
321            Ok(docs) => {
322                let summary = serde_json::json!({
323                    "base": &docs.base,
324                    "files_count": docs.files.len(),
325                });
326                log_tool_call(
327                    &timer,
328                    "list_active_documents",
329                    req_json,
330                    true,
331                    None,
332                    Some(summary),
333                );
334            }
335            Err(e) => {
336                log_tool_call(
337                    &timer,
338                    "list_active_documents",
339                    req_json,
340                    false,
341                    Some(e.to_string()),
342                    None,
343                );
344            }
345        }
346
347        result.map_err(|e| ToolError::new(ErrorCode::IoError, e.to_string()))
348    }
349
350    /// List reference repository directory paths
351    #[universal_tool(
352        description = "List reference repository directory paths (references/org/repo)",
353        mcp(read_only = true, idempotent = true, output = "text")
354    )]
355    pub async fn list_references(&self) -> Result<ReferencesList, ToolError> {
356        let timer = CallTimer::start();
357        let req_json = serde_json::json!({});
358
359        let result = (|| -> Result<ReferencesList, ToolError> {
360            let control_root = crate::git::utils::get_control_repo_root(
361                &std::env::current_dir()
362                    .map_err(|e| ToolError::new(ErrorCode::IoError, e.to_string()))?,
363            )
364            .map_err(|e| ToolError::new(ErrorCode::IoError, e.to_string()))?;
365            let mgr = RepoConfigManager::new(control_root);
366            let ds = mgr
367                .load_desired_state()
368                .map_err(|e| ToolError::new(ErrorCode::IoError, e.to_string()))?
369                .ok_or_else(|| {
370                    ToolError::new(
371                        universal_tool_core::error::ErrorCode::NotFound,
372                        "No repository configuration found",
373                    )
374                })?;
375
376            let base = ds.mount_dirs.references.clone();
377            let mut entries = Vec::new();
378
379            // Phase 4: ds.references is now Vec<ReferenceMount> with optional descriptions
380            for rm in &ds.references {
381                let path = match extract_org_repo_from_url(&rm.remote) {
382                    Ok((org, repo)) => format!("{}/{}", org, repo),
383                    Err(_) => rm.remote.clone(),
384                };
385                entries.push(ReferenceItem {
386                    path: format!("{}/{}", base, path),
387                    description: rm.description.clone(),
388                });
389            }
390
391            Ok(ReferencesList { base, entries })
392        })();
393
394        match &result {
395            Ok(refs) => {
396                let summary = serde_json::json!({
397                    "base": &refs.base,
398                    "entries_count": refs.entries.len(),
399                });
400                log_tool_call(
401                    &timer,
402                    "list_references",
403                    req_json,
404                    true,
405                    None,
406                    Some(summary),
407                );
408            }
409            Err(e) => {
410                log_tool_call(
411                    &timer,
412                    "list_references",
413                    req_json,
414                    false,
415                    Some(e.to_string()),
416                    None,
417                );
418            }
419        }
420
421        result
422    }
423
424    /// Add a GitHub repository as a reference and ensure it is cloned and mounted.
425    ///
426    /// Input must be an HTTPS GitHub URL: https://github.com/org/repo or
427    /// https://github.com/org/repo.git. Also accepts generic https://*.git clone URLs.
428    /// SSH URLs (git@…) are rejected. The operation is idempotent and safe to retry;
429    /// first-time clones may take time. Returns details about config changes, clone
430    /// location, and mount status.
431    #[universal_tool(
432        description = "Add a GitHub repository as a reference and ensure it is cloned and mounted. Input must be an HTTPS GitHub URL (https://github.com/org/repo or .git) or generic https://*.git clone URL. SSH URLs (git@…) are rejected. Idempotent and safe to retry; first-time clones may take time.",
433        mcp(destructive = false, idempotent = true, output = "text")
434    )]
435    pub async fn add_reference(
436        &self,
437        #[universal_tool_param(
438            description = "HTTPS GitHub URL (https://github.com/org/repo) or generic https://*.git clone URL"
439        )]
440        url: String,
441        #[universal_tool_param(
442            description = "Optional description for why this reference was added"
443        )]
444        description: Option<String>,
445    ) -> Result<AddReferenceOk, ToolError> {
446        let timer = CallTimer::start();
447        let req_json = serde_json::json!({
448            "url": &url,
449            "description": &description,
450        });
451
452        let result = self.add_reference_impl(url, description).await;
453
454        match &result {
455            Ok(ok) => {
456                let summary = serde_json::json!({
457                    "org": &ok.org,
458                    "repo": &ok.repo,
459                    "already_existed": ok.already_existed,
460                    "config_updated": ok.config_updated,
461                    "cloned": ok.cloned,
462                    "mounted": ok.mounted,
463                });
464                log_tool_call(&timer, "add_reference", req_json, true, None, Some(summary));
465            }
466            Err(e) => {
467                log_tool_call(
468                    &timer,
469                    "add_reference",
470                    req_json,
471                    false,
472                    Some(e.to_string()),
473                    None,
474                );
475            }
476        }
477
478        result
479    }
480
481    /// Internal implementation of add_reference (extracted to simplify logging wrapper)
482    async fn add_reference_impl(
483        &self,
484        url: String,
485        description: Option<String>,
486    ) -> Result<AddReferenceOk, ToolError> {
487        let input_url = url.trim().to_string();
488
489        // Validate URL per MCP HTTPS-only rules
490        validate_reference_url_https_only(&input_url)
491            .map_err(|e| ToolError::invalid_input(e.to_string()))?;
492
493        // Parse org/repo; safe after validation
494        let (org, repo) = extract_org_repo_from_url(&input_url)
495            .map_err(|e| ToolError::invalid_input(e.to_string()))?;
496
497        // Resolve repo root and config manager
498        let repo_root = get_control_repo_root(
499            &std::env::current_dir()
500                .map_err(|e| ToolError::new(ErrorCode::IoError, e.to_string()))?,
501        )
502        .map_err(|e| ToolError::new(ErrorCode::IoError, e.to_string()))?;
503
504        let mgr = RepoConfigManager::new(repo_root.clone());
505        let mut cfg = mgr
506            .ensure_v2_default()
507            .map_err(|e| ToolError::new(ErrorCode::IoError, e.to_string()))?;
508
509        // Build existing canonical keys set for duplicate detection
510        let mut existing_keys = std::collections::HashSet::new();
511        for e in &cfg.references {
512            let existing_url = match e {
513                ReferenceEntry::Simple(s) => s.as_str(),
514                ReferenceEntry::WithMetadata(rm) => rm.remote.as_str(),
515            };
516            if let Ok(k) = canonical_reference_key(existing_url) {
517                existing_keys.insert(k);
518            }
519        }
520        let this_key = canonical_reference_key(&input_url)
521            .map_err(|e| ToolError::invalid_input(e.to_string()))?;
522        let already_existed = existing_keys.contains(&this_key);
523
524        // Compute paths for response
525        let ds = mgr
526            .load_desired_state()
527            .map_err(|e| ToolError::new(ErrorCode::IoError, e.to_string()))?
528            .ok_or_else(|| {
529                ToolError::new(ErrorCode::NotFound, "No repository configuration found")
530            })?;
531        let mount_path = format!("{}/{}/{}", ds.mount_dirs.references, org, repo);
532        let mount_target = repo_root
533            .join(".thoughts-data")
534            .join(&mount_path)
535            .to_string_lossy()
536            .to_string();
537
538        // Capture pre-sync mapping status
539        let repo_mapping = RepoMappingManager::new()
540            .map_err(|e| ToolError::new(ErrorCode::IoError, e.to_string()))?;
541        let pre_mapping = repo_mapping
542            .resolve_url(&input_url)
543            .ok()
544            .flatten()
545            .map(|p| p.to_string_lossy().to_string());
546
547        // Update config if new
548        let mut config_updated = false;
549        let mut warnings: Vec<String> = Vec::new();
550        if !already_existed {
551            if let Some(desc) = description.clone() {
552                cfg.references
553                    .push(ReferenceEntry::WithMetadata(ReferenceMount {
554                        remote: input_url.clone(),
555                        description: if desc.trim().is_empty() {
556                            None
557                        } else {
558                            Some(desc)
559                        },
560                    }));
561            } else {
562                cfg.references
563                    .push(ReferenceEntry::Simple(input_url.clone()));
564            }
565
566            let ws = mgr
567                .save_v2_validated(&cfg)
568                .map_err(|e| ToolError::new(ErrorCode::IoError, e.to_string()))?;
569            warnings.extend(ws);
570            config_updated = true;
571        } else if description.is_some() {
572            warnings.push(
573                "Reference already exists; description was not updated (use CLI to modify metadata)"
574                    .to_string(),
575            );
576        }
577
578        // Always attempt to sync clone+mount (best-effort, no rollback)
579        if let Err(e) = update_active_mounts().await {
580            warnings.push(format!("Mount synchronization encountered an error: {}", e));
581        }
582
583        // Post-sync mapping status to infer cloning
584        let repo_mapping_post = RepoMappingManager::new()
585            .map_err(|e| ToolError::new(ErrorCode::IoError, e.to_string()))?;
586        let post_mapping = repo_mapping_post
587            .resolve_url(&input_url)
588            .ok()
589            .flatten()
590            .map(|p| p.to_string_lossy().to_string());
591        let cloned = pre_mapping.is_none() && post_mapping.is_some();
592
593        // Determine mounted by listing active mounts
594        let platform =
595            detect_platform().map_err(|e| ToolError::new(ErrorCode::IoError, e.to_string()))?;
596        let mount_manager = get_mount_manager(&platform)
597            .map_err(|e| ToolError::new(ErrorCode::IoError, e.to_string()))?;
598        let active = mount_manager
599            .list_mounts()
600            .await
601            .map_err(|e| ToolError::new(ErrorCode::IoError, e.to_string()))?;
602        let target_path = std::path::PathBuf::from(&mount_target);
603        let target_canon = std::fs::canonicalize(&target_path).unwrap_or(target_path.clone());
604        let mut mounted = false;
605        for mi in active {
606            let canon = std::fs::canonicalize(&mi.target).unwrap_or(mi.target.clone());
607            if canon == target_canon {
608                mounted = true;
609                break;
610            }
611        }
612
613        // Additional warnings for visibility
614        if post_mapping.is_none() {
615            warnings.push(
616                "Repository was not cloned or mapped. It may be private or network unavailable. \
617                 You can retry or run 'thoughts references sync' via CLI."
618                    .to_string(),
619            );
620        }
621        if !mounted {
622            warnings.push(
623                "Mount is not active. You can retry or run 'thoughts mount update' via CLI."
624                    .to_string(),
625            );
626        }
627
628        Ok(AddReferenceOk {
629            url: input_url,
630            org,
631            repo,
632            mount_path,
633            mount_target,
634            mapping_path: post_mapping,
635            already_existed,
636            config_updated,
637            cloned,
638            mounted,
639            warnings,
640        })
641    }
642
643    /// Get a compile-time embedded document template with usage guidance.
644    #[universal_tool(
645        description = "Return a compile-time embedded template (research, plan, requirements, pr_description) with usage guidance",
646        mcp(read_only = true, idempotent = true, output = "text")
647    )]
648    pub async fn get_template(
649        &self,
650        #[universal_tool_param(
651            description = "Which template to fetch (research, plan, requirements, pr_description)"
652        )]
653        template: TemplateType,
654    ) -> Result<TemplateResponse, ToolError> {
655        let timer = CallTimer::start();
656        let req_json = serde_json::json!({
657            "template": template.label(),
658        });
659
660        let result = TemplateResponse {
661            template_type: template,
662        };
663
664        let summary = serde_json::json!({
665            "template_type": result.template_type.label(),
666        });
667        log_tool_call(&timer, "get_template", req_json, true, None, Some(summary));
668
669        Ok(result)
670    }
671}
672
673// MCP server wrapper
674pub struct ThoughtsMcpServer {
675    tools: std::sync::Arc<ThoughtsMcpTools>,
676}
677universal_tool_core::implement_mcp_server!(ThoughtsMcpServer, tools);
678
679/// Serve MCP over stdio (called from main)
680pub async fn serve_stdio() -> Result<(), Box<dyn std::error::Error>> {
681    let server = ThoughtsMcpServer {
682        tools: std::sync::Arc::new(ThoughtsMcpTools),
683    };
684    let transport = universal_tool_core::mcp::stdio();
685    let svc = server.serve(transport).await?;
686    svc.waiting().await?;
687    Ok(())
688}
689
690#[cfg(test)]
691mod tests {
692    use super::*;
693
694    #[test]
695    fn test_human_size_formatting() {
696        assert_eq!(human_size(0), "0 B");
697        assert_eq!(human_size(1), "1 B");
698        assert_eq!(human_size(1023), "1023 B");
699        assert_eq!(human_size(1024), "1.0 KB");
700        assert_eq!(human_size(2048), "2.0 KB");
701        assert_eq!(human_size(1024 * 1024), "1.0 MB");
702        assert_eq!(human_size(2 * 1024 * 1024), "2.0 MB");
703    }
704
705    #[test]
706    fn test_write_document_ok_format() {
707        let ok = WriteDocumentOk {
708            path: "./thoughts/feat/research/a.md".into(),
709            bytes_written: 2048,
710        };
711        let text = ok.mcp_format_text();
712        assert!(text.contains("2.0 KB"));
713        assert!(text.contains("✓ Created"));
714        assert!(text.contains("./thoughts/feat/research/a.md"));
715    }
716
717    #[test]
718    fn test_active_documents_empty() {
719        let docs = ActiveDocuments {
720            base: "./thoughts/x".into(),
721            files: vec![],
722        };
723        let s = docs.mcp_format_text();
724        assert!(s.contains("<none>"));
725        assert!(s.contains("./thoughts/x"));
726    }
727
728    #[test]
729    fn test_active_documents_with_files() {
730        let docs = ActiveDocuments {
731            base: "./thoughts/feature".into(),
732            files: vec![DocumentInfo {
733                path: "./thoughts/feature/research/test.md".into(),
734                doc_type: "research".into(),
735                size: 1024,
736                modified: "2025-10-15T12:00:00Z".into(),
737            }],
738        };
739        let text = docs.mcp_format_text();
740        assert!(text.contains("research/test.md"));
741        assert!(text.contains("2025-10-15 12:00 UTC"));
742    }
743
744    // Note: DocumentType serde tests are in crate::documents::tests
745
746    #[test]
747    fn test_references_list_empty() {
748        let refs = ReferencesList {
749            base: "references".into(),
750            entries: vec![],
751        };
752        let s = refs.mcp_format_text();
753        assert!(s.contains("<none>"));
754        assert!(s.contains("references"));
755    }
756
757    #[test]
758    fn test_references_list_without_descriptions() {
759        let refs = ReferencesList {
760            base: "references".into(),
761            entries: vec![
762                ReferenceItem {
763                    path: "references/org/repo1".into(),
764                    description: None,
765                },
766                ReferenceItem {
767                    path: "references/org/repo2".into(),
768                    description: None,
769                },
770            ],
771        };
772        let text = refs.mcp_format_text();
773        assert!(text.contains("org/repo1"));
774        assert!(text.contains("org/repo2"));
775        assert!(!text.contains("—")); // No description separator
776    }
777
778    #[test]
779    fn test_references_list_with_descriptions() {
780        let refs = ReferencesList {
781            base: "references".into(),
782            entries: vec![
783                ReferenceItem {
784                    path: "references/org/repo1".into(),
785                    description: Some("First repo".into()),
786                },
787                ReferenceItem {
788                    path: "references/org/repo2".into(),
789                    description: Some("Second repo".into()),
790                },
791            ],
792        };
793        let text = refs.mcp_format_text();
794        assert!(text.contains("org/repo1 — First repo"));
795        assert!(text.contains("org/repo2 — Second repo"));
796    }
797
798    #[test]
799    fn test_add_reference_ok_format() {
800        let ok = AddReferenceOk {
801            url: "https://github.com/org/repo".into(),
802            org: "org".into(),
803            repo: "repo".into(),
804            mount_path: "references/org/repo".into(),
805            mount_target: "/abs/.thoughts-data/references/org/repo".into(),
806            mapping_path: Some("/home/user/.thoughts/clones/repo".into()),
807            already_existed: false,
808            config_updated: true,
809            cloned: true,
810            mounted: true,
811            warnings: vec!["note".into()],
812        };
813        let s = ok.mcp_format_text();
814        assert!(s.contains("✓ Added reference"));
815        assert!(s.contains("Org/Repo: org/repo"));
816        assert!(s.contains("Cloned: true"));
817        assert!(s.contains("Mounted: true"));
818        assert!(s.contains("Warnings:\n- note"));
819    }
820
821    #[test]
822    fn test_add_reference_ok_format_already_existed() {
823        let ok = AddReferenceOk {
824            url: "https://github.com/org/repo".into(),
825            org: "org".into(),
826            repo: "repo".into(),
827            mount_path: "references/org/repo".into(),
828            mount_target: "/abs/.thoughts-data/references/org/repo".into(),
829            mapping_path: Some("/home/user/.thoughts/clones/repo".into()),
830            already_existed: true,
831            config_updated: false,
832            cloned: false,
833            mounted: true,
834            warnings: vec![],
835        };
836        let s = ok.mcp_format_text();
837        assert!(s.contains("✓ Reference already exists (idempotent)"));
838        assert!(s.contains("Config updated: false"));
839        assert!(!s.contains("Warnings:"));
840    }
841
842    #[test]
843    fn test_add_reference_ok_format_no_mapping() {
844        let ok = AddReferenceOk {
845            url: "https://github.com/org/repo".into(),
846            org: "org".into(),
847            repo: "repo".into(),
848            mount_path: "references/org/repo".into(),
849            mount_target: "/abs/.thoughts-data/references/org/repo".into(),
850            mapping_path: None,
851            already_existed: false,
852            config_updated: true,
853            cloned: false,
854            mounted: false,
855            warnings: vec!["Clone failed".into()],
856        };
857        let s = ok.mcp_format_text();
858        assert!(s.contains("Mapping: <none>"));
859        assert!(s.contains("Mounted: false"));
860        assert!(s.contains("- Clone failed"));
861    }
862
863    #[test]
864    fn test_template_response_format_research() {
865        let resp = TemplateResponse {
866            template_type: TemplateType::Research,
867        };
868        let s = resp.mcp_format_text();
869        assert!(s.starts_with("Here is the research template:"));
870        assert!(s.contains("```markdown"));
871        // spot-check content from the research template
872        assert!(s.contains("# Research: [Topic]"));
873        // research guidance presence
874        assert!(s.contains("Stop. Before writing this document"));
875    }
876
877    #[test]
878    fn test_template_variants_non_empty() {
879        let all = [
880            TemplateType::Research,
881            TemplateType::Plan,
882            TemplateType::Requirements,
883            TemplateType::PrDescription,
884        ];
885        for t in all {
886            assert!(
887                !t.content().trim().is_empty(),
888                "Embedded content unexpectedly empty for {:?}",
889                t
890            );
891            assert!(
892                !t.label().trim().is_empty(),
893                "Label unexpectedly empty for {:?}",
894                t
895            );
896        }
897    }
898
899    // =========================================================================
900    // Logging failure isolation tests
901    // =========================================================================
902
903    #[test]
904    fn test_log_tool_call_does_not_panic_when_logs_unavailable() {
905        // When active_logs_dir() fails (e.g., no active branch), log_tool_call
906        // should return silently without panicking or affecting caller
907        let timer = CallTimer::start();
908
909        // This should not panic even if logs directory is unavailable
910        log_tool_call(
911            &timer,
912            "test_tool",
913            serde_json::json!({"param": "value"}),
914            true,
915            None,
916            Some(serde_json::json!({"result": "success"})),
917        );
918        // Test passes if we reach here without panic
919    }
920
921    #[test]
922    fn test_log_tool_call_with_error_does_not_panic() {
923        // Logging an error result should also be graceful
924        let timer = CallTimer::start();
925
926        log_tool_call(
927            &timer,
928            "failing_tool",
929            serde_json::json!({"bad": "input"}),
930            false,
931            Some("Operation failed".into()),
932            None,
933        );
934        // Test passes if we reach here without panic
935    }
936
937    #[test]
938    fn test_log_tool_call_request_json_shape_write_document() {
939        // Verify the expected request JSON structure for write_document
940        let req = serde_json::json!({
941            "doc_type": "plan",
942            "filename": "my_plan.md",
943        });
944
945        assert!(req.get("doc_type").is_some());
946        assert!(req.get("filename").is_some());
947        assert!(req["doc_type"].is_string());
948        assert!(req["filename"].is_string());
949    }
950
951    #[test]
952    fn test_log_tool_call_request_json_shape_list_active_documents() {
953        // Verify the expected request JSON structure for list_active_documents
954        let req = serde_json::json!({
955            "subdir": "research",
956        });
957
958        assert!(req.get("subdir").is_some());
959
960        // Also test with null subdir
961        let req_null = serde_json::json!({
962            "subdir": null,
963        });
964        assert!(req_null["subdir"].is_null());
965    }
966
967    #[test]
968    fn test_log_tool_call_request_json_shape_add_reference() {
969        // Verify the expected request JSON structure for add_reference
970        let req = serde_json::json!({
971            "url": "https://github.com/org/repo",
972            "description": "Reference implementation",
973        });
974
975        assert!(req.get("url").is_some());
976        assert!(req.get("description").is_some());
977        assert!(req["url"].is_string());
978    }
979
980    #[test]
981    fn test_log_tool_call_summary_shapes() {
982        // Verify summary JSON structures for different tools
983        let write_doc_summary = serde_json::json!({
984            "path": "./thoughts/branch/plans/file.md",
985            "bytes_written": 1024,
986        });
987        assert!(write_doc_summary["path"].is_string());
988        assert!(write_doc_summary["bytes_written"].is_number());
989
990        let list_docs_summary = serde_json::json!({
991            "base": "./thoughts/branch",
992            "files_count": 5,
993        });
994        assert!(list_docs_summary["base"].is_string());
995        assert!(list_docs_summary["files_count"].is_number());
996
997        let add_ref_summary = serde_json::json!({
998            "org": "someorg",
999            "repo": "somerepo",
1000            "already_existed": false,
1001            "config_updated": true,
1002            "cloned": true,
1003            "mounted": true,
1004        });
1005        assert!(add_ref_summary["org"].is_string());
1006        assert!(add_ref_summary["repo"].is_string());
1007        assert!(add_ref_summary["already_existed"].is_boolean());
1008        assert!(add_ref_summary["config_updated"].is_boolean());
1009    }
1010}