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
25fn 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, };
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
97fn 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
107impl 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#[derive(Clone, Default)]
251pub struct ThoughtsMcpTools;
252
253#[universal_tool_router(mcp(name = "thoughts_tool", version = "0.3.0"))]
254impl ThoughtsMcpTools {
255 #[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 #[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 #[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 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 #[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 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_reference_url_https_only(&input_url)
491 .map_err(|e| ToolError::invalid_input(e.to_string()))?;
492
493 let (org, repo) = extract_org_repo_from_url(&input_url)
495 .map_err(|e| ToolError::invalid_input(e.to_string()))?;
496
497 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 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 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 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 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 if let Err(e) = update_active_mounts().await {
580 warnings.push(format!("Mount synchronization encountered an error: {}", e));
581 }
582
583 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 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 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 #[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
673pub struct ThoughtsMcpServer {
675 tools: std::sync::Arc<ThoughtsMcpTools>,
676}
677universal_tool_core::implement_mcp_server!(ThoughtsMcpServer, tools);
678
679pub 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 #[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("—")); }
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 assert!(s.contains("# Research: [Topic]"));
873 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 #[test]
904 fn test_log_tool_call_does_not_panic_when_logs_unavailable() {
905 let timer = CallTimer::start();
908
909 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 }
920
921 #[test]
922 fn test_log_tool_call_with_error_does_not_panic() {
923 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 }
936
937 #[test]
938 fn test_log_tool_call_request_json_shape_write_document() {
939 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 let req = serde_json::json!({
955 "subdir": "research",
956 });
957
958 assert!(req.get("subdir").is_some());
959
960 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 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 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}