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