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