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