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::Text(text) => Ok(text_result(text, None, None)),
488 }
489}
490
491fn format_messenger_chats(chats: &[devboy_core::MessengerChat]) -> String {
493 if chats.is_empty() {
494 return "No chats found.".to_string();
495 }
496
497 let mut output = format!("# Messenger Chats ({})\n\n", chats.len());
498 for chat in chats {
499 let description = chat.description.as_deref().unwrap_or("-");
500 let members = chat
501 .member_count
502 .map(|count| count.to_string())
503 .unwrap_or_else(|| "-".to_string());
504 let active = if chat.is_active { "active" } else { "inactive" };
505 let chat_type = match chat.chat_type {
506 devboy_core::types::ChatType::Direct => "direct",
507 devboy_core::types::ChatType::Group => "group",
508 devboy_core::types::ChatType::Channel => "channel",
509 };
510 output.push_str(&format!(
511 "- {} [{}] id=`{}` members={} status={} desc={}\n",
512 chat.name, chat_type, chat.id, members, active, description
513 ));
514 }
515 output
516}
517
518fn format_messenger_messages(messages: &[devboy_core::MessengerMessage]) -> String {
520 if messages.is_empty() {
521 return "No messages found.".to_string();
522 }
523
524 let mut output = format!("# Messages ({})\n\n", messages.len());
525 for message in messages {
526 output.push_str(&format_single_messenger_message(message));
527 output.push('\n');
528 }
529 output
530}
531
532fn format_single_messenger_message(message: &devboy_core::MessengerMessage) -> String {
534 let text = message.text.replace('\r', "\\r").replace('\n', "\\n");
535 let mut line = format!(
536 "- [{}] {} ({}) in `{}`: {}",
537 message.timestamp, message.author.name, message.author.id, message.chat_id, text
538 );
539 if let Some(thread_id) = message.thread_id.as_deref() {
540 line.push_str(&format!(" thread=`{}`", thread_id));
541 }
542 if !message.attachments.is_empty() {
543 line.push_str(&format!(" attachments={}", message.attachments.len()));
544 }
545 line
546}
547
548fn format_statuses(statuses: &[devboy_core::IssueStatus]) -> String {
550 if statuses.is_empty() {
551 return "No statuses found.".to_string();
552 }
553
554 let mut output = String::from("# Available Statuses\n\n");
555 output.push_str("| ID | Name | Category | Color | Order |\n");
556 output.push_str("|---|---|---|---|---|\n");
557
558 for s in statuses {
559 let color = s.color.as_deref().unwrap_or("-");
560 let order = s
561 .order
562 .map(|o| o.to_string())
563 .unwrap_or_else(|| "-".to_string());
564 output.push_str(&format!(
565 "| {} | {} | {} | {} | {} |\n",
566 s.id, s.name, s.category, color, order
567 ));
568 }
569
570 output
571}
572
573fn format_project_versions(
584 versions: &[devboy_core::ProjectVersion],
585 pagination: Option<&devboy_core::Pagination>,
586) -> String {
587 if versions.is_empty() {
588 return "No project versions found.".to_string();
589 }
590
591 let total = pagination
592 .and_then(|p| p.total)
593 .unwrap_or(versions.len() as u32);
594 let shown = versions.len() as u32;
595 let header = if total > shown {
596 format!("# Project Versions ({} of {})\n\n", shown, total)
597 } else {
598 format!("# Project Versions ({})\n\n", shown)
599 };
600 let mut output = header;
601 output.push_str("| Name | Released | Release Date | Issues | Description |\n");
602 output.push_str("|---|---|---|---|---|\n");
603
604 for v in versions {
605 let released = if v.released { "yes" } else { "no" };
606 let release_date = v.release_date.as_deref().unwrap_or("-");
607 let issue_count = match (v.issue_count, v.unresolved_issue_count) {
612 (Some(t), Some(u)) => format!("{t} ({u} open)"),
613 (Some(t), None) => t.to_string(),
614 (None, Some(u)) => format!("{u} open"),
615 (None, None) => "-".to_string(),
616 };
617 let description = match v.description.as_deref() {
618 None | Some("") => "-".to_string(),
619 Some(d) => escape_table_cell(&truncate_for_table(d, 120)),
620 };
621 let archived_marker = if v.archived { " (archived)" } else { "" };
622 output.push_str(&format!(
623 "| {}{} | {} | {} | {} | {} |\n",
624 escape_table_cell(&v.name),
625 archived_marker,
626 released,
627 release_date,
628 issue_count,
629 description
630 ));
631 }
632
633 if total > shown {
634 let omitted = total - shown;
635 let suggested_limit = total.min(MAX_VERSION_LIMIT);
641 output.push_str(&format!(
642 "\n[+{omitted} more — call with `limit: {suggested_limit}` (or `archived: \"all\"` to include archived versions)]\n"
643 ));
644 }
645
646 output
647}
648
649const MAX_VERSION_LIMIT: u32 = 200;
653
654fn escape_table_cell(s: &str) -> String {
659 s.replace('\\', "\\\\").replace('|', "\\|")
660}
661
662fn format_single_project_version(v: &devboy_core::ProjectVersion) -> String {
666 let safe_name = v.name.replace(['\n', '\r'], " ");
670 let mut output = format!("# {} (project {})\n\n", safe_name, v.project);
671 output.push_str(&format!("- **id:** {}\n", v.id));
672 output.push_str(&format!(
673 "- **released:** {}\n",
674 if v.released { "yes" } else { "no" }
675 ));
676 output.push_str(&format!(
677 "- **archived:** {}\n",
678 if v.archived { "yes" } else { "no" }
679 ));
680 if let Some(ref d) = v.start_date {
681 output.push_str(&format!("- **start_date:** {d}\n"));
682 }
683 if let Some(ref d) = v.release_date {
684 output.push_str(&format!("- **release_date:** {d}\n"));
685 }
686 if let Some(overdue) = v.overdue {
687 output.push_str(&format!("- **overdue:** {overdue}\n"));
688 }
689 if let Some(count) = v.issue_count {
690 output.push_str(&format!("- **issue_count:** {count}\n"));
691 }
692 if let Some(count) = v.unresolved_issue_count {
693 output.push_str(&format!("- **unresolved_issue_count:** {count}\n"));
694 }
695 if let Some(ref desc) = v.description.as_deref().filter(|d| !d.is_empty()) {
696 output.push_str(&format!("\n## Description\n\n{desc}\n"));
697 }
698 output
699}
700
701fn truncate_for_table(s: &str, max_chars: usize) -> String {
705 let single_line: String = s
706 .chars()
707 .map(|c| if c == '\n' || c == '\r' { ' ' } else { c })
708 .collect();
709 let count = single_line.chars().count();
710 if count <= max_chars {
711 return single_line;
712 }
713 let mut out: String = single_line.chars().take(max_chars).collect();
714 out.push('…');
715 out
716}
717
718fn format_users(users: &[devboy_core::User]) -> String {
720 if users.is_empty() {
721 return "No users found.".to_string();
722 }
723
724 let mut output = String::from("# Users\n\n");
725 output.push_str("| ID | Username | Name | Email |\n");
726 output.push_str("|---|---|---|---|\n");
727
728 for u in users {
729 let name = u.name.as_deref().unwrap_or("-");
730 let email = u.email.as_deref().unwrap_or("-");
731 output.push_str(&format!(
732 "| {} | {} | {} | {} |\n",
733 u.id, u.username, name, email
734 ));
735 }
736
737 output
738}
739
740fn format_meeting_notes(meetings: &[devboy_core::MeetingNote]) -> String {
742 if meetings.is_empty() {
743 return "No meeting notes found.".to_string();
744 }
745
746 let mut output = format!("# Meeting Notes ({} results)\n\n", meetings.len());
747
748 for m in meetings {
749 output.push_str(&format!("## {}\n", m.title));
750 if let Some(ref date) = m.meeting_date {
751 output.push_str(&format!("**Date:** {date}\n"));
752 }
753 if let Some(secs) = m.duration_seconds {
754 let mins = secs / 60;
755 output.push_str(&format!("**Duration:** {mins} min\n"));
756 }
757 if let Some(ref host) = m.host_email {
758 output.push_str(&format!("**Host:** {host}\n"));
759 }
760 if !m.participants.is_empty() {
761 output.push_str(&format!(
762 "**Participants:** {}\n",
763 m.participants.join(", ")
764 ));
765 }
766 if let Some(ref summary) = m.summary {
767 output.push_str(&format!("\n{summary}\n"));
768 }
769 if !m.action_items.is_empty() {
770 output.push_str("\n**Action Items:**\n");
771 for item in &m.action_items {
772 output.push_str(&format!("- {item}\n"));
773 }
774 }
775 if !m.keywords.is_empty() {
776 output.push_str(&format!("**Keywords:** {}\n", m.keywords.join(", ")));
777 }
778 output.push('\n');
779 }
780
781 output
782}
783
784fn format_meeting_transcript(transcript: &devboy_core::MeetingTranscript) -> String {
786 let title = transcript.title.as_deref().unwrap_or("Meeting Transcript");
787 let mut output = format!("# {title}\n\n");
788 output.push_str(&format!(
789 "Showing {} sentences\n\n",
790 transcript.sentences.len()
791 ));
792
793 for s in &transcript.sentences {
794 let fallback = if s.speaker_id.is_empty() {
795 "Unknown speaker".to_string()
796 } else {
797 format!("Speaker {}", s.speaker_id)
798 };
799 let speaker = s.speaker_name.as_deref().unwrap_or(&fallback);
800 let time = format_time(s.start_time);
801 output.push_str(&format!("[{time}] {speaker}: {}\n", s.text));
802 }
803
804 output
805}
806
807fn format_knowledge_base_spaces(spaces: &[devboy_core::KbSpace]) -> String {
808 if spaces.is_empty() {
809 return "No knowledge base spaces found.".to_string();
810 }
811
812 let mut output = format!("# Knowledge Base Spaces ({})\n\n", spaces.len());
813 for space in spaces {
814 output.push_str(&format!("- {} (`{}`)\n", space.name, space.key));
815 if let Some(description) = &space.description {
816 output.push_str(&format!(" {description}\n"));
817 }
818 if let Some(url) = &space.url {
819 output.push_str(&format!(" {url}\n"));
820 }
821 }
822 output
823}
824
825fn format_knowledge_base_pages(pages: &[devboy_core::KbPage]) -> String {
826 if pages.is_empty() {
827 return "No knowledge base pages found.".to_string();
828 }
829
830 let mut output = format!("# Knowledge Base Pages ({})\n\n", pages.len());
831 for page in pages {
832 output.push_str(&format!("- {} (`{}`)\n", page.title, page.id));
833 if let Some(space_key) = &page.space_key {
834 output.push_str(&format!(" space: {space_key}\n"));
835 }
836 if let Some(author) = &page.author {
837 output.push_str(&format!(" author: {author}\n"));
838 }
839 if let Some(last_modified) = &page.last_modified {
840 output.push_str(&format!(" updated: {last_modified}\n"));
841 }
842 if let Some(excerpt) = &page.excerpt {
843 output.push_str(&format!(" excerpt: {excerpt}\n"));
844 }
845 if let Some(url) = &page.url {
846 output.push_str(&format!(" {url}\n"));
847 }
848 }
849 output
850}
851
852fn format_knowledge_base_page_summary(page: &devboy_core::KbPage) -> String {
853 let mut output = format!("# Knowledge Base Page\n\n{} (`{}`)\n", page.title, page.id);
854 if let Some(space_key) = &page.space_key {
855 output.push_str(&format!("space: {space_key}\n"));
856 }
857 if let Some(author) = &page.author {
858 output.push_str(&format!("author: {author}\n"));
859 }
860 if let Some(last_modified) = &page.last_modified {
861 output.push_str(&format!("updated: {last_modified}\n"));
862 }
863 if let Some(url) = &page.url {
864 output.push_str(&format!("url: {url}\n"));
865 }
866 output
867}
868
869fn format_knowledge_base_page(page: &devboy_core::KbPageContent) -> String {
870 let mut output = format!("# {}\n\n", page.page.title);
871 output.push_str(&format!("id: `{}`\n", page.page.id));
872 if let Some(space_key) = &page.page.space_key {
873 output.push_str(&format!("space: `{space_key}`\n"));
874 }
875 output.push_str(&format!("content_type: `{}`\n", page.content_type));
876 if !page.labels.is_empty() {
877 output.push_str(&format!("labels: {}\n", page.labels.join(", ")));
878 }
879 if !page.ancestors.is_empty() {
880 let chain = page
881 .ancestors
882 .iter()
883 .map(|ancestor| ancestor.title.as_str())
884 .collect::<Vec<_>>()
885 .join(" > ");
886 output.push_str(&format!("ancestors: {chain}\n"));
887 }
888 if let Some(url) = &page.page.url {
889 output.push_str(&format!("url: {url}\n"));
890 }
891 output.push('\n');
892 output.push_str(&page.content);
893 output
894}
895
896fn format_time(seconds: f64) -> String {
898 let total_secs = seconds as u64;
899 let hours = total_secs / 3600;
900 let minutes = (total_secs % 3600) / 60;
901 let secs = total_secs % 60;
902 if hours > 0 {
903 format!("{hours:02}:{minutes:02}:{secs:02}")
904 } else {
905 format!("{minutes:02}:{secs:02}")
906 }
907}
908
909fn format_pipeline(info: &devboy_core::PipelineInfo) -> String {
911 let status_icon = match info.status {
912 devboy_core::PipelineStatus::Success => "✅",
913 devboy_core::PipelineStatus::Failed => "❌",
914 devboy_core::PipelineStatus::Running => "🔄",
915 devboy_core::PipelineStatus::Pending => "⏳",
916 devboy_core::PipelineStatus::Canceled => "🚫",
917 _ => "❓",
918 };
919
920 let mut output = format!(
921 "# Pipeline {}\n\n{} **Status:** {} | **Ref:** `{}` | **SHA:** `{}`",
922 info.id,
923 status_icon,
924 info.status.as_str(),
925 info.reference,
926 &info.sha[..info
927 .sha
928 .char_indices()
929 .nth(7)
930 .map(|(i, _)| i)
931 .unwrap_or(info.sha.len())]
932 );
933
934 if let Some(url) = &info.url {
935 output.push_str(&format!("\n🔗 {url}"));
936 }
937
938 if let Some(duration) = info.duration {
939 output.push_str(&format!("\n⏱️ Duration: {}s", duration));
940 }
941
942 let s = &info.summary;
944 output.push_str(&format!(
945 "\n\n**Summary:** {} total | ✅ {} | ❌ {} | 🔄 {} | ⏳ {} | 🚫 {} | ⏭️ {}",
946 s.total, s.success, s.failed, s.running, s.pending, s.canceled, s.skipped
947 ));
948
949 for stage in &info.stages {
951 output.push_str(&format!("\n\n## {}\n", stage.name));
952 for job in &stage.jobs {
953 let job_icon = match job.status {
954 devboy_core::PipelineStatus::Success => "✅",
955 devboy_core::PipelineStatus::Failed => "❌",
956 devboy_core::PipelineStatus::Running => "🔄",
957 devboy_core::PipelineStatus::Pending => "⏳",
958 _ => "❓",
959 };
960 let dur = job.duration.map(|d| format!(" ({d}s)")).unwrap_or_default();
961 output.push_str(&format!("\n{} **{}**{}", job_icon, job.name, dur));
962 if let Some(url) = &job.url {
963 output.push_str(&format!(" — [logs]({url})"));
964 }
965 }
966 }
967
968 if !info.failed_jobs.is_empty() {
970 output.push_str("\n\n## Failed Jobs\n");
971 for fj in &info.failed_jobs {
972 output.push_str(&format!("\n### ❌ {} (job {})\n", fj.name, fj.id));
973 if let Some(snippet) = &fj.error_snippet {
974 output.push_str(&format!("\n```\n{snippet}\n```\n"));
975 }
976 }
977 }
978
979 output
980}
981
982fn format_job_log(log: &devboy_core::JobLogOutput) -> String {
984 let mut output = format!("# Job Log ({})\n\n", log.job_id);
985 output.push_str(&format!("**Mode:** {}", log.mode));
986 if let Some(total) = log.total_lines {
987 output.push_str(&format!(" | **Total lines:** {total}"));
988 }
989 output.push_str(&format!("\n\n```\n{}\n```", log.content));
990 output
991}
992
993pub async fn execute_and_format(
997 executor: &crate::executor::Executor,
998 tool: &str,
999 args: serde_json::Value,
1000 ctx: &crate::context::AdditionalContext,
1001 pipeline_config: Option<PipelineConfig>,
1002) -> Result<FormatResult> {
1003 let format = args
1005 .get("format")
1006 .and_then(|v| v.as_str())
1007 .map(String::from);
1008
1009 let budget = args
1010 .get("budget")
1011 .and_then(|v| v.as_u64())
1012 .map(|b| b as usize);
1013
1014 let pipeline_config = if let Some(b) = budget {
1016 let mut config = pipeline_config.unwrap_or_default();
1017 config.max_chars = (b as f64 * 3.5).floor() as usize;
1019 Some(config)
1020 } else {
1021 pipeline_config
1022 };
1023
1024 let output = executor.execute(tool, args, ctx).await?;
1025 format_output(output, format.as_deref(), Some(tool), pipeline_config)
1026}
1027
1028#[cfg(test)]
1029mod tests {
1030 use super::*;
1031 use devboy_core::Issue;
1032
1033 fn sample_issue() -> Issue {
1034 Issue {
1035 key: "gh#1".into(),
1036 title: "Test Issue".into(),
1037 description: Some("Test description".into()),
1038 state: "open".into(),
1039 source: "github".into(),
1040 priority: None,
1041 labels: vec!["bug".into()],
1042 author: None,
1043 assignees: vec![],
1044 url: Some("https://github.com/test/repo/issues/1".into()),
1045 created_at: Some("2024-01-01T00:00:00Z".into()),
1046 updated_at: Some("2024-01-02T00:00:00Z".into()),
1047 attachments_count: None,
1048 parent: None,
1049 subtasks: vec![],
1050 }
1051 }
1052
1053 #[test]
1054 fn test_format_issues_toon() {
1055 let output = ToolOutput::Issues(vec![sample_issue()], None);
1056 let result = format_output(output, Some("toon"), None, None)
1057 .unwrap()
1058 .content;
1059 assert!(result.contains("gh#1"));
1060 assert!(result.contains("Test Issue"));
1061 }
1062
1063 #[test]
1064 fn test_format_metadata_toon_compression() {
1065 let output = ToolOutput::Issues(vec![sample_issue()], None);
1066 let result = format_output(output, Some("toon"), None, None).unwrap();
1067
1068 assert!(result.metadata.raw_chars > 0, "raw_chars should be > 0");
1069 assert!(
1070 result.metadata.output_chars > 0,
1071 "output_chars should be > 0"
1072 );
1073 assert!(result.metadata.estimated_tokens > 0, "tokens should be > 0");
1074 assert_eq!(result.metadata.format, "toon");
1075 assert!(!result.metadata.truncated);
1076 assert!(
1078 result.metadata.compression_ratio < 2.0,
1079 "compression_ratio should be reasonable, got {}",
1080 result.metadata.compression_ratio
1081 );
1082 }
1083
1084 #[test]
1085 fn test_format_metadata_text_passthrough() {
1086 let output = ToolOutput::Text("plain text".into());
1087 let result = format_output(output, None, None, None).unwrap();
1088
1089 assert_eq!(result.metadata.raw_chars, 10);
1090 assert_eq!(result.metadata.output_chars, 10);
1091 assert_eq!(result.metadata.compression_ratio, 1.0);
1092 assert_eq!(result.metadata.format, "text");
1093 assert!(!result.metadata.truncated);
1094 }
1095
1096 #[test]
1097 fn test_format_metadata_savings_split() {
1098 let issues: Vec<_> = (0..20).map(|_| sample_issue()).collect();
1101 let output = ToolOutput::Issues(issues, None);
1102 let result = format_output(output, Some("toon"), None, None).unwrap();
1103
1104 assert_eq!(result.metadata.dedup_savings_pct, 0.0);
1106 assert!(
1108 (0.0..1.0).contains(&result.metadata.encoder_savings_pct),
1109 "encoder savings out of range: {}",
1110 result.metadata.encoder_savings_pct
1111 );
1112 assert_eq!(
1114 result.metadata.combined_savings_pct,
1115 result.metadata.encoder_savings_pct
1116 );
1117 assert_eq!(result.metadata.baseline, "json_pretty");
1119 assert!(
1120 !result.metadata.tokenizer.is_empty(),
1121 "tokenizer must be set"
1122 );
1123 }
1124
1125 #[test]
1126 fn test_format_metadata_passthrough_savings_zero() {
1127 let output = ToolOutput::Text("nothing to compress".into());
1130 let result = format_output(output, None, None, None).unwrap();
1131 assert_eq!(result.metadata.dedup_savings_pct, 0.0);
1132 assert_eq!(result.metadata.encoder_savings_pct, 0.0);
1133 assert_eq!(result.metadata.combined_savings_pct, 0.0);
1134 assert_eq!(result.metadata.baseline, "json_pretty");
1135 assert!(!result.metadata.tokenizer.is_empty());
1136 }
1137
1138 #[test]
1139 fn test_format_metadata_truncated() {
1140 let output = ToolOutput::Issues(vec![sample_issue()], None);
1141 let config = PipelineConfig {
1142 max_chars: 50, ..PipelineConfig::default()
1144 };
1145 let result = format_output(output, Some("toon"), None, Some(config)).unwrap();
1146
1147 assert!(result.metadata.truncated);
1148 assert!(
1150 result.metadata.output_chars < result.metadata.raw_chars,
1151 "truncated output ({}) should be smaller than raw ({})",
1152 result.metadata.output_chars,
1153 result.metadata.raw_chars
1154 );
1155 }
1156
1157 #[test]
1158 fn test_format_issues_json() {
1159 let output = ToolOutput::Issues(vec![sample_issue()], None);
1160 let result = format_output(output, Some("json"), None, None)
1161 .unwrap()
1162 .content;
1163 assert!(result.contains("gh#1"));
1164 }
1165
1166 #[test]
1167 fn test_format_issues_toon_explicit() {
1168 let output = ToolOutput::Issues(vec![sample_issue()], None);
1169 let result = format_output(output, Some("toon"), None, None)
1170 .unwrap()
1171 .content;
1172 assert!(result.contains("gh#1"));
1173 }
1174
1175 #[test]
1176 fn test_format_text_passthrough() {
1177 let output = ToolOutput::Text("Comment created".into());
1178 let result = format_output(output, None, None, None).unwrap().content;
1179 assert_eq!(result, "Comment created");
1180 }
1181
1182 #[test]
1183 fn test_format_default_is_toon() {
1184 let output = ToolOutput::Issues(vec![sample_issue()], None);
1185 let result = format_output(output, None, None, None).unwrap().content;
1186 assert!(result.contains("gh#1"));
1187 }
1188
1189 #[test]
1190 fn test_format_single_issue() {
1191 let output = ToolOutput::SingleIssue(Box::new(sample_issue()));
1192 let result = format_output(output, Some("toon"), None, None)
1193 .unwrap()
1194 .content;
1195 assert!(result.contains("gh#1"));
1196 }
1197
1198 fn sample_mr() -> devboy_core::MergeRequest {
1199 devboy_core::MergeRequest {
1200 key: "pr#1".into(),
1201 title: "Test PR".into(),
1202 description: None,
1203 state: "open".into(),
1204 source: "github".into(),
1205 source_branch: "feature".into(),
1206 target_branch: "main".into(),
1207 author: None,
1208 assignees: vec![],
1209 reviewers: vec![],
1210 labels: vec![],
1211 draft: false,
1212 url: None,
1213 created_at: None,
1214 updated_at: None,
1215 }
1216 }
1217
1218 #[test]
1219 fn test_format_merge_requests() {
1220 let output = ToolOutput::MergeRequests(vec![sample_mr()], None);
1221 let result = format_output(output, Some("toon"), None, None)
1222 .unwrap()
1223 .content;
1224 assert!(result.contains("pr#1"));
1225 }
1226
1227 #[test]
1228 fn test_format_single_merge_request() {
1229 let output = ToolOutput::SingleMergeRequest(Box::new(sample_mr()));
1230 let result = format_output(output, Some("toon"), None, None)
1231 .unwrap()
1232 .content;
1233 assert!(result.contains("pr#1"));
1234 }
1235
1236 #[test]
1237 fn test_format_discussions() {
1238 let output = ToolOutput::Discussions(
1239 vec![devboy_core::Discussion {
1240 id: "d1".into(),
1241 resolved: false,
1242 resolved_by: None,
1243 comments: vec![devboy_core::Comment {
1244 id: "c1".into(),
1245 body: "Review comment".into(),
1246 author: None,
1247 created_at: None,
1248 updated_at: None,
1249 position: None,
1250 }],
1251 position: None,
1252 }],
1253 None,
1254 );
1255 let result = format_output(output, Some("toon"), None, None)
1256 .unwrap()
1257 .content;
1258 assert!(result.contains("Review comment"));
1259 }
1260
1261 #[test]
1262 fn test_format_diffs() {
1263 let output = ToolOutput::Diffs(
1264 vec![devboy_core::FileDiff {
1265 file_path: "src/main.rs".into(),
1266 old_path: None,
1267 new_file: false,
1268 deleted_file: false,
1269 renamed_file: false,
1270 diff: "+added line".into(),
1271 additions: Some(1),
1272 deletions: Some(0),
1273 }],
1274 None,
1275 );
1276 let result = format_output(output, Some("toon"), None, None)
1277 .unwrap()
1278 .content;
1279 assert!(result.contains("src/main.rs"));
1280 }
1281
1282 #[test]
1283 fn test_format_comments() {
1284 let output = ToolOutput::Comments(
1285 vec![devboy_core::Comment {
1286 id: "c1".into(),
1287 body: "A comment body".into(),
1288 author: None,
1289 created_at: None,
1290 updated_at: None,
1291 position: None,
1292 }],
1293 None,
1294 );
1295 let result = format_output(output, Some("json"), None, None)
1296 .unwrap()
1297 .content;
1298 assert!(result.contains("A comment body"));
1299 }
1300
1301 #[test]
1302 fn test_format_with_custom_pipeline_config() {
1303 let output = ToolOutput::Issues(vec![sample_issue()], None);
1304 let config = PipelineConfig {
1305 max_chars: 500,
1306 ..PipelineConfig::default()
1307 };
1308 let result = format_output(output, Some("toon"), None, Some(config))
1309 .unwrap()
1310 .content;
1311 assert!(result.contains("gh#1"));
1312 }
1313
1314 #[test]
1315 fn test_format_pipeline() {
1316 let output = ToolOutput::Pipeline(Box::new(devboy_core::PipelineInfo {
1317 id: "100".into(),
1318 status: devboy_core::PipelineStatus::Failed,
1319 reference: "main".into(),
1320 sha: "abc123def".into(),
1321 url: Some("https://example.com/pipeline/100".into()),
1322 duration: Some(120),
1323 coverage: Some(85.5),
1324 summary: devboy_core::PipelineSummary {
1325 total: 3,
1326 success: 2,
1327 failed: 1,
1328 ..Default::default()
1329 },
1330 stages: vec![devboy_core::PipelineStage {
1331 name: "build".into(),
1332 jobs: vec![devboy_core::PipelineJob {
1333 id: "1".into(),
1334 name: "compile".into(),
1335 status: devboy_core::PipelineStatus::Success,
1336 url: None,
1337 duration: Some(30),
1338 }],
1339 }],
1340 failed_jobs: vec![devboy_core::FailedJob {
1341 id: "2".into(),
1342 name: "test".into(),
1343 url: None,
1344 error_snippet: Some("error: test failed".into()),
1345 }],
1346 }));
1347 let result = format_output(output, None, None, None).unwrap().content;
1348 assert!(result.contains("Pipeline 100"));
1349 assert!(result.contains("failed"));
1350 assert!(result.contains("main"));
1351 assert!(result.contains("120s"));
1352 assert!(result.contains("compile"));
1353 assert!(result.contains("error: test failed"));
1354 }
1355
1356 #[test]
1357 fn test_format_job_log() {
1358 let output = ToolOutput::JobLog(Box::new(devboy_core::JobLogOutput {
1359 job_id: "202".into(),
1360 job_name: Some("test".into()),
1361 content: "error: assertion failed\nat src/test.rs:42".into(),
1362 mode: "smart".into(),
1363 total_lines: Some(100),
1364 }));
1365 let result = format_output(output, None, None, None).unwrap().content;
1366 assert!(result.contains("Job Log"));
1367 assert!(result.contains("202"));
1368 assert!(result.contains("smart"));
1369 assert!(result.contains("assertion failed"));
1370 }
1371
1372 #[test]
1375 fn test_format_pipeline_success_status() {
1376 let output = ToolOutput::Pipeline(Box::new(devboy_core::PipelineInfo {
1377 id: "200".into(),
1378 status: devboy_core::PipelineStatus::Success,
1379 reference: "develop".into(),
1380 sha: "deadbeefcafe".into(),
1381 url: None,
1382 duration: None,
1383 coverage: None,
1384 summary: devboy_core::PipelineSummary {
1385 total: 5,
1386 success: 5,
1387 ..Default::default()
1388 },
1389 stages: vec![],
1390 failed_jobs: vec![],
1391 }));
1392 let result = format_output(output, None, None, None).unwrap().content;
1393 assert!(result.contains("Pipeline 200"));
1394 assert!(result.contains("success"));
1395 assert!(result.contains("develop"));
1396 assert!(result.contains("deadbee")); }
1398
1399 #[test]
1400 fn test_format_pipeline_running_status() {
1401 let output = ToolOutput::Pipeline(Box::new(devboy_core::PipelineInfo {
1402 id: "301".into(),
1403 status: devboy_core::PipelineStatus::Running,
1404 reference: "feature".into(),
1405 sha: "1234567890abcdef".into(),
1406 url: Some("https://ci.example.com/301".into()),
1407 duration: Some(60),
1408 coverage: None,
1409 summary: devboy_core::PipelineSummary {
1410 total: 3,
1411 running: 1,
1412 success: 1,
1413 pending: 1,
1414 ..Default::default()
1415 },
1416 stages: vec![],
1417 failed_jobs: vec![],
1418 }));
1419 let result = format_output(output, None, None, None).unwrap().content;
1420 assert!(result.contains("running"));
1421 assert!(result.contains("https://ci.example.com/301"));
1422 assert!(result.contains("60s"));
1423 }
1424
1425 #[test]
1426 fn test_format_pipeline_pending_status() {
1427 let output = ToolOutput::Pipeline(Box::new(devboy_core::PipelineInfo {
1428 id: "302".into(),
1429 status: devboy_core::PipelineStatus::Pending,
1430 reference: "main".into(),
1431 sha: "aabbccdd".into(),
1432 url: None,
1433 duration: None,
1434 coverage: None,
1435 summary: Default::default(),
1436 stages: vec![],
1437 failed_jobs: vec![],
1438 }));
1439 let result = format_output(output, None, None, None).unwrap().content;
1440 assert!(result.contains("pending"));
1441 }
1442
1443 #[test]
1444 fn test_format_pipeline_canceled_status() {
1445 let output = ToolOutput::Pipeline(Box::new(devboy_core::PipelineInfo {
1446 id: "303".into(),
1447 status: devboy_core::PipelineStatus::Canceled,
1448 reference: "main".into(),
1449 sha: "1122334455".into(),
1450 url: None,
1451 duration: None,
1452 coverage: None,
1453 summary: Default::default(),
1454 stages: vec![],
1455 failed_jobs: vec![],
1456 }));
1457 let result = format_output(output, None, None, None).unwrap().content;
1458 assert!(result.contains("canceled"));
1459 }
1460
1461 #[test]
1462 fn test_format_pipeline_with_job_url() {
1463 let output = ToolOutput::Pipeline(Box::new(devboy_core::PipelineInfo {
1464 id: "400".into(),
1465 status: devboy_core::PipelineStatus::Failed,
1466 reference: "main".into(),
1467 sha: "abcdef1234567".into(),
1468 url: None,
1469 duration: None,
1470 coverage: None,
1471 summary: Default::default(),
1472 stages: vec![devboy_core::PipelineStage {
1473 name: "test".into(),
1474 jobs: vec![devboy_core::PipelineJob {
1475 id: "j1".into(),
1476 name: "unit-test".into(),
1477 status: devboy_core::PipelineStatus::Failed,
1478 url: Some("https://ci.example.com/jobs/j1".into()),
1479 duration: None,
1480 }],
1481 }],
1482 failed_jobs: vec![],
1483 }));
1484 let result = format_output(output, None, None, None).unwrap().content;
1485 assert!(result.contains("[logs](https://ci.example.com/jobs/j1)"));
1486 }
1487
1488 #[test]
1489 fn test_format_pipeline_failed_job_without_snippet() {
1490 let output = ToolOutput::Pipeline(Box::new(devboy_core::PipelineInfo {
1491 id: "401".into(),
1492 status: devboy_core::PipelineStatus::Failed,
1493 reference: "main".into(),
1494 sha: "abcdef1234567".into(),
1495 url: None,
1496 duration: None,
1497 coverage: None,
1498 summary: Default::default(),
1499 stages: vec![],
1500 failed_jobs: vec![devboy_core::FailedJob {
1501 id: "fj1".into(),
1502 name: "lint".into(),
1503 url: None,
1504 error_snippet: None,
1505 }],
1506 }));
1507 let result = format_output(output, None, None, None).unwrap().content;
1508 assert!(result.contains("lint"));
1509 assert!(result.contains("fj1"));
1510 assert!(!result.contains("```")); }
1512
1513 #[test]
1516 fn test_format_statuses() {
1517 let output = ToolOutput::Statuses(
1518 vec![
1519 devboy_core::IssueStatus {
1520 id: "1".into(),
1521 name: "To Do".into(),
1522 category: "todo".into(),
1523 color: Some("#blue".into()),
1524 order: Some(0),
1525 },
1526 devboy_core::IssueStatus {
1527 id: "2".into(),
1528 name: "In Progress".into(),
1529 category: "in_progress".into(),
1530 color: None,
1531 order: None,
1532 },
1533 ],
1534 None,
1535 );
1536 let result = format_output(output, None, None, None).unwrap().content;
1537 assert!(result.contains("Available Statuses"));
1538 assert!(result.contains("To Do"));
1539 assert!(result.contains("In Progress"));
1540 assert!(result.contains("#blue"));
1541 assert!(result.contains("todo"));
1542 assert!(result.contains("| - |")); }
1544
1545 #[test]
1546 fn test_format_statuses_empty() {
1547 let output = ToolOutput::Statuses(vec![], None);
1548 let result = format_output(output, None, None, None).unwrap().content;
1549 assert_eq!(result, "No statuses found.");
1550 }
1551
1552 #[test]
1555 fn test_format_users() {
1556 let output = ToolOutput::Users(
1557 vec![
1558 devboy_core::User {
1559 id: "u1".into(),
1560 username: "johndoe".into(),
1561 name: Some("John Doe".into()),
1562 email: Some("john@example.com".into()),
1563 avatar_url: None,
1564 },
1565 devboy_core::User {
1566 id: "u2".into(),
1567 username: "janesmith".into(),
1568 name: None,
1569 email: None,
1570 avatar_url: None,
1571 },
1572 ],
1573 None,
1574 );
1575 let result = format_output(output, None, None, None).unwrap().content;
1576 assert!(result.contains("# Users"));
1577 assert!(result.contains("johndoe"));
1578 assert!(result.contains("John Doe"));
1579 assert!(result.contains("john@example.com"));
1580 assert!(result.contains("janesmith"));
1581 assert!(result.contains("| - |")); }
1583
1584 #[test]
1585 fn test_format_users_empty() {
1586 let output = ToolOutput::Users(vec![], None);
1587 let result = format_output(output, None, None, None).unwrap().content;
1588 assert_eq!(result, "No users found.");
1589 }
1590
1591 fn sample_project_version(name: &str) -> devboy_core::ProjectVersion {
1594 devboy_core::ProjectVersion {
1595 id: "1".into(),
1596 project: "PROJ".into(),
1597 name: name.into(),
1598 description: Some("Initial release".into()),
1599 start_date: Some("2025-01-01".into()),
1600 release_date: Some("2025-02-01".into()),
1601 released: true,
1602 archived: false,
1603 overdue: Some(false),
1604 issue_count: Some(7),
1605 unresolved_issue_count: None,
1606 source: "jira".into(),
1607 }
1608 }
1609
1610 #[test]
1611 fn format_project_versions_empty_returns_canonical_message() {
1612 let output = ToolOutput::ProjectVersions(vec![], None);
1613 let result = format_output(output, None, None, None).unwrap().content;
1614 assert_eq!(result, "No project versions found.");
1615 }
1616
1617 #[test]
1618 fn format_project_versions_renders_table_with_counts_and_dates() {
1619 let output = ToolOutput::ProjectVersions(vec![sample_project_version("3.18.0")], None);
1620 let result = format_output(output, None, None, None).unwrap().content;
1621 assert!(result.contains("# Project Versions (1)"), "{result}");
1622 assert!(result.contains("| Name |"), "{result}");
1623 assert!(result.contains("| 3.18.0 |"), "{result}");
1624 assert!(result.contains("| yes |"), "{result}");
1625 assert!(result.contains("2025-02-01"), "{result}");
1626 assert!(result.contains("Initial release"), "{result}");
1627 }
1628
1629 #[test]
1630 fn format_project_versions_marks_archived_inline() {
1631 let mut v = sample_project_version("0.9.0");
1632 v.archived = true;
1633 let output = ToolOutput::ProjectVersions(vec![v], None);
1634 let result = format_output(output, None, None, None).unwrap().content;
1635 assert!(
1636 result.contains("0.9.0 (archived)"),
1637 "expected archived marker, got {result}"
1638 );
1639 }
1640
1641 #[test]
1642 fn format_project_versions_truncates_long_descriptions() {
1643 let mut v = sample_project_version("1.0.0");
1644 v.description = Some("x".repeat(200));
1645 let output = ToolOutput::ProjectVersions(vec![v], None);
1646 let result = format_output(output, None, None, None).unwrap().content;
1647 assert!(result.contains('…'), "expected ellipsis, got {result}");
1648 }
1649
1650 #[test]
1651 fn format_single_project_version_renders_detail_block() {
1652 let v = sample_project_version("3.18.0");
1653 let output = ToolOutput::SingleProjectVersion(Box::new(v));
1654 let result = format_output(output, None, None, None).unwrap().content;
1655 assert!(result.contains("# 3.18.0 (project PROJ)"), "{result}");
1656 assert!(result.contains("- **id:** 1"), "{result}");
1657 assert!(result.contains("- **released:** yes"), "{result}");
1658 assert!(result.contains("## Description"), "{result}");
1659 assert!(result.contains("Initial release"), "{result}");
1660 }
1661
1662 #[test]
1663 fn format_project_versions_escapes_pipes_in_name_and_description() {
1664 let mut v = sample_project_version("v|1.0");
1667 v.description = Some("Highlights | breaking changes".into());
1668 let output = ToolOutput::ProjectVersions(vec![v], None);
1669 let result = format_output(output, None, None, None).unwrap().content;
1670 assert!(
1671 result.contains("v\\|1.0"),
1672 "name pipe not escaped: {result}"
1673 );
1674 assert!(
1675 result.contains("Highlights \\| breaking changes"),
1676 "description pipe not escaped: {result}"
1677 );
1678 let line = result
1681 .lines()
1682 .find(|l| l.starts_with("| v\\|1.0"))
1683 .expect("expected table row, got: {result}");
1684 let cells = line.split(" | ").count();
1685 assert!(cells <= 6, "row split into too many cells: {line:?}");
1686 }
1687
1688 #[test]
1689 fn format_project_versions_emits_more_hint_when_truncated() {
1690 let pagination = devboy_core::Pagination {
1694 offset: 0,
1695 limit: 1,
1696 total: Some(35),
1697 has_more: true,
1698 next_cursor: None,
1699 };
1700 let v = sample_project_version("3.18.0");
1701 let output = ToolOutput::ProjectVersions(
1702 vec![v],
1703 Some(crate::output::ResultMeta {
1704 pagination: Some(pagination),
1705 sort_info: None,
1706 }),
1707 );
1708 let result = format_output(output, None, None, None).unwrap().content;
1709 assert!(
1710 result.contains("Project Versions (1 of 35)"),
1711 "expected 'X of Y' header: {result}"
1712 );
1713 assert!(
1714 result.contains("[+34 more"),
1715 "expected +N more hint: {result}"
1716 );
1717 assert!(
1718 result.contains("`limit: 35`"),
1719 "expected limit suggestion: {result}"
1720 );
1721 }
1722
1723 #[test]
1724 fn format_project_versions_hint_caps_limit_at_max_and_uses_archived_all() {
1725 let pagination = devboy_core::Pagination {
1729 offset: 0,
1730 limit: 1,
1731 total: Some(5_000),
1732 has_more: true,
1733 next_cursor: None,
1734 };
1735 let v = sample_project_version("3.18.0");
1736 let output = ToolOutput::ProjectVersions(
1737 vec![v],
1738 Some(crate::output::ResultMeta {
1739 pagination: Some(pagination),
1740 sort_info: None,
1741 }),
1742 );
1743 let result = format_output(output, None, None, None).unwrap().content;
1744 assert!(
1745 result.contains("`limit: 200`"),
1746 "limit suggestion should clamp at 200, got: {result}"
1747 );
1748 assert!(
1749 result.contains("`archived: \"all\"`"),
1750 "expected archived hint to suggest 'all', got: {result}"
1751 );
1752 assert!(
1753 !result.contains("`archived: true`"),
1754 "must not suggest archived: true (means 'archived only'), got: {result}"
1755 );
1756 }
1757
1758 #[test]
1759 fn format_project_versions_renders_unresolved_only_cell() {
1760 let mut v = sample_project_version("3.18.0");
1764 v.issue_count = None;
1765 v.unresolved_issue_count = Some(4);
1766 let output = ToolOutput::ProjectVersions(vec![v], None);
1767 let result = format_output(output, None, None, None).unwrap().content;
1768 assert!(
1769 result.contains("4 open"),
1770 "expected '4 open' marker, got: {result}"
1771 );
1772 }
1773
1774 #[test]
1775 fn format_single_project_version_renders_unresolved_count() {
1776 let mut v = sample_project_version("3.18.0");
1777 v.issue_count = Some(20);
1778 v.unresolved_issue_count = Some(7);
1779 let output = ToolOutput::SingleProjectVersion(Box::new(v));
1780 let result = format_output(output, None, None, None).unwrap().content;
1781 assert!(result.contains("- **issue_count:** 20"), "{result}");
1782 assert!(
1783 result.contains("- **unresolved_issue_count:** 7"),
1784 "{result}"
1785 );
1786 }
1787
1788 #[test]
1789 fn format_project_versions_no_hint_when_not_truncated() {
1790 let pagination = devboy_core::Pagination {
1791 offset: 0,
1792 limit: 5,
1793 total: Some(1),
1794 has_more: false,
1795 next_cursor: None,
1796 };
1797 let v = sample_project_version("3.18.0");
1798 let output = ToolOutput::ProjectVersions(
1799 vec![v],
1800 Some(crate::output::ResultMeta {
1801 pagination: Some(pagination),
1802 sort_info: None,
1803 }),
1804 );
1805 let result = format_output(output, None, None, None).unwrap().content;
1806 assert!(
1807 !result.contains("more"),
1808 "shouldn't suggest more results: {result}"
1809 );
1810 }
1811
1812 #[test]
1813 fn escape_table_cell_handles_backslash_and_pipe() {
1814 assert_eq!(escape_table_cell("a|b"), "a\\|b");
1815 assert_eq!(escape_table_cell("a\\b"), "a\\\\b");
1816 assert_eq!(escape_table_cell("a\\|b"), "a\\\\\\|b");
1819 assert_eq!(escape_table_cell("plain"), "plain");
1820 }
1821
1822 #[test]
1825 fn test_format_job_log_no_total_lines() {
1826 let output = ToolOutput::JobLog(Box::new(devboy_core::JobLogOutput {
1827 job_id: "999".into(),
1828 job_name: Some("build".into()),
1829 content: "Building...".into(),
1830 mode: "full".into(),
1831 total_lines: None,
1832 }));
1833 let result = format_output(output, None, None, None).unwrap().content;
1834 assert!(result.contains("Job Log (999)"));
1835 assert!(result.contains("**Mode:** full"));
1836 assert!(!result.contains("Total lines"));
1837 assert!(result.contains("Building..."));
1838 }
1839
1840 #[test]
1843 fn test_format_text_empty_string() {
1844 let output = ToolOutput::Text("".into());
1845 let result = format_output(output, None, None, None).unwrap().content;
1846 assert_eq!(result, "");
1847 }
1848
1849 #[test]
1850 fn test_format_text_with_json_format_param() {
1851 let output = ToolOutput::Text("raw text".into());
1853 let result = format_output(output, Some("json"), None, None)
1854 .unwrap()
1855 .content;
1856 assert_eq!(result, "raw text");
1857 }
1858
1859 #[test]
1862 fn test_format_meeting_notes() {
1863 let meetings = vec![devboy_core::MeetingNote {
1864 id: "m1".into(),
1865 title: "Sprint Planning".into(),
1866 meeting_date: Some("2025-01-15T10:00:00Z".into()),
1867 duration_seconds: Some(2700), host_email: Some("host@example.com".into()),
1869 participants: vec!["alice@example.com".into(), "bob@example.com".into()],
1870 action_items: vec!["Review PR #42".into(), "Update docs".into()],
1871 keywords: vec!["sprint".into(), "planning".into()],
1872 summary: Some("Discussed sprint goals.".into()),
1873 ..Default::default()
1874 }];
1875 let output = ToolOutput::MeetingNotes(meetings, None);
1876 let result = format_output(output, None, None, None).unwrap().content;
1877 assert!(result.contains("Sprint Planning"));
1878 assert!(result.contains("2025-01-15T10:00:00Z"));
1879 assert!(result.contains("45 min"));
1880 assert!(result.contains("host@example.com"));
1881 assert!(result.contains("alice@example.com"));
1882 assert!(result.contains("Review PR #42"));
1883 assert!(result.contains("Update docs"));
1884 assert!(result.contains("sprint"));
1885 assert!(result.contains("Discussed sprint goals."));
1886 }
1887
1888 #[test]
1889 fn test_format_meeting_notes_empty() {
1890 let output = ToolOutput::MeetingNotes(vec![], None);
1891 let result = format_output(output, None, None, None).unwrap().content;
1892 assert_eq!(result, "No meeting notes found.");
1893 }
1894
1895 #[test]
1896 fn test_format_meeting_transcript() {
1897 let transcript = devboy_core::MeetingTranscript {
1898 meeting_id: "m1".into(),
1899 title: Some("Sprint Planning".into()),
1900 sentences: vec![
1901 devboy_core::TranscriptSentence {
1902 speaker_id: "s1".into(),
1903 speaker_name: Some("Alice".into()),
1904 text: "Let's start the meeting.".into(),
1905 start_time: 0.0,
1906 end_time: 3.0,
1907 },
1908 devboy_core::TranscriptSentence {
1909 speaker_id: "s2".into(),
1910 speaker_name: Some("Bob".into()),
1911 text: "Sounds good.".into(),
1912 start_time: 5.0,
1913 end_time: 7.0,
1914 },
1915 ],
1916 };
1917 let output = ToolOutput::MeetingTranscript(Box::new(transcript));
1918 let result = format_output(output, None, None, None).unwrap().content;
1919 assert!(result.contains("Sprint Planning"));
1920 assert!(result.contains("2 sentences"));
1921 assert!(result.contains("[00:00] Alice: Let's start the meeting."));
1922 assert!(result.contains("[00:05] Bob: Sounds good."));
1923 }
1924
1925 #[test]
1926 fn test_format_meeting_transcript_unknown_speaker() {
1927 let transcript = devboy_core::MeetingTranscript {
1928 meeting_id: "m1".into(),
1929 title: None,
1930 sentences: vec![devboy_core::TranscriptSentence {
1931 speaker_id: "".into(),
1932 speaker_name: None,
1933 text: "Hello".into(),
1934 start_time: 0.0,
1935 end_time: 1.0,
1936 }],
1937 };
1938 let output = ToolOutput::MeetingTranscript(Box::new(transcript));
1939 let result = format_output(output, None, None, None).unwrap().content;
1940 assert!(result.contains("Meeting Transcript"));
1941 assert!(result.contains("Unknown speaker"));
1942 }
1943
1944 #[test]
1947 fn test_format_relations() {
1948 let relations = devboy_core::IssueRelations {
1949 parent: Some(sample_issue()),
1950 subtasks: vec![sample_issue()],
1951 blocks: vec![devboy_core::IssueLink {
1952 issue: sample_issue(),
1953 link_type: "Blocks".into(),
1954 }],
1955 blocked_by: vec![],
1956 related_to: vec![],
1957 duplicates: vec![],
1958 };
1959 let output = ToolOutput::Relations(Box::new(relations));
1960 let result = format_output(output, None, None, None).unwrap().content;
1961 assert!(result.contains("gh#1"));
1963 assert!(result.contains("Blocks"));
1964 assert!(result.contains("Test Issue"));
1965 }
1966
1967 #[test]
1968 fn test_format_relations_empty() {
1969 let relations = devboy_core::IssueRelations::default();
1970 let output = ToolOutput::Relations(Box::new(relations));
1971 let result = format_output(output, None, None, None).unwrap().content;
1972 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1974 assert!(parsed.is_object());
1975 }
1976
1977 #[test]
1980 fn test_format_time_zero() {
1981 assert_eq!(format_time(0.0), "00:00");
1982 }
1983
1984 #[test]
1985 fn test_format_time_seconds_only() {
1986 assert_eq!(format_time(45.0), "00:45");
1987 }
1988
1989 #[test]
1990 fn test_format_time_minutes_and_seconds() {
1991 assert_eq!(format_time(125.0), "02:05");
1992 }
1993
1994 #[test]
1995 fn test_format_time_hours() {
1996 assert_eq!(format_time(3661.0), "01:01:01");
1997 }
1998
1999 #[test]
2000 fn test_format_time_fractional_seconds() {
2001 assert_eq!(format_time(59.9), "00:59");
2003 }
2004
2005 fn sample_kb_space() -> devboy_core::KbSpace {
2010 devboy_core::KbSpace {
2011 id: "100".into(),
2012 key: "ENG".into(),
2013 name: "Engineering".into(),
2014 description: Some("Team docs".into()),
2015 url: Some("https://wiki.example.com/spaces/ENG".into()),
2016 ..Default::default()
2017 }
2018 }
2019
2020 fn sample_kb_page() -> devboy_core::KbPage {
2021 devboy_core::KbPage {
2022 id: "12345".into(),
2023 title: "Architecture".into(),
2024 space_key: Some("ENG".into()),
2025 url: Some("https://wiki.example.com/pages/12345".into()),
2026 author: Some("alice".into()),
2027 last_modified: Some("2026-04-01T10:00:00Z".into()),
2028 excerpt: Some("Top-level architecture overview".into()),
2029 ..Default::default()
2030 }
2031 }
2032
2033 #[test]
2034 fn format_kb_spaces_empty_returns_canonical_message() {
2035 assert_eq!(
2036 format_knowledge_base_spaces(&[]),
2037 "No knowledge base spaces found."
2038 );
2039 }
2040
2041 #[test]
2042 fn format_kb_spaces_includes_count_name_key_description_url() {
2043 let out = format_knowledge_base_spaces(&[sample_kb_space()]);
2044 assert!(out.contains("# Knowledge Base Spaces (1)"));
2045 assert!(out.contains("Engineering"));
2046 assert!(out.contains("`ENG`"));
2047 assert!(out.contains("Team docs"));
2048 assert!(out.contains("https://wiki.example.com/spaces/ENG"));
2049 }
2050
2051 #[test]
2052 fn format_kb_pages_empty_returns_canonical_message() {
2053 assert_eq!(
2054 format_knowledge_base_pages(&[]),
2055 "No knowledge base pages found."
2056 );
2057 }
2058
2059 #[test]
2060 fn format_kb_pages_renders_all_optional_fields_when_present() {
2061 let out = format_knowledge_base_pages(&[sample_kb_page()]);
2062 assert!(out.contains("# Knowledge Base Pages (1)"));
2063 assert!(out.contains("Architecture"));
2064 assert!(out.contains("`12345`"));
2065 assert!(out.contains("space: ENG"));
2066 assert!(out.contains("author: alice"));
2067 assert!(out.contains("updated: 2026-04-01T10:00:00Z"));
2068 assert!(out.contains("excerpt: Top-level architecture overview"));
2069 assert!(out.contains("https://wiki.example.com/pages/12345"));
2070 }
2071
2072 #[test]
2073 fn format_kb_pages_omits_absent_optional_fields() {
2074 let mut bare = sample_kb_page();
2075 bare.space_key = None;
2076 bare.author = None;
2077 bare.last_modified = None;
2078 bare.excerpt = None;
2079 bare.url = None;
2080 let out = format_knowledge_base_pages(&[bare]);
2081 assert!(!out.contains("space:"));
2082 assert!(!out.contains("author:"));
2083 assert!(!out.contains("updated:"));
2084 assert!(!out.contains("excerpt:"));
2085 assert!(!out.contains("https://"));
2086 }
2087
2088 #[test]
2089 fn format_kb_page_summary_includes_metadata_lines() {
2090 let out = format_knowledge_base_page_summary(&sample_kb_page());
2091 assert!(out.contains("# Knowledge Base Page"));
2092 assert!(out.contains("Architecture"));
2093 assert!(out.contains("`12345`"));
2094 assert!(out.contains("space: ENG"));
2095 assert!(out.contains("author: alice"));
2096 assert!(out.contains("updated: 2026-04-01T10:00:00Z"));
2097 assert!(out.contains("url: https://wiki.example.com/pages/12345"));
2098 }
2099
2100 #[test]
2101 fn format_kb_page_summary_skips_absent_fields() {
2102 let bare = devboy_core::KbPage {
2103 id: "x".into(),
2104 title: "Bare".into(),
2105 ..Default::default()
2106 };
2107 let out = format_knowledge_base_page_summary(&bare);
2108 assert!(out.contains("# Knowledge Base Page"));
2109 assert!(out.contains("Bare"));
2110 assert!(!out.contains("space:"));
2111 assert!(!out.contains("author:"));
2112 assert!(!out.contains("url:"));
2113 }
2114
2115 #[test]
2116 fn format_kb_page_renders_full_content_with_ancestors_and_labels() {
2117 let parent = devboy_core::KbPage {
2118 id: "p1".into(),
2119 title: "Parent".into(),
2120 ..Default::default()
2121 };
2122 let grandparent = devboy_core::KbPage {
2123 id: "p0".into(),
2124 title: "Root".into(),
2125 ..Default::default()
2126 };
2127 let content = devboy_core::KbPageContent {
2128 page: sample_kb_page(),
2129 content: "## Body\n\nFull markdown body.".into(),
2130 content_type: "markdown".into(),
2131 ancestors: vec![grandparent, parent],
2132 labels: vec!["arch".into(), "draft".into()],
2133 };
2134
2135 let out = format_knowledge_base_page(&content);
2136 assert!(out.starts_with("# Architecture\n"));
2137 assert!(out.contains("id: `12345`"));
2138 assert!(out.contains("space: `ENG`"));
2139 assert!(out.contains("content_type: `markdown`"));
2140 assert!(out.contains("labels: arch, draft"));
2141 assert!(out.contains("ancestors: Root > Parent"));
2142 assert!(out.contains("url: https://wiki.example.com/pages/12345"));
2143 assert!(out.contains("Full markdown body."));
2144 }
2145
2146 #[test]
2147 fn format_kb_page_omits_ancestors_and_labels_when_empty() {
2148 let content = devboy_core::KbPageContent {
2149 page: devboy_core::KbPage {
2150 id: "x".into(),
2151 title: "Solo".into(),
2152 ..Default::default()
2153 },
2154 content: "No metadata.".into(),
2155 content_type: "markdown".into(),
2156 ..Default::default()
2157 };
2158 let out = format_knowledge_base_page(&content);
2159 assert!(!out.contains("ancestors:"));
2160 assert!(!out.contains("labels:"));
2161 assert!(!out.contains("space:"));
2162 assert!(out.contains("No metadata."));
2163 }
2164}