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