1use 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}