Skip to main content

thoughts_tool/
fmt.rs

1//! TextFormat implementations for thoughts_tool output types.
2//!
3//! These implementations produce identical output to the McpFormatter
4//! implementations, preserving Unicode symbols (checkmarks, dashes) for human-readable output.
5
6use agentic_tools_core::fmt::{TextFormat, TextOptions};
7
8use crate::documents::{ActiveDocuments, WriteDocumentOk};
9use crate::mcp::{AddReferenceOk, ReferencesList, TemplateResponse};
10use crate::utils::human_size;
11
12impl TextFormat for WriteDocumentOk {
13    fn fmt_text(&self, _opts: &TextOptions) -> String {
14        format!(
15            "\u{2713} Created {}\n  Size: {}",
16            self.path,
17            human_size(self.bytes_written)
18        )
19    }
20}
21
22impl TextFormat for ActiveDocuments {
23    fn fmt_text(&self, _opts: &TextOptions) -> String {
24        if self.files.is_empty() {
25            return format!(
26                "Active base: {}\nFiles (relative to base):\n<none>",
27                self.base
28            );
29        }
30        let mut out = format!("Active base: {}\nFiles (relative to base):", self.base);
31        for f in &self.files {
32            let rel = f
33                .path
34                .strip_prefix(&format!("{}/", self.base.trim_end_matches('/')))
35                .unwrap_or(&f.path);
36            let ts = match chrono::DateTime::parse_from_rfc3339(&f.modified) {
37                Ok(dt) => dt
38                    .with_timezone(&chrono::Utc)
39                    .format("%Y-%m-%d %H:%M UTC")
40                    .to_string(),
41                Err(_) => f.modified.clone(),
42            };
43            out.push_str(&format!("\n{} @ {}", rel, ts));
44        }
45        out
46    }
47}
48
49impl TextFormat for ReferencesList {
50    fn fmt_text(&self, _opts: &TextOptions) -> String {
51        if self.entries.is_empty() {
52            return format!("References base: {}\n<none>", self.base);
53        }
54        let mut out = format!("References base: {}", self.base);
55        for e in &self.entries {
56            let rel = e
57                .path
58                .strip_prefix(&format!("{}/", self.base.trim_end_matches('/')))
59                .unwrap_or(&e.path);
60            match &e.description {
61                Some(desc) if !desc.trim().is_empty() => {
62                    out.push_str(&format!("\n{} \u{2014} {}", rel, desc));
63                }
64                _ => {
65                    out.push_str(&format!("\n{}", rel));
66                }
67            }
68        }
69        out
70    }
71}
72
73impl TextFormat for AddReferenceOk {
74    fn fmt_text(&self, _opts: &TextOptions) -> String {
75        let mut out = String::new();
76        if self.already_existed {
77            out.push_str("\u{2713} Reference already exists (idempotent)\n");
78        } else {
79            out.push_str("\u{2713} Added reference\n");
80        }
81        out.push_str(&format!(
82            "  URL: {}\n  Org/Repo: {}/{}\n  Mount: {}\n  Target: {}",
83            self.url, self.org, self.repo, self.mount_path, self.mount_target
84        ));
85        if let Some(mp) = &self.mapping_path {
86            out.push_str(&format!("\n  Mapping: {}", mp));
87        } else {
88            out.push_str("\n  Mapping: <none>");
89        }
90        out.push_str(&format!(
91            "\n  Config updated: {}\n  Cloned: {}\n  Mounted: {}",
92            self.config_updated, self.cloned, self.mounted
93        ));
94        if !self.warnings.is_empty() {
95            out.push_str("\nWarnings:");
96            for w in &self.warnings {
97                out.push_str(&format!("\n- {}", w));
98            }
99        }
100        out
101    }
102}
103
104impl TextFormat for TemplateResponse {
105    fn fmt_text(&self, _opts: &TextOptions) -> String {
106        let ty = self.template_type.label();
107        let content = self.template_type.content();
108        let guidance = self.template_type.guidance();
109        format!(
110            "Here is the {} template:\n\n```markdown\n{}\n```\n\n{}",
111            ty, content, guidance
112        )
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use crate::documents::DocumentInfo;
120    use crate::mcp::{ReferenceItem, TemplateType};
121
122    #[test]
123    fn write_document_text_format() {
124        let v = WriteDocumentOk {
125            path: "./thoughts/x/research/test.md".into(),
126            bytes_written: 2048,
127        };
128        let tf = v.fmt_text(&TextOptions::default());
129        assert!(tf.contains("\u{2713} Created"));
130        assert!(tf.contains("2.0 KB"));
131    }
132
133    #[test]
134    fn active_documents_empty_text_format() {
135        let docs = ActiveDocuments {
136            base: "./thoughts/branch".into(),
137            files: vec![],
138        };
139        let tf = docs.fmt_text(&TextOptions::default());
140        assert!(tf.contains("<none>"));
141    }
142
143    #[test]
144    fn active_documents_with_files_text_format() {
145        let docs = ActiveDocuments {
146            base: "./thoughts/feature".into(),
147            files: vec![DocumentInfo {
148                path: "./thoughts/feature/research/test.md".into(),
149                doc_type: "research".into(),
150                size: 1024,
151                modified: "2025-10-15T12:00:00Z".into(),
152            }],
153        };
154        let tf = docs.fmt_text(&TextOptions::default());
155        assert!(tf.contains("research/test.md"));
156    }
157
158    #[test]
159    fn references_list_empty_text_format() {
160        let refs = ReferencesList {
161            base: "references".into(),
162            entries: vec![],
163        };
164        let tf = refs.fmt_text(&TextOptions::default());
165        assert!(tf.contains("<none>"));
166    }
167
168    #[test]
169    fn references_list_with_descriptions_text_format() {
170        let refs = ReferencesList {
171            base: "references".into(),
172            entries: vec![
173                ReferenceItem {
174                    path: "references/org/repo1".into(),
175                    description: Some("First repo".into()),
176                },
177                ReferenceItem {
178                    path: "references/org/repo2".into(),
179                    description: None,
180                },
181            ],
182        };
183        let tf = refs.fmt_text(&TextOptions::default());
184        assert!(tf.contains("org/repo1 \u{2014} First repo"));
185        assert!(tf.contains("org/repo2"));
186    }
187
188    #[test]
189    fn add_reference_ok_text_format() {
190        let ok = AddReferenceOk {
191            url: "https://github.com/org/repo".into(),
192            org: "org".into(),
193            repo: "repo".into(),
194            mount_path: "references/org/repo".into(),
195            mount_target: "/abs/.thoughts-data/references/org/repo".into(),
196            mapping_path: Some("/home/user/.thoughts/clones/repo".into()),
197            already_existed: false,
198            config_updated: true,
199            cloned: true,
200            mounted: true,
201            warnings: vec!["note".into()],
202        };
203        let tf = ok.fmt_text(&TextOptions::default());
204        assert!(tf.contains("\u{2713} Added reference"));
205        assert!(tf.contains("Warnings:\n- note"));
206    }
207
208    #[test]
209    fn add_reference_ok_already_existed_text_format() {
210        let ok = AddReferenceOk {
211            url: "https://github.com/org/repo".into(),
212            org: "org".into(),
213            repo: "repo".into(),
214            mount_path: "references/org/repo".into(),
215            mount_target: "/abs/.thoughts-data/references/org/repo".into(),
216            mapping_path: None,
217            already_existed: true,
218            config_updated: false,
219            cloned: false,
220            mounted: true,
221            warnings: vec![],
222        };
223        let tf = ok.fmt_text(&TextOptions::default());
224        assert!(tf.contains("\u{2713} Reference already exists (idempotent)"));
225        assert!(tf.contains("Mapping: <none>"));
226    }
227
228    #[test]
229    fn template_response_text_format() {
230        let resp = TemplateResponse {
231            template_type: TemplateType::Research,
232        };
233        let tf = resp.fmt_text(&TextOptions::default());
234        assert!(tf.starts_with("Here is the research template:"));
235        assert!(tf.contains("```markdown"));
236    }
237}