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, RepoRefsList, 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 RepoRefsList {
74    fn fmt_text(&self, _opts: &TextOptions) -> String {
75        if self.entries.is_empty() {
76            return format!("Remote refs for {}\n<none>", self.url);
77        }
78
79        let mut out = if self.truncated {
80            format!(
81                "Remote refs for {} (showing {} of {}):",
82                self.url,
83                self.entries.len(),
84                self.total
85            )
86        } else {
87            format!("Remote refs for {} ({}):", self.url, self.total)
88        };
89
90        for entry in &self.entries {
91            out.push_str(&format!("\n{}", entry.name));
92            if let Some(oid) = &entry.oid {
93                out.push_str(&format!(" oid={oid}"));
94            }
95            if let Some(peeled) = &entry.peeled {
96                out.push_str(&format!(" peeled={peeled}"));
97            }
98            if let Some(target) = &entry.target {
99                out.push_str(&format!(" target={target}"));
100            }
101        }
102
103        out
104    }
105}
106
107impl TextFormat for AddReferenceOk {
108    fn fmt_text(&self, _opts: &TextOptions) -> String {
109        let mut out = String::new();
110        if self.already_existed {
111            out.push_str("\u{2713} Reference already exists (idempotent)\n");
112        } else {
113            out.push_str("\u{2713} Added reference\n");
114        }
115        out.push_str(&format!(
116            "  URL: {}\n  Org/Repo: {}/{}",
117            self.url, self.org, self.repo
118        ));
119        if let Some(ref_name) = &self.ref_name {
120            out.push_str(&format!("\n  Ref: {}", ref_name));
121        }
122        out.push_str(&format!(
123            "\n  Mount: {}\n  Target: {}",
124            self.mount_path, self.mount_target
125        ));
126        if let Some(mp) = &self.mapping_path {
127            out.push_str(&format!("\n  Mapping: {}", mp));
128        } else {
129            out.push_str("\n  Mapping: <none>");
130        }
131        out.push_str(&format!(
132            "\n  Config updated: {}\n  Cloned: {}\n  Mounted: {}",
133            self.config_updated, self.cloned, self.mounted
134        ));
135        if !self.warnings.is_empty() {
136            out.push_str("\nWarnings:");
137            for w in &self.warnings {
138                out.push_str(&format!("\n- {}", w));
139            }
140        }
141        out
142    }
143}
144
145impl TextFormat for TemplateResponse {
146    fn fmt_text(&self, _opts: &TextOptions) -> String {
147        let ty = self.template_type.label();
148        let content = self.template_type.content();
149        let guidance = self.template_type.guidance();
150        format!(
151            "Here is the {} template:\n\n```markdown\n{}\n```\n\n{}",
152            ty, content, guidance
153        )
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use crate::documents::DocumentInfo;
161    use crate::git::remote_refs::RemoteRef;
162    use crate::mcp::{ReferenceItem, TemplateType};
163
164    #[test]
165    fn write_document_text_format() {
166        let v = WriteDocumentOk {
167            path: "./thoughts/x/research/test.md".into(),
168            bytes_written: 2048,
169        };
170        let tf = v.fmt_text(&TextOptions::default());
171        assert!(tf.contains("\u{2713} Created"));
172        assert!(tf.contains("2.0 KB"));
173    }
174
175    #[test]
176    fn active_documents_empty_text_format() {
177        let docs = ActiveDocuments {
178            base: "./thoughts/branch".into(),
179            files: vec![],
180        };
181        let tf = docs.fmt_text(&TextOptions::default());
182        assert!(tf.contains("<none>"));
183    }
184
185    #[test]
186    fn active_documents_with_files_text_format() {
187        let docs = ActiveDocuments {
188            base: "./thoughts/feature".into(),
189            files: vec![DocumentInfo {
190                path: "./thoughts/feature/research/test.md".into(),
191                doc_type: "research".into(),
192                size: 1024,
193                modified: "2025-10-15T12:00:00Z".into(),
194            }],
195        };
196        let tf = docs.fmt_text(&TextOptions::default());
197        assert!(tf.contains("research/test.md"));
198    }
199
200    #[test]
201    fn references_list_empty_text_format() {
202        let refs = ReferencesList {
203            base: "references".into(),
204            entries: vec![],
205        };
206        let tf = refs.fmt_text(&TextOptions::default());
207        assert!(tf.contains("<none>"));
208    }
209
210    #[test]
211    fn references_list_with_descriptions_text_format() {
212        let refs = ReferencesList {
213            base: "references".into(),
214            entries: vec![
215                ReferenceItem {
216                    path: "references/org/repo1".into(),
217                    description: Some("First repo".into()),
218                },
219                ReferenceItem {
220                    path: "references/org/repo2".into(),
221                    description: None,
222                },
223            ],
224        };
225        let tf = refs.fmt_text(&TextOptions::default());
226        assert!(tf.contains("org/repo1 \u{2014} First repo"));
227        assert!(tf.contains("org/repo2"));
228    }
229
230    #[test]
231    fn repo_refs_list_text_format() {
232        let refs = RepoRefsList {
233            url: "https://github.com/org/repo".into(),
234            total: 2,
235            truncated: false,
236            entries: vec![
237                RemoteRef {
238                    name: "refs/heads/main".into(),
239                    oid: Some("abc123".into()),
240                    peeled: None,
241                    target: None,
242                },
243                RemoteRef {
244                    name: "HEAD".into(),
245                    oid: Some("abc123".into()),
246                    peeled: None,
247                    target: Some("refs/heads/main".into()),
248                },
249            ],
250        };
251        let tf = refs.fmt_text(&TextOptions::default());
252        assert!(tf.contains("Remote refs for https://github.com/org/repo"));
253        assert!(tf.contains("refs/heads/main oid=abc123"));
254        assert!(tf.contains("HEAD oid=abc123 target=refs/heads/main"));
255    }
256
257    #[test]
258    fn add_reference_ok_text_format() {
259        let ok = AddReferenceOk {
260            url: "https://github.com/org/repo".into(),
261            ref_name: Some("refs/heads/main".into()),
262            org: "org".into(),
263            repo: "repo".into(),
264            mount_path: "references/org/repo".into(),
265            mount_target: "/abs/.thoughts-data/references/org/repo".into(),
266            mapping_path: Some("/home/user/.thoughts/clones/repo".into()),
267            already_existed: false,
268            config_updated: true,
269            cloned: true,
270            mounted: true,
271            warnings: vec!["note".into()],
272        };
273        let tf = ok.fmt_text(&TextOptions::default());
274        assert!(tf.contains("\u{2713} Added reference"));
275        assert!(tf.contains("Ref: refs/heads/main"));
276        assert!(tf.contains("Warnings:\n- note"));
277    }
278
279    #[test]
280    fn add_reference_ok_already_existed_text_format() {
281        let ok = AddReferenceOk {
282            url: "https://github.com/org/repo".into(),
283            ref_name: None,
284            org: "org".into(),
285            repo: "repo".into(),
286            mount_path: "references/org/repo".into(),
287            mount_target: "/abs/.thoughts-data/references/org/repo".into(),
288            mapping_path: None,
289            already_existed: true,
290            config_updated: false,
291            cloned: false,
292            mounted: true,
293            warnings: vec![],
294        };
295        let tf = ok.fmt_text(&TextOptions::default());
296        assert!(tf.contains("\u{2713} Reference already exists (idempotent)"));
297        assert!(tf.contains("Mapping: <none>"));
298    }
299
300    #[test]
301    fn template_response_text_format() {
302        let resp = TemplateResponse {
303            template_type: TemplateType::Research,
304        };
305        let tf = resp.fmt_text(&TextOptions::default());
306        assert!(tf.starts_with("Here is the research template:"));
307        assert!(tf.contains("```markdown"));
308    }
309}