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 }
1135 }
1136
1137 #[test]
1138 fn test_format_issues_toon() {
1139 let output = ToolOutput::Issues(vec![sample_issue()], None);
1140 let result = format_output(output, Some("toon"), None, None)
1141 .unwrap()
1142 .content;
1143 assert!(result.contains("gh#1"));
1144 assert!(result.contains("Test Issue"));
1145 }
1146
1147 #[test]
1148 fn test_format_metadata_toon_compression() {
1149 let output = ToolOutput::Issues(vec![sample_issue()], None);
1150 let result = format_output(output, Some("toon"), None, None).unwrap();
1151
1152 assert!(result.metadata.raw_chars > 0, "raw_chars should be > 0");
1153 assert!(
1154 result.metadata.output_chars > 0,
1155 "output_chars should be > 0"
1156 );
1157 assert!(result.metadata.estimated_tokens > 0, "tokens should be > 0");
1158 assert_eq!(result.metadata.format, "toon");
1159 assert!(!result.metadata.truncated);
1160 assert!(
1162 result.metadata.compression_ratio < 2.0,
1163 "compression_ratio should be reasonable, got {}",
1164 result.metadata.compression_ratio
1165 );
1166 }
1167
1168 #[test]
1169 fn test_format_metadata_text_passthrough() {
1170 let output = ToolOutput::Text("plain text".into());
1171 let result = format_output(output, None, None, None).unwrap();
1172
1173 assert_eq!(result.metadata.raw_chars, 10);
1174 assert_eq!(result.metadata.output_chars, 10);
1175 assert_eq!(result.metadata.compression_ratio, 1.0);
1176 assert_eq!(result.metadata.format, "text");
1177 assert!(!result.metadata.truncated);
1178 }
1179
1180 #[test]
1181 fn test_format_metadata_savings_split() {
1182 let issues: Vec<_> = (0..20).map(|_| sample_issue()).collect();
1185 let output = ToolOutput::Issues(issues, None);
1186 let result = format_output(output, Some("toon"), None, None).unwrap();
1187
1188 assert_eq!(result.metadata.dedup_savings_pct, 0.0);
1190 assert!(
1192 (0.0..1.0).contains(&result.metadata.encoder_savings_pct),
1193 "encoder savings out of range: {}",
1194 result.metadata.encoder_savings_pct
1195 );
1196 assert_eq!(
1198 result.metadata.combined_savings_pct,
1199 result.metadata.encoder_savings_pct
1200 );
1201 assert_eq!(result.metadata.baseline, "json_pretty");
1203 assert!(
1204 !result.metadata.tokenizer.is_empty(),
1205 "tokenizer must be set"
1206 );
1207 }
1208
1209 #[test]
1210 fn test_format_metadata_passthrough_savings_zero() {
1211 let output = ToolOutput::Text("nothing to compress".into());
1214 let result = format_output(output, None, None, None).unwrap();
1215 assert_eq!(result.metadata.dedup_savings_pct, 0.0);
1216 assert_eq!(result.metadata.encoder_savings_pct, 0.0);
1217 assert_eq!(result.metadata.combined_savings_pct, 0.0);
1218 assert_eq!(result.metadata.baseline, "json_pretty");
1219 assert!(!result.metadata.tokenizer.is_empty());
1220 }
1221
1222 #[test]
1223 fn test_format_metadata_truncated() {
1224 let output = ToolOutput::Issues(vec![sample_issue()], None);
1225 let config = PipelineConfig {
1226 max_chars: 50, ..PipelineConfig::default()
1228 };
1229 let result = format_output(output, Some("toon"), None, Some(config)).unwrap();
1230
1231 assert!(result.metadata.truncated);
1232 assert!(
1234 result.metadata.output_chars < result.metadata.raw_chars,
1235 "truncated output ({}) should be smaller than raw ({})",
1236 result.metadata.output_chars,
1237 result.metadata.raw_chars
1238 );
1239 }
1240
1241 #[test]
1242 fn test_format_issues_json() {
1243 let output = ToolOutput::Issues(vec![sample_issue()], None);
1244 let result = format_output(output, Some("json"), None, None)
1245 .unwrap()
1246 .content;
1247 assert!(result.contains("gh#1"));
1248 }
1249
1250 #[test]
1251 fn test_format_issues_toon_explicit() {
1252 let output = ToolOutput::Issues(vec![sample_issue()], None);
1253 let result = format_output(output, Some("toon"), None, None)
1254 .unwrap()
1255 .content;
1256 assert!(result.contains("gh#1"));
1257 }
1258
1259 #[test]
1260 fn test_format_text_passthrough() {
1261 let output = ToolOutput::Text("Comment created".into());
1262 let result = format_output(output, None, None, None).unwrap().content;
1263 assert_eq!(result, "Comment created");
1264 }
1265
1266 #[test]
1267 fn test_format_default_is_toon() {
1268 let output = ToolOutput::Issues(vec![sample_issue()], None);
1269 let result = format_output(output, None, None, None).unwrap().content;
1270 assert!(result.contains("gh#1"));
1271 }
1272
1273 #[test]
1274 fn test_format_single_issue() {
1275 let output = ToolOutput::SingleIssue(Box::new(sample_issue()));
1276 let result = format_output(output, Some("toon"), None, None)
1277 .unwrap()
1278 .content;
1279 assert!(result.contains("gh#1"));
1280 }
1281
1282 fn sample_mr() -> devboy_core::MergeRequest {
1283 devboy_core::MergeRequest {
1284 key: "pr#1".into(),
1285 title: "Test PR".into(),
1286 description: None,
1287 state: "open".into(),
1288 source: "github".into(),
1289 source_branch: "feature".into(),
1290 target_branch: "main".into(),
1291 author: None,
1292 assignees: vec![],
1293 reviewers: vec![],
1294 labels: vec![],
1295 draft: false,
1296 url: None,
1297 created_at: None,
1298 updated_at: None,
1299 }
1300 }
1301
1302 #[test]
1303 fn test_format_merge_requests() {
1304 let output = ToolOutput::MergeRequests(vec![sample_mr()], None);
1305 let result = format_output(output, Some("toon"), None, None)
1306 .unwrap()
1307 .content;
1308 assert!(result.contains("pr#1"));
1309 }
1310
1311 #[test]
1312 fn test_format_single_merge_request() {
1313 let output = ToolOutput::SingleMergeRequest(Box::new(sample_mr()));
1314 let result = format_output(output, Some("toon"), None, None)
1315 .unwrap()
1316 .content;
1317 assert!(result.contains("pr#1"));
1318 }
1319
1320 #[test]
1321 fn test_format_discussions() {
1322 let output = ToolOutput::Discussions(
1323 vec![devboy_core::Discussion {
1324 id: "d1".into(),
1325 resolved: false,
1326 resolved_by: None,
1327 comments: vec![devboy_core::Comment {
1328 id: "c1".into(),
1329 body: "Review comment".into(),
1330 author: None,
1331 created_at: None,
1332 updated_at: None,
1333 position: None,
1334 }],
1335 position: None,
1336 }],
1337 None,
1338 );
1339 let result = format_output(output, Some("toon"), None, None)
1340 .unwrap()
1341 .content;
1342 assert!(result.contains("Review comment"));
1343 }
1344
1345 #[test]
1346 fn test_format_diffs() {
1347 let output = ToolOutput::Diffs(
1348 vec![devboy_core::FileDiff {
1349 file_path: "src/main.rs".into(),
1350 old_path: None,
1351 new_file: false,
1352 deleted_file: false,
1353 renamed_file: false,
1354 diff: "+added line".into(),
1355 additions: Some(1),
1356 deletions: Some(0),
1357 }],
1358 None,
1359 );
1360 let result = format_output(output, Some("toon"), None, None)
1361 .unwrap()
1362 .content;
1363 assert!(result.contains("src/main.rs"));
1364 }
1365
1366 #[test]
1367 fn test_format_comments() {
1368 let output = ToolOutput::Comments(
1369 vec![devboy_core::Comment {
1370 id: "c1".into(),
1371 body: "A comment body".into(),
1372 author: None,
1373 created_at: None,
1374 updated_at: None,
1375 position: None,
1376 }],
1377 None,
1378 );
1379 let result = format_output(output, Some("json"), None, None)
1380 .unwrap()
1381 .content;
1382 assert!(result.contains("A comment body"));
1383 }
1384
1385 #[test]
1386 fn test_format_with_custom_pipeline_config() {
1387 let output = ToolOutput::Issues(vec![sample_issue()], None);
1388 let config = PipelineConfig {
1389 max_chars: 500,
1390 ..PipelineConfig::default()
1391 };
1392 let result = format_output(output, Some("toon"), None, Some(config))
1393 .unwrap()
1394 .content;
1395 assert!(result.contains("gh#1"));
1396 }
1397
1398 #[test]
1399 fn test_format_pipeline() {
1400 let output = ToolOutput::Pipeline(Box::new(devboy_core::PipelineInfo {
1401 id: "100".into(),
1402 status: devboy_core::PipelineStatus::Failed,
1403 reference: "main".into(),
1404 sha: "abc123def".into(),
1405 url: Some("https://example.com/pipeline/100".into()),
1406 duration: Some(120),
1407 coverage: Some(85.5),
1408 summary: devboy_core::PipelineSummary {
1409 total: 3,
1410 success: 2,
1411 failed: 1,
1412 ..Default::default()
1413 },
1414 stages: vec![devboy_core::PipelineStage {
1415 name: "build".into(),
1416 jobs: vec![devboy_core::PipelineJob {
1417 id: "1".into(),
1418 name: "compile".into(),
1419 status: devboy_core::PipelineStatus::Success,
1420 url: None,
1421 duration: Some(30),
1422 }],
1423 }],
1424 failed_jobs: vec![devboy_core::FailedJob {
1425 id: "2".into(),
1426 name: "test".into(),
1427 url: None,
1428 error_snippet: Some("error: test failed".into()),
1429 }],
1430 }));
1431 let result = format_output(output, None, None, None).unwrap().content;
1432 assert!(result.contains("Pipeline 100"));
1433 assert!(result.contains("failed"));
1434 assert!(result.contains("main"));
1435 assert!(result.contains("120s"));
1436 assert!(result.contains("compile"));
1437 assert!(result.contains("error: test failed"));
1438 }
1439
1440 #[test]
1441 fn test_format_job_log() {
1442 let output = ToolOutput::JobLog(Box::new(devboy_core::JobLogOutput {
1443 job_id: "202".into(),
1444 job_name: Some("test".into()),
1445 content: "error: assertion failed\nat src/test.rs:42".into(),
1446 mode: "smart".into(),
1447 total_lines: Some(100),
1448 }));
1449 let result = format_output(output, None, None, None).unwrap().content;
1450 assert!(result.contains("Job Log"));
1451 assert!(result.contains("202"));
1452 assert!(result.contains("smart"));
1453 assert!(result.contains("assertion failed"));
1454 }
1455
1456 #[test]
1459 fn test_format_pipeline_success_status() {
1460 let output = ToolOutput::Pipeline(Box::new(devboy_core::PipelineInfo {
1461 id: "200".into(),
1462 status: devboy_core::PipelineStatus::Success,
1463 reference: "develop".into(),
1464 sha: "deadbeefcafe".into(),
1465 url: None,
1466 duration: None,
1467 coverage: None,
1468 summary: devboy_core::PipelineSummary {
1469 total: 5,
1470 success: 5,
1471 ..Default::default()
1472 },
1473 stages: vec![],
1474 failed_jobs: vec![],
1475 }));
1476 let result = format_output(output, None, None, None).unwrap().content;
1477 assert!(result.contains("Pipeline 200"));
1478 assert!(result.contains("success"));
1479 assert!(result.contains("develop"));
1480 assert!(result.contains("deadbee")); }
1482
1483 #[test]
1484 fn test_format_pipeline_running_status() {
1485 let output = ToolOutput::Pipeline(Box::new(devboy_core::PipelineInfo {
1486 id: "301".into(),
1487 status: devboy_core::PipelineStatus::Running,
1488 reference: "feature".into(),
1489 sha: "1234567890abcdef".into(),
1490 url: Some("https://ci.example.com/301".into()),
1491 duration: Some(60),
1492 coverage: None,
1493 summary: devboy_core::PipelineSummary {
1494 total: 3,
1495 running: 1,
1496 success: 1,
1497 pending: 1,
1498 ..Default::default()
1499 },
1500 stages: vec![],
1501 failed_jobs: vec![],
1502 }));
1503 let result = format_output(output, None, None, None).unwrap().content;
1504 assert!(result.contains("running"));
1505 assert!(result.contains("https://ci.example.com/301"));
1506 assert!(result.contains("60s"));
1507 }
1508
1509 #[test]
1510 fn test_format_pipeline_pending_status() {
1511 let output = ToolOutput::Pipeline(Box::new(devboy_core::PipelineInfo {
1512 id: "302".into(),
1513 status: devboy_core::PipelineStatus::Pending,
1514 reference: "main".into(),
1515 sha: "aabbccdd".into(),
1516 url: None,
1517 duration: None,
1518 coverage: None,
1519 summary: Default::default(),
1520 stages: vec![],
1521 failed_jobs: vec![],
1522 }));
1523 let result = format_output(output, None, None, None).unwrap().content;
1524 assert!(result.contains("pending"));
1525 }
1526
1527 #[test]
1528 fn test_format_pipeline_canceled_status() {
1529 let output = ToolOutput::Pipeline(Box::new(devboy_core::PipelineInfo {
1530 id: "303".into(),
1531 status: devboy_core::PipelineStatus::Canceled,
1532 reference: "main".into(),
1533 sha: "1122334455".into(),
1534 url: None,
1535 duration: None,
1536 coverage: None,
1537 summary: Default::default(),
1538 stages: vec![],
1539 failed_jobs: vec![],
1540 }));
1541 let result = format_output(output, None, None, None).unwrap().content;
1542 assert!(result.contains("canceled"));
1543 }
1544
1545 #[test]
1546 fn test_format_pipeline_with_job_url() {
1547 let output = ToolOutput::Pipeline(Box::new(devboy_core::PipelineInfo {
1548 id: "400".into(),
1549 status: devboy_core::PipelineStatus::Failed,
1550 reference: "main".into(),
1551 sha: "abcdef1234567".into(),
1552 url: None,
1553 duration: None,
1554 coverage: None,
1555 summary: Default::default(),
1556 stages: vec![devboy_core::PipelineStage {
1557 name: "test".into(),
1558 jobs: vec![devboy_core::PipelineJob {
1559 id: "j1".into(),
1560 name: "unit-test".into(),
1561 status: devboy_core::PipelineStatus::Failed,
1562 url: Some("https://ci.example.com/jobs/j1".into()),
1563 duration: None,
1564 }],
1565 }],
1566 failed_jobs: vec![],
1567 }));
1568 let result = format_output(output, None, None, None).unwrap().content;
1569 assert!(result.contains("[logs](https://ci.example.com/jobs/j1)"));
1570 }
1571
1572 #[test]
1573 fn test_format_pipeline_failed_job_without_snippet() {
1574 let output = ToolOutput::Pipeline(Box::new(devboy_core::PipelineInfo {
1575 id: "401".into(),
1576 status: devboy_core::PipelineStatus::Failed,
1577 reference: "main".into(),
1578 sha: "abcdef1234567".into(),
1579 url: None,
1580 duration: None,
1581 coverage: None,
1582 summary: Default::default(),
1583 stages: vec![],
1584 failed_jobs: vec![devboy_core::FailedJob {
1585 id: "fj1".into(),
1586 name: "lint".into(),
1587 url: None,
1588 error_snippet: None,
1589 }],
1590 }));
1591 let result = format_output(output, None, None, None).unwrap().content;
1592 assert!(result.contains("lint"));
1593 assert!(result.contains("fj1"));
1594 assert!(!result.contains("```")); }
1596
1597 #[test]
1600 fn test_format_statuses() {
1601 let output = ToolOutput::Statuses(
1602 vec![
1603 devboy_core::IssueStatus {
1604 id: "1".into(),
1605 name: "To Do".into(),
1606 category: "todo".into(),
1607 color: Some("#blue".into()),
1608 order: Some(0),
1609 },
1610 devboy_core::IssueStatus {
1611 id: "2".into(),
1612 name: "In Progress".into(),
1613 category: "in_progress".into(),
1614 color: None,
1615 order: None,
1616 },
1617 ],
1618 None,
1619 );
1620 let result = format_output(output, None, None, None).unwrap().content;
1621 assert!(result.contains("Available Statuses"));
1622 assert!(result.contains("To Do"));
1623 assert!(result.contains("In Progress"));
1624 assert!(result.contains("#blue"));
1625 assert!(result.contains("todo"));
1626 assert!(result.contains("| - |")); }
1628
1629 #[test]
1630 fn test_format_statuses_empty() {
1631 let output = ToolOutput::Statuses(vec![], None);
1632 let result = format_output(output, None, None, None).unwrap().content;
1633 assert_eq!(result, "No statuses found.");
1634 }
1635
1636 #[test]
1639 fn test_format_users() {
1640 let output = ToolOutput::Users(
1641 vec![
1642 devboy_core::User {
1643 id: "u1".into(),
1644 username: "johndoe".into(),
1645 name: Some("John Doe".into()),
1646 email: Some("john@example.com".into()),
1647 avatar_url: None,
1648 },
1649 devboy_core::User {
1650 id: "u2".into(),
1651 username: "janesmith".into(),
1652 name: None,
1653 email: None,
1654 avatar_url: None,
1655 },
1656 ],
1657 None,
1658 );
1659 let result = format_output(output, None, None, None).unwrap().content;
1660 assert!(result.contains("# Users"));
1661 assert!(result.contains("johndoe"));
1662 assert!(result.contains("John Doe"));
1663 assert!(result.contains("john@example.com"));
1664 assert!(result.contains("janesmith"));
1665 assert!(result.contains("| - |")); }
1667
1668 #[test]
1669 fn test_format_users_empty() {
1670 let output = ToolOutput::Users(vec![], None);
1671 let result = format_output(output, None, None, None).unwrap().content;
1672 assert_eq!(result, "No users found.");
1673 }
1674
1675 fn sample_project_version(name: &str) -> devboy_core::ProjectVersion {
1678 devboy_core::ProjectVersion {
1679 id: "1".into(),
1680 project: "PROJ".into(),
1681 name: name.into(),
1682 description: Some("Initial release".into()),
1683 start_date: Some("2025-01-01".into()),
1684 release_date: Some("2025-02-01".into()),
1685 released: true,
1686 archived: false,
1687 overdue: Some(false),
1688 issue_count: Some(7),
1689 unresolved_issue_count: None,
1690 source: "jira".into(),
1691 }
1692 }
1693
1694 #[test]
1695 fn format_project_versions_empty_returns_canonical_message() {
1696 let output = ToolOutput::ProjectVersions(vec![], None);
1697 let result = format_output(output, None, None, None).unwrap().content;
1698 assert_eq!(result, "No project versions found.");
1699 }
1700
1701 #[test]
1702 fn format_project_versions_renders_table_with_counts_and_dates() {
1703 let output = ToolOutput::ProjectVersions(vec![sample_project_version("3.18.0")], None);
1704 let result = format_output(output, None, None, None).unwrap().content;
1705 assert!(result.contains("# Project Versions (1)"), "{result}");
1706 assert!(result.contains("| Name |"), "{result}");
1707 assert!(result.contains("| 3.18.0 |"), "{result}");
1708 assert!(result.contains("| yes |"), "{result}");
1709 assert!(result.contains("2025-02-01"), "{result}");
1710 assert!(result.contains("Initial release"), "{result}");
1711 }
1712
1713 #[test]
1714 fn format_project_versions_marks_archived_inline() {
1715 let mut v = sample_project_version("0.9.0");
1716 v.archived = true;
1717 let output = ToolOutput::ProjectVersions(vec![v], None);
1718 let result = format_output(output, None, None, None).unwrap().content;
1719 assert!(
1720 result.contains("0.9.0 (archived)"),
1721 "expected archived marker, got {result}"
1722 );
1723 }
1724
1725 #[test]
1726 fn format_project_versions_truncates_long_descriptions() {
1727 let mut v = sample_project_version("1.0.0");
1728 v.description = Some("x".repeat(200));
1729 let output = ToolOutput::ProjectVersions(vec![v], None);
1730 let result = format_output(output, None, None, None).unwrap().content;
1731 assert!(result.contains('…'), "expected ellipsis, got {result}");
1732 }
1733
1734 #[test]
1735 fn format_single_project_version_renders_detail_block() {
1736 let v = sample_project_version("3.18.0");
1737 let output = ToolOutput::SingleProjectVersion(Box::new(v));
1738 let result = format_output(output, None, None, None).unwrap().content;
1739 assert!(result.contains("# 3.18.0 (project PROJ)"), "{result}");
1740 assert!(result.contains("- **id:** 1"), "{result}");
1741 assert!(result.contains("- **released:** yes"), "{result}");
1742 assert!(result.contains("## Description"), "{result}");
1743 assert!(result.contains("Initial release"), "{result}");
1744 }
1745
1746 #[test]
1747 fn format_project_versions_escapes_pipes_in_name_and_description() {
1748 let mut v = sample_project_version("v|1.0");
1751 v.description = Some("Highlights | breaking changes".into());
1752 let output = ToolOutput::ProjectVersions(vec![v], None);
1753 let result = format_output(output, None, None, None).unwrap().content;
1754 assert!(
1755 result.contains("v\\|1.0"),
1756 "name pipe not escaped: {result}"
1757 );
1758 assert!(
1759 result.contains("Highlights \\| breaking changes"),
1760 "description pipe not escaped: {result}"
1761 );
1762 let line = result
1765 .lines()
1766 .find(|l| l.starts_with("| v\\|1.0"))
1767 .expect("expected table row, got: {result}");
1768 let cells = line.split(" | ").count();
1769 assert!(cells <= 6, "row split into too many cells: {line:?}");
1770 }
1771
1772 #[test]
1773 fn format_project_versions_emits_more_hint_when_truncated() {
1774 let pagination = devboy_core::Pagination {
1778 offset: 0,
1779 limit: 1,
1780 total: Some(35),
1781 has_more: true,
1782 next_cursor: None,
1783 };
1784 let v = sample_project_version("3.18.0");
1785 let output = ToolOutput::ProjectVersions(
1786 vec![v],
1787 Some(crate::output::ResultMeta {
1788 pagination: Some(pagination),
1789 sort_info: None,
1790 }),
1791 );
1792 let result = format_output(output, None, None, None).unwrap().content;
1793 assert!(
1794 result.contains("Project Versions (1 of 35)"),
1795 "expected 'X of Y' header: {result}"
1796 );
1797 assert!(
1798 result.contains("[+34 more"),
1799 "expected +N more hint: {result}"
1800 );
1801 assert!(
1802 result.contains("`limit: 35`"),
1803 "expected limit suggestion: {result}"
1804 );
1805 }
1806
1807 #[test]
1808 fn format_project_versions_hint_caps_limit_at_max_and_uses_archived_all() {
1809 let pagination = devboy_core::Pagination {
1813 offset: 0,
1814 limit: 1,
1815 total: Some(5_000),
1816 has_more: true,
1817 next_cursor: None,
1818 };
1819 let v = sample_project_version("3.18.0");
1820 let output = ToolOutput::ProjectVersions(
1821 vec![v],
1822 Some(crate::output::ResultMeta {
1823 pagination: Some(pagination),
1824 sort_info: None,
1825 }),
1826 );
1827 let result = format_output(output, None, None, None).unwrap().content;
1828 assert!(
1829 result.contains("`limit: 200`"),
1830 "limit suggestion should clamp at 200, got: {result}"
1831 );
1832 assert!(
1833 result.contains("`archived: \"all\"`"),
1834 "expected archived hint to suggest 'all', got: {result}"
1835 );
1836 assert!(
1837 !result.contains("`archived: true`"),
1838 "must not suggest archived: true (means 'archived only'), got: {result}"
1839 );
1840 }
1841
1842 #[test]
1843 fn format_project_versions_renders_unresolved_only_cell() {
1844 let mut v = sample_project_version("3.18.0");
1848 v.issue_count = None;
1849 v.unresolved_issue_count = Some(4);
1850 let output = ToolOutput::ProjectVersions(vec![v], None);
1851 let result = format_output(output, None, None, None).unwrap().content;
1852 assert!(
1853 result.contains("4 open"),
1854 "expected '4 open' marker, got: {result}"
1855 );
1856 }
1857
1858 #[test]
1859 fn format_single_project_version_renders_unresolved_count() {
1860 let mut v = sample_project_version("3.18.0");
1861 v.issue_count = Some(20);
1862 v.unresolved_issue_count = Some(7);
1863 let output = ToolOutput::SingleProjectVersion(Box::new(v));
1864 let result = format_output(output, None, None, None).unwrap().content;
1865 assert!(result.contains("- **issue_count:** 20"), "{result}");
1866 assert!(
1867 result.contains("- **unresolved_issue_count:** 7"),
1868 "{result}"
1869 );
1870 }
1871
1872 #[test]
1873 fn format_project_versions_no_hint_when_not_truncated() {
1874 let pagination = devboy_core::Pagination {
1875 offset: 0,
1876 limit: 5,
1877 total: Some(1),
1878 has_more: false,
1879 next_cursor: None,
1880 };
1881 let v = sample_project_version("3.18.0");
1882 let output = ToolOutput::ProjectVersions(
1883 vec![v],
1884 Some(crate::output::ResultMeta {
1885 pagination: Some(pagination),
1886 sort_info: None,
1887 }),
1888 );
1889 let result = format_output(output, None, None, None).unwrap().content;
1890 assert!(
1891 !result.contains("more"),
1892 "shouldn't suggest more results: {result}"
1893 );
1894 }
1895
1896 #[test]
1897 fn escape_table_cell_handles_backslash_and_pipe() {
1898 assert_eq!(escape_table_cell("a|b"), "a\\|b");
1899 assert_eq!(escape_table_cell("a\\b"), "a\\\\b");
1900 assert_eq!(escape_table_cell("a\\|b"), "a\\\\\\|b");
1903 assert_eq!(escape_table_cell("plain"), "plain");
1904 }
1905
1906 #[test]
1909 fn test_format_job_log_no_total_lines() {
1910 let output = ToolOutput::JobLog(Box::new(devboy_core::JobLogOutput {
1911 job_id: "999".into(),
1912 job_name: Some("build".into()),
1913 content: "Building...".into(),
1914 mode: "full".into(),
1915 total_lines: None,
1916 }));
1917 let result = format_output(output, None, None, None).unwrap().content;
1918 assert!(result.contains("Job Log (999)"));
1919 assert!(result.contains("**Mode:** full"));
1920 assert!(!result.contains("Total lines"));
1921 assert!(result.contains("Building..."));
1922 }
1923
1924 #[test]
1927 fn test_format_text_empty_string() {
1928 let output = ToolOutput::Text("".into());
1929 let result = format_output(output, None, None, None).unwrap().content;
1930 assert_eq!(result, "");
1931 }
1932
1933 #[test]
1934 fn test_format_text_with_json_format_param() {
1935 let output = ToolOutput::Text("raw text".into());
1937 let result = format_output(output, Some("json"), None, None)
1938 .unwrap()
1939 .content;
1940 assert_eq!(result, "raw text");
1941 }
1942
1943 #[test]
1946 fn test_format_meeting_notes() {
1947 let meetings = vec![devboy_core::MeetingNote {
1948 id: "m1".into(),
1949 title: "Sprint Planning".into(),
1950 meeting_date: Some("2025-01-15T10:00:00Z".into()),
1951 duration_seconds: Some(2700), host_email: Some("host@example.com".into()),
1953 participants: vec!["alice@example.com".into(), "bob@example.com".into()],
1954 action_items: vec!["Review PR #42".into(), "Update docs".into()],
1955 keywords: vec!["sprint".into(), "planning".into()],
1956 summary: Some("Discussed sprint goals.".into()),
1957 ..Default::default()
1958 }];
1959 let output = ToolOutput::MeetingNotes(meetings, None);
1960 let result = format_output(output, None, None, None).unwrap().content;
1961 assert!(result.contains("Sprint Planning"));
1962 assert!(result.contains("2025-01-15T10:00:00Z"));
1963 assert!(result.contains("45 min"));
1964 assert!(result.contains("host@example.com"));
1965 assert!(result.contains("alice@example.com"));
1966 assert!(result.contains("Review PR #42"));
1967 assert!(result.contains("Update docs"));
1968 assert!(result.contains("sprint"));
1969 assert!(result.contains("Discussed sprint goals."));
1970 }
1971
1972 #[test]
1973 fn test_format_meeting_notes_empty() {
1974 let output = ToolOutput::MeetingNotes(vec![], None);
1975 let result = format_output(output, None, None, None).unwrap().content;
1976 assert_eq!(result, "No meeting notes found.");
1977 }
1978
1979 #[test]
1980 fn test_format_meeting_transcript() {
1981 let transcript = devboy_core::MeetingTranscript {
1982 meeting_id: "m1".into(),
1983 title: Some("Sprint Planning".into()),
1984 sentences: vec![
1985 devboy_core::TranscriptSentence {
1986 speaker_id: "s1".into(),
1987 speaker_name: Some("Alice".into()),
1988 text: "Let's start the meeting.".into(),
1989 start_time: 0.0,
1990 end_time: 3.0,
1991 },
1992 devboy_core::TranscriptSentence {
1993 speaker_id: "s2".into(),
1994 speaker_name: Some("Bob".into()),
1995 text: "Sounds good.".into(),
1996 start_time: 5.0,
1997 end_time: 7.0,
1998 },
1999 ],
2000 };
2001 let output = ToolOutput::MeetingTranscript(Box::new(transcript));
2002 let result = format_output(output, None, None, None).unwrap().content;
2003 assert!(result.contains("Sprint Planning"));
2004 assert!(result.contains("2 sentences"));
2005 assert!(result.contains("[00:00] Alice: Let's start the meeting."));
2006 assert!(result.contains("[00:05] Bob: Sounds good."));
2007 }
2008
2009 #[test]
2010 fn test_format_meeting_transcript_unknown_speaker() {
2011 let transcript = devboy_core::MeetingTranscript {
2012 meeting_id: "m1".into(),
2013 title: None,
2014 sentences: vec![devboy_core::TranscriptSentence {
2015 speaker_id: "".into(),
2016 speaker_name: None,
2017 text: "Hello".into(),
2018 start_time: 0.0,
2019 end_time: 1.0,
2020 }],
2021 };
2022 let output = ToolOutput::MeetingTranscript(Box::new(transcript));
2023 let result = format_output(output, None, None, None).unwrap().content;
2024 assert!(result.contains("Meeting Transcript"));
2025 assert!(result.contains("Unknown speaker"));
2026 }
2027
2028 #[test]
2031 fn test_format_relations() {
2032 let relations = devboy_core::IssueRelations {
2033 parent: Some(sample_issue()),
2034 subtasks: vec![sample_issue()],
2035 blocks: vec![devboy_core::IssueLink {
2036 issue: sample_issue(),
2037 link_type: "Blocks".into(),
2038 }],
2039 blocked_by: vec![],
2040 related_to: vec![],
2041 duplicates: vec![],
2042 epic_key: None,
2043 };
2044 let output = ToolOutput::Relations(Box::new(relations));
2045 let result = format_output(output, None, None, None).unwrap().content;
2046 assert!(result.contains("gh#1"));
2048 assert!(result.contains("Blocks"));
2049 assert!(result.contains("Test Issue"));
2050 }
2051
2052 #[test]
2053 fn test_format_relations_empty() {
2054 let relations = devboy_core::IssueRelations::default();
2055 let output = ToolOutput::Relations(Box::new(relations));
2056 let result = format_output(output, None, None, None).unwrap().content;
2057 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2059 assert!(parsed.is_object());
2060 }
2061
2062 #[test]
2065 fn test_format_time_zero() {
2066 assert_eq!(format_time(0.0), "00:00");
2067 }
2068
2069 #[test]
2070 fn test_format_time_seconds_only() {
2071 assert_eq!(format_time(45.0), "00:45");
2072 }
2073
2074 #[test]
2075 fn test_format_time_minutes_and_seconds() {
2076 assert_eq!(format_time(125.0), "02:05");
2077 }
2078
2079 #[test]
2080 fn test_format_time_hours() {
2081 assert_eq!(format_time(3661.0), "01:01:01");
2082 }
2083
2084 #[test]
2085 fn test_format_time_fractional_seconds() {
2086 assert_eq!(format_time(59.9), "00:59");
2088 }
2089
2090 fn sample_kb_space() -> devboy_core::KbSpace {
2095 devboy_core::KbSpace {
2096 id: "100".into(),
2097 key: "ENG".into(),
2098 name: "Engineering".into(),
2099 description: Some("Team docs".into()),
2100 url: Some("https://wiki.example.com/spaces/ENG".into()),
2101 ..Default::default()
2102 }
2103 }
2104
2105 fn sample_kb_page() -> devboy_core::KbPage {
2106 devboy_core::KbPage {
2107 id: "12345".into(),
2108 title: "Architecture".into(),
2109 space_key: Some("ENG".into()),
2110 url: Some("https://wiki.example.com/pages/12345".into()),
2111 author: Some("alice".into()),
2112 last_modified: Some("2026-04-01T10:00:00Z".into()),
2113 excerpt: Some("Top-level architecture overview".into()),
2114 ..Default::default()
2115 }
2116 }
2117
2118 #[test]
2119 fn format_kb_spaces_empty_returns_canonical_message() {
2120 assert_eq!(
2121 format_knowledge_base_spaces(&[]),
2122 "No knowledge base spaces found."
2123 );
2124 }
2125
2126 #[test]
2127 fn format_kb_spaces_includes_count_name_key_description_url() {
2128 let out = format_knowledge_base_spaces(&[sample_kb_space()]);
2129 assert!(out.contains("# Knowledge Base Spaces (1)"));
2130 assert!(out.contains("Engineering"));
2131 assert!(out.contains("`ENG`"));
2132 assert!(out.contains("Team docs"));
2133 assert!(out.contains("https://wiki.example.com/spaces/ENG"));
2134 }
2135
2136 #[test]
2137 fn format_kb_pages_empty_returns_canonical_message() {
2138 assert_eq!(
2139 format_knowledge_base_pages(&[]),
2140 "No knowledge base pages found."
2141 );
2142 }
2143
2144 #[test]
2145 fn format_kb_pages_renders_all_optional_fields_when_present() {
2146 let out = format_knowledge_base_pages(&[sample_kb_page()]);
2147 assert!(out.contains("# Knowledge Base Pages (1)"));
2148 assert!(out.contains("Architecture"));
2149 assert!(out.contains("`12345`"));
2150 assert!(out.contains("space: ENG"));
2151 assert!(out.contains("author: alice"));
2152 assert!(out.contains("updated: 2026-04-01T10:00:00Z"));
2153 assert!(out.contains("excerpt: Top-level architecture overview"));
2154 assert!(out.contains("https://wiki.example.com/pages/12345"));
2155 }
2156
2157 #[test]
2158 fn format_kb_pages_omits_absent_optional_fields() {
2159 let mut bare = sample_kb_page();
2160 bare.space_key = None;
2161 bare.author = None;
2162 bare.last_modified = None;
2163 bare.excerpt = None;
2164 bare.url = None;
2165 let out = format_knowledge_base_pages(&[bare]);
2166 assert!(!out.contains("space:"));
2167 assert!(!out.contains("author:"));
2168 assert!(!out.contains("updated:"));
2169 assert!(!out.contains("excerpt:"));
2170 assert!(!out.contains("https://"));
2171 }
2172
2173 #[test]
2174 fn format_kb_page_summary_includes_metadata_lines() {
2175 let out = format_knowledge_base_page_summary(&sample_kb_page());
2176 assert!(out.contains("# Knowledge Base Page"));
2177 assert!(out.contains("Architecture"));
2178 assert!(out.contains("`12345`"));
2179 assert!(out.contains("space: ENG"));
2180 assert!(out.contains("author: alice"));
2181 assert!(out.contains("updated: 2026-04-01T10:00:00Z"));
2182 assert!(out.contains("url: https://wiki.example.com/pages/12345"));
2183 }
2184
2185 #[test]
2186 fn format_kb_page_summary_skips_absent_fields() {
2187 let bare = devboy_core::KbPage {
2188 id: "x".into(),
2189 title: "Bare".into(),
2190 ..Default::default()
2191 };
2192 let out = format_knowledge_base_page_summary(&bare);
2193 assert!(out.contains("# Knowledge Base Page"));
2194 assert!(out.contains("Bare"));
2195 assert!(!out.contains("space:"));
2196 assert!(!out.contains("author:"));
2197 assert!(!out.contains("url:"));
2198 }
2199
2200 #[test]
2201 fn format_kb_page_renders_full_content_with_ancestors_and_labels() {
2202 let parent = devboy_core::KbPage {
2203 id: "p1".into(),
2204 title: "Parent".into(),
2205 ..Default::default()
2206 };
2207 let grandparent = devboy_core::KbPage {
2208 id: "p0".into(),
2209 title: "Root".into(),
2210 ..Default::default()
2211 };
2212 let content = devboy_core::KbPageContent {
2213 page: sample_kb_page(),
2214 content: "## Body\n\nFull markdown body.".into(),
2215 content_type: "markdown".into(),
2216 ancestors: vec![grandparent, parent],
2217 labels: vec!["arch".into(), "draft".into()],
2218 };
2219
2220 let out = format_knowledge_base_page(&content);
2221 assert!(out.starts_with("# Architecture\n"));
2222 assert!(out.contains("id: `12345`"));
2223 assert!(out.contains("space: `ENG`"));
2224 assert!(out.contains("content_type: `markdown`"));
2225 assert!(out.contains("labels: arch, draft"));
2226 assert!(out.contains("ancestors: Root > Parent"));
2227 assert!(out.contains("url: https://wiki.example.com/pages/12345"));
2228 assert!(out.contains("Full markdown body."));
2229 }
2230
2231 #[test]
2232 fn format_kb_page_omits_ancestors_and_labels_when_empty() {
2233 let content = devboy_core::KbPageContent {
2234 page: devboy_core::KbPage {
2235 id: "x".into(),
2236 title: "Solo".into(),
2237 ..Default::default()
2238 },
2239 content: "No metadata.".into(),
2240 content_type: "markdown".into(),
2241 ..Default::default()
2242 };
2243 let out = format_knowledge_base_page(&content);
2244 assert!(!out.contains("ancestors:"));
2245 assert!(!out.contains("labels:"));
2246 assert!(!out.contains("space:"));
2247 assert!(out.contains("No metadata."));
2248 }
2249}