1use devboy_core::{Pagination, Result, SortInfo};
7use devboy_format_pipeline::{OutputFormat, Pipeline, PipelineConfig};
8use serde::Serialize;
9
10use crate::output::ToolOutput;
11
12#[derive(Debug, Clone, Serialize)]
26pub struct FormatMetadata {
27 pub raw_chars: usize,
29 pub output_chars: usize,
31 pub pre_trim_chars: usize,
36 pub estimated_tokens: usize,
38 pub compression_ratio: f32,
40 pub format: String,
41 pub truncated: bool,
43 pub total_items: Option<usize>,
45 pub included_items: usize,
47 pub chunked: bool,
49 pub total_chunks: usize,
51 pub chunk_number: usize,
53 pub provider_pagination: Option<Pagination>,
55 pub provider_sort: Option<SortInfo>,
57 #[serde(default)]
62 pub dedup_savings_pct: f32,
63 #[serde(default)]
68 pub encoder_savings_pct: f32,
69 #[serde(default)]
73 pub combined_savings_pct: f32,
74 #[serde(default)]
79 pub baseline: String,
80 #[serde(default)]
84 pub tokenizer: String,
85}
86
87#[derive(Debug, Clone, Serialize)]
89pub struct FormatResult {
90 pub content: String,
92 pub metadata: FormatMetadata,
94}
95
96pub fn format_output(
106 output: ToolOutput,
107 format: Option<&str>,
108 _tool_name: Option<&str>,
109 config: Option<PipelineConfig>,
110) -> Result<FormatResult> {
111 let output_format = match format {
112 Some("json") => OutputFormat::Json,
113 Some("mckp") => OutputFormat::Mckp,
114 _ => OutputFormat::Toon,
115 };
116
117 let pipeline_config = config.unwrap_or_else(|| PipelineConfig {
118 format: output_format,
119 ..PipelineConfig::default()
120 });
121
122 let pipeline_config = PipelineConfig {
124 format: output_format,
125 ..pipeline_config
126 };
127
128 let format_name = match output_format {
129 OutputFormat::Json => "json",
130 OutputFormat::Toon => "toon",
131 OutputFormat::Mckp => "mckp",
132 };
133
134 let baseline = "json_pretty";
149 let tokenizer = "heuristic";
150 let token_counter = devboy_format_pipeline::Tokenizer::Heuristic;
151
152 let requested_chunk = pipeline_config.chunk.unwrap_or(1);
153 let pipeline = Pipeline::with_config(pipeline_config);
154
155 let provider_pagination = output.result_meta().and_then(|m| m.pagination.clone());
157 let provider_sort = output.result_meta().and_then(|m| m.sort_info.clone());
158
159 let baseline_for_helper = baseline.to_string();
161 let tokenizer_for_helper = tokenizer.to_string();
162 let to_result = |t: devboy_format_pipeline::TransformOutput,
163 pag: Option<Pagination>,
164 sort: Option<SortInfo>|
165 -> FormatResult {
166 let content_chars = t.output_chars;
169 let content = t.to_string_with_hints();
170 let raw_chars = if t.raw_chars > 0 {
171 t.raw_chars
172 } else {
173 content_chars
174 };
175 let pre_trim = if t.pre_trim_chars > 0 {
176 t.pre_trim_chars
177 } else {
178 content_chars
179 };
180 let (chunked, total_chunks) = match &t.page_index {
182 Some(idx) if idx.total_pages > 1 => (true, idx.total_pages),
183 _ => (false, 1),
184 };
185 let chunk_number = requested_chunk;
186
187 let baseline_tokens = if raw_chars > 0 {
194 (raw_chars as f64 / 3.5).ceil() as usize
198 } else {
199 0
200 };
201 let final_tokens = (content_chars as f64 / 3.5).ceil() as usize;
202 let encoder_savings_pct = if baseline_tokens > 0 {
203 ((baseline_tokens.saturating_sub(final_tokens)) as f32) / (baseline_tokens as f32)
204 } else {
205 0.0
206 };
207 let combined_savings_pct = encoder_savings_pct;
209
210 FormatResult {
211 metadata: FormatMetadata {
212 raw_chars,
213 output_chars: content_chars,
216 pre_trim_chars: pre_trim,
217 estimated_tokens: token_counter.count(&content),
219 compression_ratio: if raw_chars > 0 {
220 content_chars as f32 / raw_chars as f32
221 } else {
222 1.0
223 },
224 format: format_name.to_string(),
225 truncated: t.truncated,
226 total_items: t.total_count,
227 included_items: t.included_count,
228 chunked,
229 total_chunks,
230 chunk_number,
231 provider_pagination: pag,
232 provider_sort: sort,
233 dedup_savings_pct: 0.0,
234 encoder_savings_pct,
235 combined_savings_pct,
236 baseline: baseline_for_helper.clone(),
237 tokenizer: tokenizer_for_helper.clone(),
238 },
239 content,
240 }
241 };
242
243 let baseline_for_text = baseline.to_string();
245 let tokenizer_for_text = tokenizer.to_string();
246 let text_result =
247 |text: String, pag: Option<Pagination>, sort: Option<SortInfo>| -> FormatResult {
248 let chars = text.len();
249 FormatResult {
250 metadata: FormatMetadata {
251 raw_chars: chars,
252 output_chars: chars,
253 pre_trim_chars: chars,
254 estimated_tokens: token_counter.count(&text),
255 compression_ratio: 1.0,
256 format: "text".to_string(),
257 truncated: false,
258 total_items: None,
259 included_items: 0,
260 chunked: false,
261 total_chunks: 1,
262 chunk_number: 1,
263 provider_pagination: pag,
264 provider_sort: sort,
265 dedup_savings_pct: 0.0,
266 encoder_savings_pct: 0.0,
267 combined_savings_pct: 0.0,
268 baseline: baseline_for_text.clone(),
269 tokenizer: tokenizer_for_text.clone(),
270 },
271 content: text,
272 }
273 };
274
275 match output {
276 ToolOutput::Issues(issues, _) => Ok(to_result(
277 pipeline.transform_issues(issues)?,
278 provider_pagination,
279 provider_sort,
280 )),
281 ToolOutput::SingleIssue(issue) => Ok(to_result(
282 pipeline.transform_issues(vec![*issue])?,
283 None,
284 None,
285 )),
286 ToolOutput::MergeRequests(mrs, _) => Ok(to_result(
287 pipeline.transform_merge_requests(mrs)?,
288 provider_pagination,
289 provider_sort,
290 )),
291 ToolOutput::SingleMergeRequest(mr) => Ok(to_result(
292 pipeline.transform_merge_requests(vec![*mr])?,
293 None,
294 None,
295 )),
296 ToolOutput::Discussions(discussions, _) => Ok(to_result(
297 pipeline.transform_discussions(discussions)?,
298 provider_pagination,
299 provider_sort,
300 )),
301 ToolOutput::Diffs(diffs, _) => Ok(to_result(
302 pipeline.transform_diffs(diffs)?,
303 provider_pagination,
304 provider_sort,
305 )),
306 ToolOutput::Comments(comments, _) => Ok(to_result(
307 pipeline.transform_comments(comments)?,
308 provider_pagination,
309 provider_sort,
310 )),
311 ToolOutput::Pipeline(info) => Ok(text_result(format_pipeline(&info), None, None)),
312 ToolOutput::JobLog(log) => Ok(text_result(format_job_log(&log), None, None)),
313 ToolOutput::Statuses(statuses, _) => Ok(text_result(
314 format_statuses(&statuses),
315 provider_pagination,
316 provider_sort,
317 )),
318 ToolOutput::Users(users, _) => Ok(text_result(
319 format_users(&users),
320 provider_pagination,
321 provider_sort,
322 )),
323 ToolOutput::MeetingNotes(meetings, _) => Ok(text_result(
324 format_meeting_notes(&meetings),
325 provider_pagination,
326 provider_sort,
327 )),
328 ToolOutput::MeetingTranscript(transcript) => Ok(text_result(
329 format_meeting_transcript(&transcript),
330 None,
331 None,
332 )),
333 ToolOutput::KnowledgeBaseSpaces(spaces, _) => Ok(text_result(
334 format_knowledge_base_spaces(&spaces),
335 provider_pagination,
336 provider_sort,
337 )),
338 ToolOutput::KnowledgeBasePages(pages, _) => Ok(text_result(
339 format_knowledge_base_pages(&pages),
340 provider_pagination,
341 provider_sort,
342 )),
343 ToolOutput::KnowledgeBasePageSummary(page) => Ok(text_result(
344 format_knowledge_base_page_summary(&page),
345 None,
346 None,
347 )),
348 ToolOutput::KnowledgeBasePage(page) => {
349 Ok(text_result(format_knowledge_base_page(&page), None, None))
350 }
351 ToolOutput::Relations(relations) => {
352 let json = serde_json::to_string_pretty(&*relations).map_err(|e| {
353 devboy_core::Error::InvalidData(format!("failed to serialize relations: {e}"))
354 })?;
355 Ok(text_result(json, None, None))
356 }
357 ToolOutput::MessengerChats(chats, _) => Ok(text_result(
358 format_messenger_chats(&chats),
359 provider_pagination,
360 provider_sort,
361 )),
362 ToolOutput::MessengerMessages(messages, _) => Ok(text_result(
363 format_messenger_messages(&messages),
364 provider_pagination,
365 provider_sort,
366 )),
367 ToolOutput::SingleMessage(message) => Ok(text_result(
368 format_single_messenger_message(&message),
369 None,
370 None,
371 )),
372 ToolOutput::AssetList {
373 attachments,
374 count,
375 capabilities,
376 } => {
377 let output = serde_json::json!({
378 "attachments": attachments,
379 "count": count,
380 "capabilities": capabilities,
381 });
382 Ok(text_result(
383 serde_json::to_string_pretty(&output).unwrap_or_default(),
384 None,
385 None,
386 ))
387 }
388 ToolOutput::AssetDownloaded {
389 asset_id,
390 size,
391 local_path,
392 data,
393 cached,
394 } => {
395 let output = serde_json::json!({
396 "success": true,
397 "asset_id": asset_id,
398 "size": size,
399 "local_path": local_path,
400 "data": data,
401 "cached": cached,
402 });
403 Ok(text_result(
404 serde_json::to_string_pretty(&output).unwrap_or_default(),
405 None,
406 None,
407 ))
408 }
409 ToolOutput::AssetUploaded {
410 url,
411 filename,
412 size,
413 } => {
414 let output = serde_json::json!({
415 "success": true,
416 "url": url,
417 "filename": filename,
418 "size": size,
419 });
420 Ok(text_result(
421 serde_json::to_string_pretty(&output).unwrap_or_default(),
422 None,
423 None,
424 ))
425 }
426 ToolOutput::AssetDeleted { asset_id, message } => {
427 let output = serde_json::json!({
428 "success": true,
429 "asset_id": asset_id,
430 "message": message,
431 });
432 Ok(text_result(
433 serde_json::to_string_pretty(&output).unwrap_or_default(),
434 None,
435 None,
436 ))
437 }
438 ToolOutput::Structures(items, _meta) => {
442 let json = serde_json::to_string_pretty(&items).map_err(|e| {
443 devboy_core::Error::InvalidData(format!("failed to serialize structures: {e}"))
444 })?;
445 Ok(text_result(json, None, None))
446 }
447 ToolOutput::StructureForest(forest) => {
448 let json = serde_json::to_string_pretty(&*forest).map_err(|e| {
449 devboy_core::Error::InvalidData(format!(
450 "failed to serialize structure forest: {e}"
451 ))
452 })?;
453 Ok(text_result(json, None, None))
454 }
455 ToolOutput::StructureValues(values) => {
456 let json = serde_json::to_string_pretty(&*values).map_err(|e| {
457 devboy_core::Error::InvalidData(format!(
458 "failed to serialize structure values: {e}"
459 ))
460 })?;
461 Ok(text_result(json, None, None))
462 }
463 ToolOutput::StructureViews(views, _meta) => {
464 let json = serde_json::to_string_pretty(&views).map_err(|e| {
465 devboy_core::Error::InvalidData(format!("failed to serialize structure views: {e}"))
466 })?;
467 Ok(text_result(json, None, None))
468 }
469 ToolOutput::ForestModified(result) => {
470 let json = serde_json::to_string_pretty(&result).map_err(|e| {
471 devboy_core::Error::InvalidData(format!(
472 "failed to serialize forest modification result: {e}"
473 ))
474 })?;
475 Ok(text_result(json, None, None))
476 }
477 ToolOutput::ProjectVersions(versions, _meta) => Ok(text_result(
478 format_project_versions(&versions, provider_pagination.as_ref()),
479 provider_pagination,
480 provider_sort,
481 )),
482 ToolOutput::SingleProjectVersion(version) => Ok(text_result(
483 format_single_project_version(&version),
484 None,
485 None,
486 )),
487 ToolOutput::Sprints(sprints, _meta) => Ok(text_result(
488 format_sprints(&sprints),
489 provider_pagination,
490 provider_sort,
491 )),
492 ToolOutput::CustomFields(fields, _meta) => Ok(text_result(
493 format_custom_fields(&fields, provider_pagination.as_ref()),
494 provider_pagination,
495 provider_sort,
496 )),
497 ToolOutput::Text(text) => Ok(text_result(text, None, None)),
498 }
499}
500
501fn format_messenger_chats(chats: &[devboy_core::MessengerChat]) -> String {
503 if chats.is_empty() {
504 return "No chats found.".to_string();
505 }
506
507 let mut output = format!("# Messenger Chats ({})\n\n", chats.len());
508 for chat in chats {
509 let description = chat.description.as_deref().unwrap_or("-");
510 let members = chat
511 .member_count
512 .map(|count| count.to_string())
513 .unwrap_or_else(|| "-".to_string());
514 let active = if chat.is_active { "active" } else { "inactive" };
515 let chat_type = match chat.chat_type {
516 devboy_core::types::ChatType::Direct => "direct",
517 devboy_core::types::ChatType::Group => "group",
518 devboy_core::types::ChatType::Channel => "channel",
519 };
520 output.push_str(&format!(
521 "- {} [{}] id=`{}` members={} status={} desc={}\n",
522 chat.name, chat_type, chat.id, members, active, description
523 ));
524 }
525 output
526}
527
528fn format_messenger_messages(messages: &[devboy_core::MessengerMessage]) -> String {
530 if messages.is_empty() {
531 return "No messages found.".to_string();
532 }
533
534 let mut output = format!("# Messages ({})\n\n", messages.len());
535 for message in messages {
536 output.push_str(&format_single_messenger_message(message));
537 output.push('\n');
538 }
539 output
540}
541
542fn format_single_messenger_message(message: &devboy_core::MessengerMessage) -> String {
544 let text = message.text.replace('\r', "\\r").replace('\n', "\\n");
545 let mut line = format!(
546 "- [{}] {} ({}) in `{}`: {}",
547 message.timestamp, message.author.name, message.author.id, message.chat_id, text
548 );
549 if let Some(thread_id) = message.thread_id.as_deref() {
550 line.push_str(&format!(" thread=`{}`", thread_id));
551 }
552 if !message.attachments.is_empty() {
553 line.push_str(&format!(" attachments={}", message.attachments.len()));
554 }
555 line
556}
557
558fn format_statuses(statuses: &[devboy_core::IssueStatus]) -> String {
560 if statuses.is_empty() {
561 return "No statuses found.".to_string();
562 }
563
564 let mut output = String::from("# Available Statuses\n\n");
565 output.push_str("| ID | Name | Category | Color | Order |\n");
566 output.push_str("|---|---|---|---|---|\n");
567
568 for s in statuses {
569 let color = s.color.as_deref().unwrap_or("-");
570 let order = s
571 .order
572 .map(|o| o.to_string())
573 .unwrap_or_else(|| "-".to_string());
574 output.push_str(&format!(
575 "| {} | {} | {} | {} | {} |\n",
576 s.id, s.name, s.category, color, order
577 ));
578 }
579
580 output
581}
582
583fn format_project_versions(
594 versions: &[devboy_core::ProjectVersion],
595 pagination: Option<&devboy_core::Pagination>,
596) -> String {
597 if versions.is_empty() {
598 return "No project versions found.".to_string();
599 }
600
601 let total = pagination
602 .and_then(|p| p.total)
603 .unwrap_or(versions.len() as u32);
604 let shown = versions.len() as u32;
605 let header = if total > shown {
606 format!("# Project Versions ({} of {})\n\n", shown, total)
607 } else {
608 format!("# Project Versions ({})\n\n", shown)
609 };
610 let mut output = header;
611 output.push_str("| Name | Released | Release Date | Issues | Description |\n");
612 output.push_str("|---|---|---|---|---|\n");
613
614 for v in versions {
615 let released = if v.released { "yes" } else { "no" };
616 let release_date = v.release_date.as_deref().unwrap_or("-");
617 let issue_count = match (v.issue_count, v.unresolved_issue_count) {
622 (Some(t), Some(u)) => format!("{t} ({u} open)"),
623 (Some(t), None) => t.to_string(),
624 (None, Some(u)) => format!("{u} open"),
625 (None, None) => "-".to_string(),
626 };
627 let description = match v.description.as_deref() {
628 None | Some("") => "-".to_string(),
629 Some(d) => escape_table_cell(&truncate_for_table(d, 120)),
630 };
631 let archived_marker = if v.archived { " (archived)" } else { "" };
632 output.push_str(&format!(
633 "| {}{} | {} | {} | {} | {} |\n",
634 escape_table_cell(&v.name),
635 archived_marker,
636 released,
637 release_date,
638 issue_count,
639 description
640 ));
641 }
642
643 if total > shown {
644 let omitted = total - shown;
645 let suggested_limit = total.min(MAX_VERSION_LIMIT);
651 output.push_str(&format!(
652 "\n[+{omitted} more — call with `limit: {suggested_limit}` (or `archived: \"all\"` to include archived versions)]\n"
653 ));
654 }
655
656 output
657}
658
659fn format_sprints(sprints: &[devboy_core::Sprint]) -> String {
661 if sprints.is_empty() {
662 return "No sprints found.".to_string();
663 }
664
665 let mut output = format!("# Sprints ({})\n\n", sprints.len());
666 output.push_str("| Id | Name | State | Start | End | Goal |\n");
667 output.push_str("|---|---|---|---|---|---|\n");
668 for s in sprints {
669 let start = s.start_date.as_deref().unwrap_or("-");
670 let end = s.end_date.as_deref().unwrap_or("-");
671 let goal = match s.goal.as_deref() {
672 None | Some("") => "-".to_string(),
673 Some(g) => escape_table_cell(&truncate_for_table(g, 120)),
674 };
675 output.push_str(&format!(
676 "| {} | {} | {} | {} | {} | {} |\n",
677 s.id,
678 escape_table_cell(&s.name),
679 s.state,
680 start,
681 end,
682 goal,
683 ));
684 }
685 output
686}
687
688fn format_custom_fields(
690 fields: &[devboy_core::CustomFieldDescriptor],
691 pagination: Option<&devboy_core::Pagination>,
692) -> String {
693 if fields.is_empty() {
694 return "No custom fields found.".to_string();
695 }
696
697 let total = pagination
698 .and_then(|p| p.total)
699 .unwrap_or(fields.len() as u32);
700 let shown = fields.len() as u32;
701 let header = if total > shown {
702 format!("# Custom Fields ({} of {})\n\n", shown, total)
703 } else {
704 format!("# Custom Fields ({})\n\n", shown)
705 };
706 let mut output = header;
707 output.push_str("| Id | Name | Type |\n");
708 output.push_str("|---|---|---|\n");
709 for f in fields {
710 let field_type = if f.field_type.is_empty() {
711 "-"
712 } else {
713 &f.field_type
714 };
715 output.push_str(&format!(
716 "| `{}` | {} | {} |\n",
717 escape_table_cell(&f.id),
718 escape_table_cell(&f.name),
719 escape_table_cell(field_type),
720 ));
721 }
722 if total > shown {
723 let omitted = total - shown;
724 output.push_str(&format!(
725 "\n[+{omitted} more — call with `limit: {}` (max 200) or narrow with `search`]\n",
726 total.min(200)
727 ));
728 }
729 output
730}
731
732const MAX_VERSION_LIMIT: u32 = 200;
736
737fn escape_table_cell(s: &str) -> String {
742 s.replace('\\', "\\\\").replace('|', "\\|")
743}
744
745fn format_single_project_version(v: &devboy_core::ProjectVersion) -> String {
749 let safe_name = v.name.replace(['\n', '\r'], " ");
753 let mut output = format!("# {} (project {})\n\n", safe_name, v.project);
754 output.push_str(&format!("- **id:** {}\n", v.id));
755 output.push_str(&format!(
756 "- **released:** {}\n",
757 if v.released { "yes" } else { "no" }
758 ));
759 output.push_str(&format!(
760 "- **archived:** {}\n",
761 if v.archived { "yes" } else { "no" }
762 ));
763 if let Some(ref d) = v.start_date {
764 output.push_str(&format!("- **start_date:** {d}\n"));
765 }
766 if let Some(ref d) = v.release_date {
767 output.push_str(&format!("- **release_date:** {d}\n"));
768 }
769 if let Some(overdue) = v.overdue {
770 output.push_str(&format!("- **overdue:** {overdue}\n"));
771 }
772 if let Some(count) = v.issue_count {
773 output.push_str(&format!("- **issue_count:** {count}\n"));
774 }
775 if let Some(count) = v.unresolved_issue_count {
776 output.push_str(&format!("- **unresolved_issue_count:** {count}\n"));
777 }
778 if let Some(ref desc) = v.description.as_deref().filter(|d| !d.is_empty()) {
779 output.push_str(&format!("\n## Description\n\n{desc}\n"));
780 }
781 output
782}
783
784fn truncate_for_table(s: &str, max_chars: usize) -> String {
788 let single_line: String = s
789 .chars()
790 .map(|c| if c == '\n' || c == '\r' { ' ' } else { c })
791 .collect();
792 let count = single_line.chars().count();
793 if count <= max_chars {
794 return single_line;
795 }
796 let mut out: String = single_line.chars().take(max_chars).collect();
797 out.push('…');
798 out
799}
800
801fn format_users(users: &[devboy_core::User]) -> String {
803 if users.is_empty() {
804 return "No users found.".to_string();
805 }
806
807 let mut output = String::from("# Users\n\n");
808 output.push_str("| ID | Username | Name | Email |\n");
809 output.push_str("|---|---|---|---|\n");
810
811 for u in users {
812 let name = u.name.as_deref().unwrap_or("-");
813 let email = u.email.as_deref().unwrap_or("-");
814 output.push_str(&format!(
815 "| {} | {} | {} | {} |\n",
816 u.id, u.username, name, email
817 ));
818 }
819
820 output
821}
822
823fn format_meeting_notes(meetings: &[devboy_core::MeetingNote]) -> String {
825 if meetings.is_empty() {
826 return "No meeting notes found.".to_string();
827 }
828
829 let mut output = format!("# Meeting Notes ({} results)\n\n", meetings.len());
830
831 for m in meetings {
832 output.push_str(&format!("## {}\n", m.title));
833 if let Some(ref date) = m.meeting_date {
834 output.push_str(&format!("**Date:** {date}\n"));
835 }
836 if let Some(secs) = m.duration_seconds {
837 let mins = secs / 60;
838 output.push_str(&format!("**Duration:** {mins} min\n"));
839 }
840 if let Some(ref host) = m.host_email {
841 output.push_str(&format!("**Host:** {host}\n"));
842 }
843 if !m.participants.is_empty() {
844 output.push_str(&format!(
845 "**Participants:** {}\n",
846 m.participants.join(", ")
847 ));
848 }
849 if let Some(ref summary) = m.summary {
850 output.push_str(&format!("\n{summary}\n"));
851 }
852 if !m.action_items.is_empty() {
853 output.push_str("\n**Action Items:**\n");
854 for item in &m.action_items {
855 output.push_str(&format!("- {item}\n"));
856 }
857 }
858 if !m.keywords.is_empty() {
859 output.push_str(&format!("**Keywords:** {}\n", m.keywords.join(", ")));
860 }
861 output.push('\n');
862 }
863
864 output
865}
866
867fn format_meeting_transcript(transcript: &devboy_core::MeetingTranscript) -> String {
869 let title = transcript.title.as_deref().unwrap_or("Meeting Transcript");
870 let mut output = format!("# {title}\n\n");
871 output.push_str(&format!(
872 "Showing {} sentences\n\n",
873 transcript.sentences.len()
874 ));
875
876 for s in &transcript.sentences {
877 let fallback = if s.speaker_id.is_empty() {
878 "Unknown speaker".to_string()
879 } else {
880 format!("Speaker {}", s.speaker_id)
881 };
882 let speaker = s.speaker_name.as_deref().unwrap_or(&fallback);
883 let time = format_time(s.start_time);
884 output.push_str(&format!("[{time}] {speaker}: {}\n", s.text));
885 }
886
887 output
888}
889
890fn format_knowledge_base_spaces(spaces: &[devboy_core::KbSpace]) -> String {
891 if spaces.is_empty() {
892 return "No knowledge base spaces found.".to_string();
893 }
894
895 let mut output = format!("# Knowledge Base Spaces ({})\n\n", spaces.len());
896 for space in spaces {
897 output.push_str(&format!("- {} (`{}`)\n", space.name, space.key));
898 if let Some(description) = &space.description {
899 output.push_str(&format!(" {description}\n"));
900 }
901 if let Some(url) = &space.url {
902 output.push_str(&format!(" {url}\n"));
903 }
904 }
905 output
906}
907
908fn format_knowledge_base_pages(pages: &[devboy_core::KbPage]) -> String {
909 if pages.is_empty() {
910 return "No knowledge base pages found.".to_string();
911 }
912
913 let mut output = format!("# Knowledge Base Pages ({})\n\n", pages.len());
914 for page in pages {
915 output.push_str(&format!("- {} (`{}`)\n", page.title, page.id));
916 if let Some(space_key) = &page.space_key {
917 output.push_str(&format!(" space: {space_key}\n"));
918 }
919 if let Some(author) = &page.author {
920 output.push_str(&format!(" author: {author}\n"));
921 }
922 if let Some(last_modified) = &page.last_modified {
923 output.push_str(&format!(" updated: {last_modified}\n"));
924 }
925 if let Some(excerpt) = &page.excerpt {
926 output.push_str(&format!(" excerpt: {excerpt}\n"));
927 }
928 if let Some(url) = &page.url {
929 output.push_str(&format!(" {url}\n"));
930 }
931 }
932 output
933}
934
935fn format_knowledge_base_page_summary(page: &devboy_core::KbPage) -> String {
936 let mut output = format!("# Knowledge Base Page\n\n{} (`{}`)\n", page.title, page.id);
937 if let Some(space_key) = &page.space_key {
938 output.push_str(&format!("space: {space_key}\n"));
939 }
940 if let Some(author) = &page.author {
941 output.push_str(&format!("author: {author}\n"));
942 }
943 if let Some(last_modified) = &page.last_modified {
944 output.push_str(&format!("updated: {last_modified}\n"));
945 }
946 if let Some(url) = &page.url {
947 output.push_str(&format!("url: {url}\n"));
948 }
949 output
950}
951
952fn format_knowledge_base_page(page: &devboy_core::KbPageContent) -> String {
953 let mut output = format!("# {}\n\n", page.page.title);
954 output.push_str(&format!("id: `{}`\n", page.page.id));
955 if let Some(space_key) = &page.page.space_key {
956 output.push_str(&format!("space: `{space_key}`\n"));
957 }
958 output.push_str(&format!("content_type: `{}`\n", page.content_type));
959 if !page.labels.is_empty() {
960 output.push_str(&format!("labels: {}\n", page.labels.join(", ")));
961 }
962 if !page.ancestors.is_empty() {
963 let chain = page
964 .ancestors
965 .iter()
966 .map(|ancestor| ancestor.title.as_str())
967 .collect::<Vec<_>>()
968 .join(" > ");
969 output.push_str(&format!("ancestors: {chain}\n"));
970 }
971 if let Some(url) = &page.page.url {
972 output.push_str(&format!("url: {url}\n"));
973 }
974 output.push('\n');
975 output.push_str(&page.content);
976 output
977}
978
979fn format_time(seconds: f64) -> String {
981 let total_secs = seconds as u64;
982 let hours = total_secs / 3600;
983 let minutes = (total_secs % 3600) / 60;
984 let secs = total_secs % 60;
985 if hours > 0 {
986 format!("{hours:02}:{minutes:02}:{secs:02}")
987 } else {
988 format!("{minutes:02}:{secs:02}")
989 }
990}
991
992fn format_pipeline(info: &devboy_core::PipelineInfo) -> String {
994 let status_icon = match info.status {
995 devboy_core::PipelineStatus::Success => "✅",
996 devboy_core::PipelineStatus::Failed => "❌",
997 devboy_core::PipelineStatus::Running => "🔄",
998 devboy_core::PipelineStatus::Pending => "⏳",
999 devboy_core::PipelineStatus::Canceled => "🚫",
1000 _ => "❓",
1001 };
1002
1003 let mut output = format!(
1004 "# Pipeline {}\n\n{} **Status:** {} | **Ref:** `{}` | **SHA:** `{}`",
1005 info.id,
1006 status_icon,
1007 info.status.as_str(),
1008 info.reference,
1009 &info.sha[..info
1010 .sha
1011 .char_indices()
1012 .nth(7)
1013 .map(|(i, _)| i)
1014 .unwrap_or(info.sha.len())]
1015 );
1016
1017 if let Some(url) = &info.url {
1018 output.push_str(&format!("\n🔗 {url}"));
1019 }
1020
1021 if let Some(duration) = info.duration {
1022 output.push_str(&format!("\n⏱️ Duration: {}s", duration));
1023 }
1024
1025 let s = &info.summary;
1027 output.push_str(&format!(
1028 "\n\n**Summary:** {} total | ✅ {} | ❌ {} | 🔄 {} | ⏳ {} | 🚫 {} | ⏭️ {}",
1029 s.total, s.success, s.failed, s.running, s.pending, s.canceled, s.skipped
1030 ));
1031
1032 for stage in &info.stages {
1034 output.push_str(&format!("\n\n## {}\n", stage.name));
1035 for job in &stage.jobs {
1036 let job_icon = match job.status {
1037 devboy_core::PipelineStatus::Success => "✅",
1038 devboy_core::PipelineStatus::Failed => "❌",
1039 devboy_core::PipelineStatus::Running => "🔄",
1040 devboy_core::PipelineStatus::Pending => "⏳",
1041 _ => "❓",
1042 };
1043 let dur = job.duration.map(|d| format!(" ({d}s)")).unwrap_or_default();
1044 output.push_str(&format!("\n{} **{}**{}", job_icon, job.name, dur));
1045 if let Some(url) = &job.url {
1046 output.push_str(&format!(" — [logs]({url})"));
1047 }
1048 }
1049 }
1050
1051 if !info.failed_jobs.is_empty() {
1053 output.push_str("\n\n## Failed Jobs\n");
1054 for fj in &info.failed_jobs {
1055 output.push_str(&format!("\n### ❌ {} (job {})\n", fj.name, fj.id));
1056 if let Some(snippet) = &fj.error_snippet {
1057 output.push_str(&format!("\n```\n{snippet}\n```\n"));
1058 }
1059 }
1060 }
1061
1062 output
1063}
1064
1065fn format_job_log(log: &devboy_core::JobLogOutput) -> String {
1067 let mut output = format!("# Job Log ({})\n\n", log.job_id);
1068 output.push_str(&format!("**Mode:** {}", log.mode));
1069 if let Some(total) = log.total_lines {
1070 output.push_str(&format!(" | **Total lines:** {total}"));
1071 }
1072 output.push_str(&format!("\n\n```\n{}\n```", log.content));
1073 output
1074}
1075
1076pub async fn execute_and_format(
1080 executor: &crate::executor::Executor,
1081 tool: &str,
1082 args: serde_json::Value,
1083 ctx: &crate::context::AdditionalContext,
1084 pipeline_config: Option<PipelineConfig>,
1085) -> Result<FormatResult> {
1086 let format = args
1088 .get("format")
1089 .and_then(|v| v.as_str())
1090 .map(String::from);
1091
1092 let budget = args
1093 .get("budget")
1094 .and_then(|v| v.as_u64())
1095 .map(|b| b as usize);
1096
1097 let pipeline_config = if let Some(b) = budget {
1099 let mut config = pipeline_config.unwrap_or_default();
1100 config.max_chars = (b as f64 * 3.5).floor() as usize;
1102 Some(config)
1103 } else {
1104 pipeline_config
1105 };
1106
1107 let output = executor.execute(tool, args, ctx).await?;
1108 format_output(output, format.as_deref(), Some(tool), pipeline_config)
1109}
1110
1111#[cfg(test)]
1112mod tests {
1113 use super::*;
1114 use devboy_core::Issue;
1115
1116 fn sample_issue() -> Issue {
1117 Issue {
1118 key: "gh#1".into(),
1119 title: "Test Issue".into(),
1120 description: Some("Test description".into()),
1121 state: "open".into(),
1122 source: "github".into(),
1123 priority: None,
1124 labels: vec!["bug".into()],
1125 author: None,
1126 assignees: vec![],
1127 url: Some("https://github.com/test/repo/issues/1".into()),
1128 created_at: Some("2024-01-01T00:00:00Z".into()),
1129 updated_at: Some("2024-01-02T00:00:00Z".into()),
1130 attachments_count: None,
1131 parent: None,
1132 subtasks: vec![],
1133 custom_fields: std::collections::HashMap::new(),
1134 ..Default::default()
1135 }
1136 }
1137
1138 #[test]
1139 fn test_format_issues_toon() {
1140 let output = ToolOutput::Issues(vec![sample_issue()], None);
1141 let result = format_output(output, Some("toon"), None, None)
1142 .unwrap()
1143 .content;
1144 assert!(result.contains("gh#1"));
1145 assert!(result.contains("Test Issue"));
1146 }
1147
1148 #[test]
1149 fn test_format_metadata_toon_compression() {
1150 let output = ToolOutput::Issues(vec![sample_issue()], None);
1151 let result = format_output(output, Some("toon"), None, None).unwrap();
1152
1153 assert!(result.metadata.raw_chars > 0, "raw_chars should be > 0");
1154 assert!(
1155 result.metadata.output_chars > 0,
1156 "output_chars should be > 0"
1157 );
1158 assert!(result.metadata.estimated_tokens > 0, "tokens should be > 0");
1159 assert_eq!(result.metadata.format, "toon");
1160 assert!(!result.metadata.truncated);
1161 assert!(
1163 result.metadata.compression_ratio < 2.0,
1164 "compression_ratio should be reasonable, got {}",
1165 result.metadata.compression_ratio
1166 );
1167 }
1168
1169 #[test]
1170 fn test_format_metadata_text_passthrough() {
1171 let output = ToolOutput::Text("plain text".into());
1172 let result = format_output(output, None, None, None).unwrap();
1173
1174 assert_eq!(result.metadata.raw_chars, 10);
1175 assert_eq!(result.metadata.output_chars, 10);
1176 assert_eq!(result.metadata.compression_ratio, 1.0);
1177 assert_eq!(result.metadata.format, "text");
1178 assert!(!result.metadata.truncated);
1179 }
1180
1181 #[test]
1182 fn test_format_metadata_savings_split() {
1183 let issues: Vec<_> = (0..20).map(|_| sample_issue()).collect();
1186 let output = ToolOutput::Issues(issues, None);
1187 let result = format_output(output, Some("toon"), None, None).unwrap();
1188
1189 assert_eq!(result.metadata.dedup_savings_pct, 0.0);
1191 assert!(
1193 (0.0..1.0).contains(&result.metadata.encoder_savings_pct),
1194 "encoder savings out of range: {}",
1195 result.metadata.encoder_savings_pct
1196 );
1197 assert_eq!(
1199 result.metadata.combined_savings_pct,
1200 result.metadata.encoder_savings_pct
1201 );
1202 assert_eq!(result.metadata.baseline, "json_pretty");
1204 assert!(
1205 !result.metadata.tokenizer.is_empty(),
1206 "tokenizer must be set"
1207 );
1208 }
1209
1210 #[test]
1211 fn test_format_metadata_passthrough_savings_zero() {
1212 let output = ToolOutput::Text("nothing to compress".into());
1215 let result = format_output(output, None, None, None).unwrap();
1216 assert_eq!(result.metadata.dedup_savings_pct, 0.0);
1217 assert_eq!(result.metadata.encoder_savings_pct, 0.0);
1218 assert_eq!(result.metadata.combined_savings_pct, 0.0);
1219 assert_eq!(result.metadata.baseline, "json_pretty");
1220 assert!(!result.metadata.tokenizer.is_empty());
1221 }
1222
1223 #[test]
1224 fn test_format_metadata_truncated() {
1225 let output = ToolOutput::Issues(vec![sample_issue()], None);
1226 let config = PipelineConfig {
1227 max_chars: 50, ..PipelineConfig::default()
1229 };
1230 let result = format_output(output, Some("toon"), None, Some(config)).unwrap();
1231
1232 assert!(result.metadata.truncated);
1233 assert!(
1235 result.metadata.output_chars < result.metadata.raw_chars,
1236 "truncated output ({}) should be smaller than raw ({})",
1237 result.metadata.output_chars,
1238 result.metadata.raw_chars
1239 );
1240 }
1241
1242 #[test]
1243 fn test_format_issues_json() {
1244 let output = ToolOutput::Issues(vec![sample_issue()], None);
1245 let result = format_output(output, Some("json"), None, None)
1246 .unwrap()
1247 .content;
1248 assert!(result.contains("gh#1"));
1249 }
1250
1251 #[test]
1252 fn test_format_issues_toon_explicit() {
1253 let output = ToolOutput::Issues(vec![sample_issue()], None);
1254 let result = format_output(output, Some("toon"), None, None)
1255 .unwrap()
1256 .content;
1257 assert!(result.contains("gh#1"));
1258 }
1259
1260 #[test]
1261 fn test_format_text_passthrough() {
1262 let output = ToolOutput::Text("Comment created".into());
1263 let result = format_output(output, None, None, None).unwrap().content;
1264 assert_eq!(result, "Comment created");
1265 }
1266
1267 #[test]
1268 fn test_format_default_is_toon() {
1269 let output = ToolOutput::Issues(vec![sample_issue()], None);
1270 let result = format_output(output, None, None, None).unwrap().content;
1271 assert!(result.contains("gh#1"));
1272 }
1273
1274 #[test]
1275 fn test_format_single_issue() {
1276 let output = ToolOutput::SingleIssue(Box::new(sample_issue()));
1277 let result = format_output(output, Some("toon"), None, None)
1278 .unwrap()
1279 .content;
1280 assert!(result.contains("gh#1"));
1281 }
1282
1283 fn sample_mr() -> devboy_core::MergeRequest {
1284 devboy_core::MergeRequest {
1285 key: "pr#1".into(),
1286 title: "Test PR".into(),
1287 description: None,
1288 state: "open".into(),
1289 source: "github".into(),
1290 source_branch: "feature".into(),
1291 target_branch: "main".into(),
1292 author: None,
1293 assignees: vec![],
1294 reviewers: vec![],
1295 labels: vec![],
1296 draft: false,
1297 url: None,
1298 created_at: None,
1299 updated_at: None,
1300 }
1301 }
1302
1303 #[test]
1304 fn test_format_merge_requests() {
1305 let output = ToolOutput::MergeRequests(vec![sample_mr()], None);
1306 let result = format_output(output, Some("toon"), None, None)
1307 .unwrap()
1308 .content;
1309 assert!(result.contains("pr#1"));
1310 }
1311
1312 #[test]
1313 fn test_format_single_merge_request() {
1314 let output = ToolOutput::SingleMergeRequest(Box::new(sample_mr()));
1315 let result = format_output(output, Some("toon"), None, None)
1316 .unwrap()
1317 .content;
1318 assert!(result.contains("pr#1"));
1319 }
1320
1321 #[test]
1322 fn test_format_discussions() {
1323 let output = ToolOutput::Discussions(
1324 vec![devboy_core::Discussion {
1325 id: "d1".into(),
1326 resolved: false,
1327 resolved_by: None,
1328 comments: vec![devboy_core::Comment {
1329 id: "c1".into(),
1330 body: "Review comment".into(),
1331 author: None,
1332 created_at: None,
1333 updated_at: None,
1334 position: None,
1335 }],
1336 position: None,
1337 }],
1338 None,
1339 );
1340 let result = format_output(output, Some("toon"), None, None)
1341 .unwrap()
1342 .content;
1343 assert!(result.contains("Review comment"));
1344 }
1345
1346 #[test]
1347 fn test_format_diffs() {
1348 let output = ToolOutput::Diffs(
1349 vec![devboy_core::FileDiff {
1350 file_path: "src/main.rs".into(),
1351 old_path: None,
1352 new_file: false,
1353 deleted_file: false,
1354 renamed_file: false,
1355 diff: "+added line".into(),
1356 additions: Some(1),
1357 deletions: Some(0),
1358 }],
1359 None,
1360 );
1361 let result = format_output(output, Some("toon"), None, None)
1362 .unwrap()
1363 .content;
1364 assert!(result.contains("src/main.rs"));
1365 }
1366
1367 #[test]
1368 fn test_format_comments() {
1369 let output = ToolOutput::Comments(
1370 vec![devboy_core::Comment {
1371 id: "c1".into(),
1372 body: "A comment body".into(),
1373 author: None,
1374 created_at: None,
1375 updated_at: None,
1376 position: None,
1377 }],
1378 None,
1379 );
1380 let result = format_output(output, Some("json"), None, None)
1381 .unwrap()
1382 .content;
1383 assert!(result.contains("A comment body"));
1384 }
1385
1386 #[test]
1387 fn test_format_with_custom_pipeline_config() {
1388 let output = ToolOutput::Issues(vec![sample_issue()], None);
1389 let config = PipelineConfig {
1390 max_chars: 500,
1391 ..PipelineConfig::default()
1392 };
1393 let result = format_output(output, Some("toon"), None, Some(config))
1394 .unwrap()
1395 .content;
1396 assert!(result.contains("gh#1"));
1397 }
1398
1399 #[test]
1400 fn test_format_pipeline() {
1401 let output = ToolOutput::Pipeline(Box::new(devboy_core::PipelineInfo {
1402 id: "100".into(),
1403 status: devboy_core::PipelineStatus::Failed,
1404 reference: "main".into(),
1405 sha: "abc123def".into(),
1406 url: Some("https://example.com/pipeline/100".into()),
1407 duration: Some(120),
1408 coverage: Some(85.5),
1409 summary: devboy_core::PipelineSummary {
1410 total: 3,
1411 success: 2,
1412 failed: 1,
1413 ..Default::default()
1414 },
1415 stages: vec![devboy_core::PipelineStage {
1416 name: "build".into(),
1417 jobs: vec![devboy_core::PipelineJob {
1418 id: "1".into(),
1419 name: "compile".into(),
1420 status: devboy_core::PipelineStatus::Success,
1421 url: None,
1422 duration: Some(30),
1423 }],
1424 }],
1425 failed_jobs: vec![devboy_core::FailedJob {
1426 id: "2".into(),
1427 name: "test".into(),
1428 url: None,
1429 error_snippet: Some("error: test failed".into()),
1430 }],
1431 }));
1432 let result = format_output(output, None, None, None).unwrap().content;
1433 assert!(result.contains("Pipeline 100"));
1434 assert!(result.contains("failed"));
1435 assert!(result.contains("main"));
1436 assert!(result.contains("120s"));
1437 assert!(result.contains("compile"));
1438 assert!(result.contains("error: test failed"));
1439 }
1440
1441 #[test]
1442 fn test_format_job_log() {
1443 let output = ToolOutput::JobLog(Box::new(devboy_core::JobLogOutput {
1444 job_id: "202".into(),
1445 job_name: Some("test".into()),
1446 content: "error: assertion failed\nat src/test.rs:42".into(),
1447 mode: "smart".into(),
1448 total_lines: Some(100),
1449 }));
1450 let result = format_output(output, None, None, None).unwrap().content;
1451 assert!(result.contains("Job Log"));
1452 assert!(result.contains("202"));
1453 assert!(result.contains("smart"));
1454 assert!(result.contains("assertion failed"));
1455 }
1456
1457 #[test]
1460 fn test_format_pipeline_success_status() {
1461 let output = ToolOutput::Pipeline(Box::new(devboy_core::PipelineInfo {
1462 id: "200".into(),
1463 status: devboy_core::PipelineStatus::Success,
1464 reference: "develop".into(),
1465 sha: "deadbeefcafe".into(),
1466 url: None,
1467 duration: None,
1468 coverage: None,
1469 summary: devboy_core::PipelineSummary {
1470 total: 5,
1471 success: 5,
1472 ..Default::default()
1473 },
1474 stages: vec![],
1475 failed_jobs: vec![],
1476 }));
1477 let result = format_output(output, None, None, None).unwrap().content;
1478 assert!(result.contains("Pipeline 200"));
1479 assert!(result.contains("success"));
1480 assert!(result.contains("develop"));
1481 assert!(result.contains("deadbee")); }
1483
1484 #[test]
1485 fn test_format_pipeline_running_status() {
1486 let output = ToolOutput::Pipeline(Box::new(devboy_core::PipelineInfo {
1487 id: "301".into(),
1488 status: devboy_core::PipelineStatus::Running,
1489 reference: "feature".into(),
1490 sha: "1234567890abcdef".into(),
1491 url: Some("https://ci.example.com/301".into()),
1492 duration: Some(60),
1493 coverage: None,
1494 summary: devboy_core::PipelineSummary {
1495 total: 3,
1496 running: 1,
1497 success: 1,
1498 pending: 1,
1499 ..Default::default()
1500 },
1501 stages: vec![],
1502 failed_jobs: vec![],
1503 }));
1504 let result = format_output(output, None, None, None).unwrap().content;
1505 assert!(result.contains("running"));
1506 assert!(result.contains("https://ci.example.com/301"));
1507 assert!(result.contains("60s"));
1508 }
1509
1510 #[test]
1511 fn test_format_pipeline_pending_status() {
1512 let output = ToolOutput::Pipeline(Box::new(devboy_core::PipelineInfo {
1513 id: "302".into(),
1514 status: devboy_core::PipelineStatus::Pending,
1515 reference: "main".into(),
1516 sha: "aabbccdd".into(),
1517 url: None,
1518 duration: None,
1519 coverage: None,
1520 summary: Default::default(),
1521 stages: vec![],
1522 failed_jobs: vec![],
1523 }));
1524 let result = format_output(output, None, None, None).unwrap().content;
1525 assert!(result.contains("pending"));
1526 }
1527
1528 #[test]
1529 fn test_format_pipeline_canceled_status() {
1530 let output = ToolOutput::Pipeline(Box::new(devboy_core::PipelineInfo {
1531 id: "303".into(),
1532 status: devboy_core::PipelineStatus::Canceled,
1533 reference: "main".into(),
1534 sha: "1122334455".into(),
1535 url: None,
1536 duration: None,
1537 coverage: None,
1538 summary: Default::default(),
1539 stages: vec![],
1540 failed_jobs: vec![],
1541 }));
1542 let result = format_output(output, None, None, None).unwrap().content;
1543 assert!(result.contains("canceled"));
1544 }
1545
1546 #[test]
1547 fn test_format_pipeline_with_job_url() {
1548 let output = ToolOutput::Pipeline(Box::new(devboy_core::PipelineInfo {
1549 id: "400".into(),
1550 status: devboy_core::PipelineStatus::Failed,
1551 reference: "main".into(),
1552 sha: "abcdef1234567".into(),
1553 url: None,
1554 duration: None,
1555 coverage: None,
1556 summary: Default::default(),
1557 stages: vec![devboy_core::PipelineStage {
1558 name: "test".into(),
1559 jobs: vec![devboy_core::PipelineJob {
1560 id: "j1".into(),
1561 name: "unit-test".into(),
1562 status: devboy_core::PipelineStatus::Failed,
1563 url: Some("https://ci.example.com/jobs/j1".into()),
1564 duration: None,
1565 }],
1566 }],
1567 failed_jobs: vec![],
1568 }));
1569 let result = format_output(output, None, None, None).unwrap().content;
1570 assert!(result.contains("[logs](https://ci.example.com/jobs/j1)"));
1571 }
1572
1573 #[test]
1574 fn test_format_pipeline_failed_job_without_snippet() {
1575 let output = ToolOutput::Pipeline(Box::new(devboy_core::PipelineInfo {
1576 id: "401".into(),
1577 status: devboy_core::PipelineStatus::Failed,
1578 reference: "main".into(),
1579 sha: "abcdef1234567".into(),
1580 url: None,
1581 duration: None,
1582 coverage: None,
1583 summary: Default::default(),
1584 stages: vec![],
1585 failed_jobs: vec![devboy_core::FailedJob {
1586 id: "fj1".into(),
1587 name: "lint".into(),
1588 url: None,
1589 error_snippet: None,
1590 }],
1591 }));
1592 let result = format_output(output, None, None, None).unwrap().content;
1593 assert!(result.contains("lint"));
1594 assert!(result.contains("fj1"));
1595 assert!(!result.contains("```")); }
1597
1598 #[test]
1601 fn test_format_statuses() {
1602 let output = ToolOutput::Statuses(
1603 vec![
1604 devboy_core::IssueStatus {
1605 id: "1".into(),
1606 name: "To Do".into(),
1607 category: "todo".into(),
1608 color: Some("#blue".into()),
1609 order: Some(0),
1610 },
1611 devboy_core::IssueStatus {
1612 id: "2".into(),
1613 name: "In Progress".into(),
1614 category: "in_progress".into(),
1615 color: None,
1616 order: None,
1617 },
1618 ],
1619 None,
1620 );
1621 let result = format_output(output, None, None, None).unwrap().content;
1622 assert!(result.contains("Available Statuses"));
1623 assert!(result.contains("To Do"));
1624 assert!(result.contains("In Progress"));
1625 assert!(result.contains("#blue"));
1626 assert!(result.contains("todo"));
1627 assert!(result.contains("| - |")); }
1629
1630 #[test]
1631 fn test_format_statuses_empty() {
1632 let output = ToolOutput::Statuses(vec![], None);
1633 let result = format_output(output, None, None, None).unwrap().content;
1634 assert_eq!(result, "No statuses found.");
1635 }
1636
1637 #[test]
1640 fn test_format_users() {
1641 let output = ToolOutput::Users(
1642 vec![
1643 devboy_core::User {
1644 id: "u1".into(),
1645 username: "johndoe".into(),
1646 name: Some("John Doe".into()),
1647 email: Some("john@example.com".into()),
1648 avatar_url: None,
1649 },
1650 devboy_core::User {
1651 id: "u2".into(),
1652 username: "janesmith".into(),
1653 name: None,
1654 email: None,
1655 avatar_url: None,
1656 },
1657 ],
1658 None,
1659 );
1660 let result = format_output(output, None, None, None).unwrap().content;
1661 assert!(result.contains("# Users"));
1662 assert!(result.contains("johndoe"));
1663 assert!(result.contains("John Doe"));
1664 assert!(result.contains("john@example.com"));
1665 assert!(result.contains("janesmith"));
1666 assert!(result.contains("| - |")); }
1668
1669 #[test]
1670 fn test_format_users_empty() {
1671 let output = ToolOutput::Users(vec![], None);
1672 let result = format_output(output, None, None, None).unwrap().content;
1673 assert_eq!(result, "No users found.");
1674 }
1675
1676 fn sample_project_version(name: &str) -> devboy_core::ProjectVersion {
1679 devboy_core::ProjectVersion {
1680 id: "1".into(),
1681 project: "PROJ".into(),
1682 name: name.into(),
1683 description: Some("Initial release".into()),
1684 start_date: Some("2025-01-01".into()),
1685 release_date: Some("2025-02-01".into()),
1686 released: true,
1687 archived: false,
1688 overdue: Some(false),
1689 issue_count: Some(7),
1690 unresolved_issue_count: None,
1691 source: "jira".into(),
1692 }
1693 }
1694
1695 #[test]
1696 fn format_project_versions_empty_returns_canonical_message() {
1697 let output = ToolOutput::ProjectVersions(vec![], None);
1698 let result = format_output(output, None, None, None).unwrap().content;
1699 assert_eq!(result, "No project versions found.");
1700 }
1701
1702 #[test]
1703 fn format_project_versions_renders_table_with_counts_and_dates() {
1704 let output = ToolOutput::ProjectVersions(vec![sample_project_version("3.18.0")], None);
1705 let result = format_output(output, None, None, None).unwrap().content;
1706 assert!(result.contains("# Project Versions (1)"), "{result}");
1707 assert!(result.contains("| Name |"), "{result}");
1708 assert!(result.contains("| 3.18.0 |"), "{result}");
1709 assert!(result.contains("| yes |"), "{result}");
1710 assert!(result.contains("2025-02-01"), "{result}");
1711 assert!(result.contains("Initial release"), "{result}");
1712 }
1713
1714 #[test]
1715 fn format_project_versions_marks_archived_inline() {
1716 let mut v = sample_project_version("0.9.0");
1717 v.archived = true;
1718 let output = ToolOutput::ProjectVersions(vec![v], None);
1719 let result = format_output(output, None, None, None).unwrap().content;
1720 assert!(
1721 result.contains("0.9.0 (archived)"),
1722 "expected archived marker, got {result}"
1723 );
1724 }
1725
1726 #[test]
1727 fn format_project_versions_truncates_long_descriptions() {
1728 let mut v = sample_project_version("1.0.0");
1729 v.description = Some("x".repeat(200));
1730 let output = ToolOutput::ProjectVersions(vec![v], None);
1731 let result = format_output(output, None, None, None).unwrap().content;
1732 assert!(result.contains('…'), "expected ellipsis, got {result}");
1733 }
1734
1735 #[test]
1736 fn format_single_project_version_renders_detail_block() {
1737 let v = sample_project_version("3.18.0");
1738 let output = ToolOutput::SingleProjectVersion(Box::new(v));
1739 let result = format_output(output, None, None, None).unwrap().content;
1740 assert!(result.contains("# 3.18.0 (project PROJ)"), "{result}");
1741 assert!(result.contains("- **id:** 1"), "{result}");
1742 assert!(result.contains("- **released:** yes"), "{result}");
1743 assert!(result.contains("## Description"), "{result}");
1744 assert!(result.contains("Initial release"), "{result}");
1745 }
1746
1747 #[test]
1748 fn format_project_versions_escapes_pipes_in_name_and_description() {
1749 let mut v = sample_project_version("v|1.0");
1752 v.description = Some("Highlights | breaking changes".into());
1753 let output = ToolOutput::ProjectVersions(vec![v], None);
1754 let result = format_output(output, None, None, None).unwrap().content;
1755 assert!(
1756 result.contains("v\\|1.0"),
1757 "name pipe not escaped: {result}"
1758 );
1759 assert!(
1760 result.contains("Highlights \\| breaking changes"),
1761 "description pipe not escaped: {result}"
1762 );
1763 let line = result
1766 .lines()
1767 .find(|l| l.starts_with("| v\\|1.0"))
1768 .expect("expected table row, got: {result}");
1769 let cells = line.split(" | ").count();
1770 assert!(cells <= 6, "row split into too many cells: {line:?}");
1771 }
1772
1773 #[test]
1774 fn format_project_versions_emits_more_hint_when_truncated() {
1775 let pagination = devboy_core::Pagination {
1779 offset: 0,
1780 limit: 1,
1781 total: Some(35),
1782 has_more: true,
1783 next_cursor: None,
1784 };
1785 let v = sample_project_version("3.18.0");
1786 let output = ToolOutput::ProjectVersions(
1787 vec![v],
1788 Some(crate::output::ResultMeta {
1789 pagination: Some(pagination),
1790 sort_info: None,
1791 }),
1792 );
1793 let result = format_output(output, None, None, None).unwrap().content;
1794 assert!(
1795 result.contains("Project Versions (1 of 35)"),
1796 "expected 'X of Y' header: {result}"
1797 );
1798 assert!(
1799 result.contains("[+34 more"),
1800 "expected +N more hint: {result}"
1801 );
1802 assert!(
1803 result.contains("`limit: 35`"),
1804 "expected limit suggestion: {result}"
1805 );
1806 }
1807
1808 #[test]
1809 fn format_project_versions_hint_caps_limit_at_max_and_uses_archived_all() {
1810 let pagination = devboy_core::Pagination {
1814 offset: 0,
1815 limit: 1,
1816 total: Some(5_000),
1817 has_more: true,
1818 next_cursor: None,
1819 };
1820 let v = sample_project_version("3.18.0");
1821 let output = ToolOutput::ProjectVersions(
1822 vec![v],
1823 Some(crate::output::ResultMeta {
1824 pagination: Some(pagination),
1825 sort_info: None,
1826 }),
1827 );
1828 let result = format_output(output, None, None, None).unwrap().content;
1829 assert!(
1830 result.contains("`limit: 200`"),
1831 "limit suggestion should clamp at 200, got: {result}"
1832 );
1833 assert!(
1834 result.contains("`archived: \"all\"`"),
1835 "expected archived hint to suggest 'all', got: {result}"
1836 );
1837 assert!(
1838 !result.contains("`archived: true`"),
1839 "must not suggest archived: true (means 'archived only'), got: {result}"
1840 );
1841 }
1842
1843 #[test]
1844 fn format_project_versions_renders_unresolved_only_cell() {
1845 let mut v = sample_project_version("3.18.0");
1849 v.issue_count = None;
1850 v.unresolved_issue_count = Some(4);
1851 let output = ToolOutput::ProjectVersions(vec![v], None);
1852 let result = format_output(output, None, None, None).unwrap().content;
1853 assert!(
1854 result.contains("4 open"),
1855 "expected '4 open' marker, got: {result}"
1856 );
1857 }
1858
1859 #[test]
1860 fn format_single_project_version_renders_unresolved_count() {
1861 let mut v = sample_project_version("3.18.0");
1862 v.issue_count = Some(20);
1863 v.unresolved_issue_count = Some(7);
1864 let output = ToolOutput::SingleProjectVersion(Box::new(v));
1865 let result = format_output(output, None, None, None).unwrap().content;
1866 assert!(result.contains("- **issue_count:** 20"), "{result}");
1867 assert!(
1868 result.contains("- **unresolved_issue_count:** 7"),
1869 "{result}"
1870 );
1871 }
1872
1873 #[test]
1874 fn format_project_versions_no_hint_when_not_truncated() {
1875 let pagination = devboy_core::Pagination {
1876 offset: 0,
1877 limit: 5,
1878 total: Some(1),
1879 has_more: false,
1880 next_cursor: None,
1881 };
1882 let v = sample_project_version("3.18.0");
1883 let output = ToolOutput::ProjectVersions(
1884 vec![v],
1885 Some(crate::output::ResultMeta {
1886 pagination: Some(pagination),
1887 sort_info: None,
1888 }),
1889 );
1890 let result = format_output(output, None, None, None).unwrap().content;
1891 assert!(
1892 !result.contains("more"),
1893 "shouldn't suggest more results: {result}"
1894 );
1895 }
1896
1897 #[test]
1898 fn escape_table_cell_handles_backslash_and_pipe() {
1899 assert_eq!(escape_table_cell("a|b"), "a\\|b");
1900 assert_eq!(escape_table_cell("a\\b"), "a\\\\b");
1901 assert_eq!(escape_table_cell("a\\|b"), "a\\\\\\|b");
1904 assert_eq!(escape_table_cell("plain"), "plain");
1905 }
1906
1907 #[test]
1910 fn test_format_job_log_no_total_lines() {
1911 let output = ToolOutput::JobLog(Box::new(devboy_core::JobLogOutput {
1912 job_id: "999".into(),
1913 job_name: Some("build".into()),
1914 content: "Building...".into(),
1915 mode: "full".into(),
1916 total_lines: None,
1917 }));
1918 let result = format_output(output, None, None, None).unwrap().content;
1919 assert!(result.contains("Job Log (999)"));
1920 assert!(result.contains("**Mode:** full"));
1921 assert!(!result.contains("Total lines"));
1922 assert!(result.contains("Building..."));
1923 }
1924
1925 #[test]
1928 fn test_format_text_empty_string() {
1929 let output = ToolOutput::Text("".into());
1930 let result = format_output(output, None, None, None).unwrap().content;
1931 assert_eq!(result, "");
1932 }
1933
1934 #[test]
1935 fn test_format_text_with_json_format_param() {
1936 let output = ToolOutput::Text("raw text".into());
1938 let result = format_output(output, Some("json"), None, None)
1939 .unwrap()
1940 .content;
1941 assert_eq!(result, "raw text");
1942 }
1943
1944 #[test]
1947 fn test_format_meeting_notes() {
1948 let meetings = vec![devboy_core::MeetingNote {
1949 id: "m1".into(),
1950 title: "Sprint Planning".into(),
1951 meeting_date: Some("2025-01-15T10:00:00Z".into()),
1952 duration_seconds: Some(2700), host_email: Some("host@example.com".into()),
1954 participants: vec!["alice@example.com".into(), "bob@example.com".into()],
1955 action_items: vec!["Review PR #42".into(), "Update docs".into()],
1956 keywords: vec!["sprint".into(), "planning".into()],
1957 summary: Some("Discussed sprint goals.".into()),
1958 ..Default::default()
1959 }];
1960 let output = ToolOutput::MeetingNotes(meetings, None);
1961 let result = format_output(output, None, None, None).unwrap().content;
1962 assert!(result.contains("Sprint Planning"));
1963 assert!(result.contains("2025-01-15T10:00:00Z"));
1964 assert!(result.contains("45 min"));
1965 assert!(result.contains("host@example.com"));
1966 assert!(result.contains("alice@example.com"));
1967 assert!(result.contains("Review PR #42"));
1968 assert!(result.contains("Update docs"));
1969 assert!(result.contains("sprint"));
1970 assert!(result.contains("Discussed sprint goals."));
1971 }
1972
1973 #[test]
1974 fn test_format_meeting_notes_empty() {
1975 let output = ToolOutput::MeetingNotes(vec![], None);
1976 let result = format_output(output, None, None, None).unwrap().content;
1977 assert_eq!(result, "No meeting notes found.");
1978 }
1979
1980 #[test]
1981 fn test_format_meeting_transcript() {
1982 let transcript = devboy_core::MeetingTranscript {
1983 meeting_id: "m1".into(),
1984 title: Some("Sprint Planning".into()),
1985 sentences: vec![
1986 devboy_core::TranscriptSentence {
1987 speaker_id: "s1".into(),
1988 speaker_name: Some("Alice".into()),
1989 text: "Let's start the meeting.".into(),
1990 start_time: 0.0,
1991 end_time: 3.0,
1992 },
1993 devboy_core::TranscriptSentence {
1994 speaker_id: "s2".into(),
1995 speaker_name: Some("Bob".into()),
1996 text: "Sounds good.".into(),
1997 start_time: 5.0,
1998 end_time: 7.0,
1999 },
2000 ],
2001 };
2002 let output = ToolOutput::MeetingTranscript(Box::new(transcript));
2003 let result = format_output(output, None, None, None).unwrap().content;
2004 assert!(result.contains("Sprint Planning"));
2005 assert!(result.contains("2 sentences"));
2006 assert!(result.contains("[00:00] Alice: Let's start the meeting."));
2007 assert!(result.contains("[00:05] Bob: Sounds good."));
2008 }
2009
2010 #[test]
2011 fn test_format_meeting_transcript_unknown_speaker() {
2012 let transcript = devboy_core::MeetingTranscript {
2013 meeting_id: "m1".into(),
2014 title: None,
2015 sentences: vec![devboy_core::TranscriptSentence {
2016 speaker_id: "".into(),
2017 speaker_name: None,
2018 text: "Hello".into(),
2019 start_time: 0.0,
2020 end_time: 1.0,
2021 }],
2022 };
2023 let output = ToolOutput::MeetingTranscript(Box::new(transcript));
2024 let result = format_output(output, None, None, None).unwrap().content;
2025 assert!(result.contains("Meeting Transcript"));
2026 assert!(result.contains("Unknown speaker"));
2027 }
2028
2029 #[test]
2032 fn test_format_relations() {
2033 let relations = devboy_core::IssueRelations {
2034 parent: Some(sample_issue()),
2035 subtasks: vec![sample_issue()],
2036 blocks: vec![devboy_core::IssueLink {
2037 issue: sample_issue(),
2038 link_type: "Blocks".into(),
2039 }],
2040 blocked_by: vec![],
2041 related_to: vec![],
2042 duplicates: vec![],
2043 epic_key: None,
2044 };
2045 let output = ToolOutput::Relations(Box::new(relations));
2046 let result = format_output(output, None, None, None).unwrap().content;
2047 assert!(result.contains("gh#1"));
2049 assert!(result.contains("Blocks"));
2050 assert!(result.contains("Test Issue"));
2051 }
2052
2053 #[test]
2054 fn test_format_relations_empty() {
2055 let relations = devboy_core::IssueRelations::default();
2056 let output = ToolOutput::Relations(Box::new(relations));
2057 let result = format_output(output, None, None, None).unwrap().content;
2058 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2060 assert!(parsed.is_object());
2061 }
2062
2063 #[test]
2066 fn test_format_time_zero() {
2067 assert_eq!(format_time(0.0), "00:00");
2068 }
2069
2070 #[test]
2071 fn test_format_time_seconds_only() {
2072 assert_eq!(format_time(45.0), "00:45");
2073 }
2074
2075 #[test]
2076 fn test_format_time_minutes_and_seconds() {
2077 assert_eq!(format_time(125.0), "02:05");
2078 }
2079
2080 #[test]
2081 fn test_format_time_hours() {
2082 assert_eq!(format_time(3661.0), "01:01:01");
2083 }
2084
2085 #[test]
2086 fn test_format_time_fractional_seconds() {
2087 assert_eq!(format_time(59.9), "00:59");
2089 }
2090
2091 fn sample_kb_space() -> devboy_core::KbSpace {
2096 devboy_core::KbSpace {
2097 id: "100".into(),
2098 key: "ENG".into(),
2099 name: "Engineering".into(),
2100 description: Some("Team docs".into()),
2101 url: Some("https://wiki.example.com/spaces/ENG".into()),
2102 ..Default::default()
2103 }
2104 }
2105
2106 fn sample_kb_page() -> devboy_core::KbPage {
2107 devboy_core::KbPage {
2108 id: "12345".into(),
2109 title: "Architecture".into(),
2110 space_key: Some("ENG".into()),
2111 url: Some("https://wiki.example.com/pages/12345".into()),
2112 author: Some("alice".into()),
2113 last_modified: Some("2026-04-01T10:00:00Z".into()),
2114 excerpt: Some("Top-level architecture overview".into()),
2115 ..Default::default()
2116 }
2117 }
2118
2119 #[test]
2120 fn format_kb_spaces_empty_returns_canonical_message() {
2121 assert_eq!(
2122 format_knowledge_base_spaces(&[]),
2123 "No knowledge base spaces found."
2124 );
2125 }
2126
2127 #[test]
2128 fn format_kb_spaces_includes_count_name_key_description_url() {
2129 let out = format_knowledge_base_spaces(&[sample_kb_space()]);
2130 assert!(out.contains("# Knowledge Base Spaces (1)"));
2131 assert!(out.contains("Engineering"));
2132 assert!(out.contains("`ENG`"));
2133 assert!(out.contains("Team docs"));
2134 assert!(out.contains("https://wiki.example.com/spaces/ENG"));
2135 }
2136
2137 #[test]
2138 fn format_kb_pages_empty_returns_canonical_message() {
2139 assert_eq!(
2140 format_knowledge_base_pages(&[]),
2141 "No knowledge base pages found."
2142 );
2143 }
2144
2145 #[test]
2146 fn format_kb_pages_renders_all_optional_fields_when_present() {
2147 let out = format_knowledge_base_pages(&[sample_kb_page()]);
2148 assert!(out.contains("# Knowledge Base Pages (1)"));
2149 assert!(out.contains("Architecture"));
2150 assert!(out.contains("`12345`"));
2151 assert!(out.contains("space: ENG"));
2152 assert!(out.contains("author: alice"));
2153 assert!(out.contains("updated: 2026-04-01T10:00:00Z"));
2154 assert!(out.contains("excerpt: Top-level architecture overview"));
2155 assert!(out.contains("https://wiki.example.com/pages/12345"));
2156 }
2157
2158 #[test]
2159 fn format_kb_pages_omits_absent_optional_fields() {
2160 let mut bare = sample_kb_page();
2161 bare.space_key = None;
2162 bare.author = None;
2163 bare.last_modified = None;
2164 bare.excerpt = None;
2165 bare.url = None;
2166 let out = format_knowledge_base_pages(&[bare]);
2167 assert!(!out.contains("space:"));
2168 assert!(!out.contains("author:"));
2169 assert!(!out.contains("updated:"));
2170 assert!(!out.contains("excerpt:"));
2171 assert!(!out.contains("https://"));
2172 }
2173
2174 #[test]
2175 fn format_kb_page_summary_includes_metadata_lines() {
2176 let out = format_knowledge_base_page_summary(&sample_kb_page());
2177 assert!(out.contains("# Knowledge Base Page"));
2178 assert!(out.contains("Architecture"));
2179 assert!(out.contains("`12345`"));
2180 assert!(out.contains("space: ENG"));
2181 assert!(out.contains("author: alice"));
2182 assert!(out.contains("updated: 2026-04-01T10:00:00Z"));
2183 assert!(out.contains("url: https://wiki.example.com/pages/12345"));
2184 }
2185
2186 #[test]
2187 fn format_kb_page_summary_skips_absent_fields() {
2188 let bare = devboy_core::KbPage {
2189 id: "x".into(),
2190 title: "Bare".into(),
2191 ..Default::default()
2192 };
2193 let out = format_knowledge_base_page_summary(&bare);
2194 assert!(out.contains("# Knowledge Base Page"));
2195 assert!(out.contains("Bare"));
2196 assert!(!out.contains("space:"));
2197 assert!(!out.contains("author:"));
2198 assert!(!out.contains("url:"));
2199 }
2200
2201 #[test]
2202 fn format_kb_page_renders_full_content_with_ancestors_and_labels() {
2203 let parent = devboy_core::KbPage {
2204 id: "p1".into(),
2205 title: "Parent".into(),
2206 ..Default::default()
2207 };
2208 let grandparent = devboy_core::KbPage {
2209 id: "p0".into(),
2210 title: "Root".into(),
2211 ..Default::default()
2212 };
2213 let content = devboy_core::KbPageContent {
2214 page: sample_kb_page(),
2215 content: "## Body\n\nFull markdown body.".into(),
2216 content_type: "markdown".into(),
2217 ancestors: vec![grandparent, parent],
2218 labels: vec!["arch".into(), "draft".into()],
2219 };
2220
2221 let out = format_knowledge_base_page(&content);
2222 assert!(out.starts_with("# Architecture\n"));
2223 assert!(out.contains("id: `12345`"));
2224 assert!(out.contains("space: `ENG`"));
2225 assert!(out.contains("content_type: `markdown`"));
2226 assert!(out.contains("labels: arch, draft"));
2227 assert!(out.contains("ancestors: Root > Parent"));
2228 assert!(out.contains("url: https://wiki.example.com/pages/12345"));
2229 assert!(out.contains("Full markdown body."));
2230 }
2231
2232 #[test]
2233 fn format_kb_page_omits_ancestors_and_labels_when_empty() {
2234 let content = devboy_core::KbPageContent {
2235 page: devboy_core::KbPage {
2236 id: "x".into(),
2237 title: "Solo".into(),
2238 ..Default::default()
2239 },
2240 content: "No metadata.".into(),
2241 content_type: "markdown".into(),
2242 ..Default::default()
2243 };
2244 let out = format_knowledge_base_page(&content);
2245 assert!(!out.contains("ancestors:"));
2246 assert!(!out.contains("labels:"));
2247 assert!(!out.contains("space:"));
2248 assert!(out.contains("No metadata."));
2249 }
2250}