1use 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")); }
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}