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