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;
7use agentic_tools_core::fmt::TextOptions;
8
9use crate::documents::ActiveDocuments;
10use crate::documents::WriteDocumentOk;
11use crate::mcp::AddReferenceOk;
12use crate::mcp::ReferencesList;
13use crate::mcp::RepoRefsList;
14use crate::mcp::TemplateResponse;
15use crate::utils::human_size;
16
17impl TextFormat for WriteDocumentOk {
18    fn fmt_text(&self, _opts: &TextOptions) -> String {
19        format!(
20            "\u{2713} Created {}\n  Size: {}",
21            self.path,
22            human_size(self.bytes_written)
23        )
24    }
25}
26
27impl TextFormat for ActiveDocuments {
28    fn fmt_text(&self, _opts: &TextOptions) -> String {
29        if self.files.is_empty() {
30            return format!(
31                "Active base: {}\nFiles (relative to base):\n<none>",
32                self.base
33            );
34        }
35        let mut out = format!("Active base: {}\nFiles (relative to base):", self.base);
36        for f in &self.files {
37            let rel = f
38                .path
39                .strip_prefix(&format!("{}/", self.base.trim_end_matches('/')))
40                .unwrap_or(&f.path);
41            let ts = match chrono::DateTime::parse_from_rfc3339(&f.modified) {
42                Ok(dt) => dt
43                    .with_timezone(&chrono::Utc)
44                    .format("%Y-%m-%d %H:%M UTC")
45                    .to_string(),
46                Err(_) => f.modified.clone(),
47            };
48            out.push_str(&format!("\n{} @ {}", rel, ts));
49        }
50        out
51    }
52}
53
54impl TextFormat for ReferencesList {
55    fn fmt_text(&self, _opts: &TextOptions) -> String {
56        if self.entries.is_empty() {
57            return format!("References base: {}\n<none>", self.base);
58        }
59        let mut out = format!("References base: {}", self.base);
60        for e in &self.entries {
61            let rel = e
62                .path
63                .strip_prefix(&format!("{}/", self.base.trim_end_matches('/')))
64                .unwrap_or(&e.path);
65            match &e.description {
66                Some(desc) if !desc.trim().is_empty() => {
67                    out.push_str(&format!("\n{} \u{2014} {}", rel, desc));
68                }
69                _ => {
70                    out.push_str(&format!("\n{}", rel));
71                }
72            }
73        }
74        out
75    }
76}
77
78impl TextFormat for RepoRefsList {
79    fn fmt_text(&self, _opts: &TextOptions) -> String {
80        if self.entries.is_empty() {
81            return format!("Remote refs for {}\n<none>", self.url);
82        }
83
84        let mut out = if self.truncated {
85            format!(
86                "Remote refs for {} (showing {} of {}):",
87                self.url,
88                self.entries.len(),
89                self.total
90            )
91        } else {
92            format!("Remote refs for {} ({}):", self.url, self.total)
93        };
94
95        for entry in &self.entries {
96            out.push_str(&format!("\n{}", entry.name));
97            if let Some(oid) = &entry.oid {
98                out.push_str(&format!(" oid={oid}"));
99            }
100            if let Some(peeled) = &entry.peeled {
101                out.push_str(&format!(" peeled={peeled}"));
102            }
103            if let Some(target) = &entry.target {
104                out.push_str(&format!(" target={target}"));
105            }
106        }
107
108        out
109    }
110}
111
112impl TextFormat for AddReferenceOk {
113    fn fmt_text(&self, _opts: &TextOptions) -> String {
114        let mut out = String::new();
115        if self.already_existed {
116            out.push_str("\u{2713} Reference already exists (idempotent)\n");
117        } else {
118            out.push_str("\u{2713} Added reference\n");
119        }
120        out.push_str(&format!(
121            "  URL: {}\n  Org/Repo: {}/{}",
122            self.url, self.org, self.repo
123        ));
124        if let Some(ref_name) = &self.ref_name {
125            out.push_str(&format!("\n  Ref: {}", ref_name));
126        }
127        out.push_str(&format!(
128            "\n  Mount: {}\n  Target: {}",
129            self.mount_path, self.mount_target
130        ));
131        if let Some(mp) = &self.mapping_path {
132            out.push_str(&format!("\n  Mapping: {}", mp));
133        } else {
134            out.push_str("\n  Mapping: <none>");
135        }
136        out.push_str(&format!(
137            "\n  Config updated: {}\n  Cloned: {}\n  Mounted: {}",
138            self.config_updated, self.cloned, self.mounted
139        ));
140        if !self.warnings.is_empty() {
141            out.push_str("\nWarnings:");
142            for w in &self.warnings {
143                out.push_str(&format!("\n- {}", w));
144            }
145        }
146        out
147    }
148}
149
150impl TextFormat for TemplateResponse {
151    fn fmt_text(&self, _opts: &TextOptions) -> String {
152        let ty = self.template_type.label();
153        let content = self.template_type.content();
154        let guidance = self.template_type.guidance();
155        format!(
156            "Here is the {} template:\n\n```markdown\n{}\n```\n\n{}",
157            ty, content, guidance
158        )
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use crate::documents::DocumentInfo;
166    use crate::git::remote_refs::RemoteRef;
167    use crate::mcp::ReferenceItem;
168    use crate::mcp::TemplateType;
169
170    #[test]
171    fn write_document_text_format() {
172        let v = WriteDocumentOk {
173            path: "./thoughts/x/research/test.md".into(),
174            bytes_written: 2048,
175        };
176        let tf = v.fmt_text(&TextOptions::default());
177        assert!(tf.contains("\u{2713} Created"));
178        assert!(tf.contains("2.0 KB"));
179    }
180
181    #[test]
182    fn active_documents_empty_text_format() {
183        let docs = ActiveDocuments {
184            base: "./thoughts/branch".into(),
185            files: vec![],
186        };
187        let tf = docs.fmt_text(&TextOptions::default());
188        assert!(tf.contains("<none>"));
189    }
190
191    #[test]
192    fn active_documents_with_files_text_format() {
193        let docs = ActiveDocuments {
194            base: "./thoughts/feature".into(),
195            files: vec![DocumentInfo {
196                path: "./thoughts/feature/research/test.md".into(),
197                doc_type: "research".into(),
198                size: 1024,
199                modified: "2025-10-15T12:00:00Z".into(),
200            }],
201        };
202        let tf = docs.fmt_text(&TextOptions::default());
203        assert!(tf.contains("research/test.md"));
204    }
205
206    #[test]
207    fn references_list_empty_text_format() {
208        let refs = ReferencesList {
209            base: "references".into(),
210            entries: vec![],
211        };
212        let tf = refs.fmt_text(&TextOptions::default());
213        assert!(tf.contains("<none>"));
214    }
215
216    #[test]
217    fn references_list_with_descriptions_text_format() {
218        let refs = ReferencesList {
219            base: "references".into(),
220            entries: vec![
221                ReferenceItem {
222                    path: "references/org/repo1".into(),
223                    description: Some("First repo".into()),
224                },
225                ReferenceItem {
226                    path: "references/org/repo2".into(),
227                    description: None,
228                },
229            ],
230        };
231        let tf = refs.fmt_text(&TextOptions::default());
232        assert!(tf.contains("org/repo1 \u{2014} First repo"));
233        assert!(tf.contains("org/repo2"));
234    }
235
236    #[test]
237    fn repo_refs_list_text_format() {
238        let refs = RepoRefsList {
239            url: "https://github.com/org/repo".into(),
240            total: 2,
241            truncated: false,
242            entries: vec![
243                RemoteRef {
244                    name: "refs/heads/main".into(),
245                    oid: Some("abc123".into()),
246                    peeled: None,
247                    target: None,
248                },
249                RemoteRef {
250                    name: "HEAD".into(),
251                    oid: Some("abc123".into()),
252                    peeled: None,
253                    target: Some("refs/heads/main".into()),
254                },
255            ],
256        };
257        let tf = refs.fmt_text(&TextOptions::default());
258        assert!(tf.contains("Remote refs for https://github.com/org/repo"));
259        assert!(tf.contains("refs/heads/main oid=abc123"));
260        assert!(tf.contains("HEAD oid=abc123 target=refs/heads/main"));
261    }
262
263    #[test]
264    fn add_reference_ok_text_format() {
265        let ok = AddReferenceOk {
266            url: "https://github.com/org/repo".into(),
267            ref_name: Some("refs/heads/main".into()),
268            org: "org".into(),
269            repo: "repo".into(),
270            mount_path: "references/org/repo".into(),
271            mount_target: "/abs/.thoughts-data/references/org/repo".into(),
272            mapping_path: Some("/home/user/.thoughts/clones/repo".into()),
273            already_existed: false,
274            config_updated: true,
275            cloned: true,
276            mounted: true,
277            warnings: vec!["note".into()],
278        };
279        let tf = ok.fmt_text(&TextOptions::default());
280        assert!(tf.contains("\u{2713} Added reference"));
281        assert!(tf.contains("Ref: refs/heads/main"));
282        assert!(tf.contains("Warnings:\n- note"));
283    }
284
285    #[test]
286    fn add_reference_ok_already_existed_text_format() {
287        let ok = AddReferenceOk {
288            url: "https://github.com/org/repo".into(),
289            ref_name: None,
290            org: "org".into(),
291            repo: "repo".into(),
292            mount_path: "references/org/repo".into(),
293            mount_target: "/abs/.thoughts-data/references/org/repo".into(),
294            mapping_path: None,
295            already_existed: true,
296            config_updated: false,
297            cloned: false,
298            mounted: true,
299            warnings: vec![],
300        };
301        let tf = ok.fmt_text(&TextOptions::default());
302        assert!(tf.contains("\u{2713} Reference already exists (idempotent)"));
303        assert!(tf.contains("Mapping: <none>"));
304    }
305
306    #[test]
307    fn template_response_text_format() {
308        let resp = TemplateResponse {
309            template_type: TemplateType::Research,
310        };
311        let tf = resp.fmt_text(&TextOptions::default());
312        assert!(tf.starts_with("Here is the research template:"));
313        assert!(tf.contains("```markdown"));
314    }
315}