Skip to main content

devboy_executor/
format.rs

1//! Format `ToolOutput` to text using the format pipeline.
2//!
3//! This module bridges the executor's typed output with the pipeline's
4//! text formatting. Supports TOON (default) and JSON output formats.
5
6use devboy_core::{Pagination, Result, SortInfo};
7use devboy_format_pipeline::{OutputFormat, Pipeline, PipelineConfig};
8use serde::Serialize;
9
10use crate::output::ToolOutput;
11
12/// Metadata about formatting result — compression stats, token estimates.
13///
14/// Per Paper 2 §Savings Accounting, every quoted savings number must
15/// distinguish three orthogonal sources and name the baseline/tokenizer
16/// against which the percentages are taken. The split fields below
17/// (`dedup_savings_pct`, `encoder_savings_pct`, `combined_savings_pct`,
18/// `baseline`, `tokenizer`) encode that contract on the live response.
19///
20/// For typed-domain transforms (issues / merge_requests / …), the
21/// encoder runs without an L0 dedup hop, so `dedup_savings_pct == 0.0`
22/// and `combined == encoder`. The cross-turn dedup contribution is
23/// reported separately by `devboy-mcp::layered::SessionPipeline` via the
24/// telemetry sink.
25#[derive(Debug, Clone, Serialize)]
26pub struct FormatMetadata {
27    /// Size of raw JSON input (UTF-8 bytes)
28    pub raw_chars: usize,
29    /// Size of formatted output (UTF-8 bytes)
30    pub output_chars: usize,
31    /// Size of TOON/JSON output BEFORE budget trimming (UTF-8 bytes).
32    /// If no trimming occurred, equals output_chars.
33    /// toon_saved = raw_chars - pre_trim_chars
34    /// trimmed_chars = pre_trim_chars - output_chars
35    pub pre_trim_chars: usize,
36    /// Estimated token count under the active tokenizer.
37    pub estimated_tokens: usize,
38    /// Compression ratio: output_chars / raw_chars (< 1.0 = savings)
39    pub compression_ratio: f32,
40    pub format: String,
41    /// Whether output was truncated by budget trimming
42    pub truncated: bool,
43    /// Total items before truncation (e.g., 50 issues)
44    pub total_items: Option<usize>,
45    /// Items included after truncation (e.g., 20 issues)
46    pub included_items: usize,
47    /// Whether response was split into chunks (budget exceeded)
48    pub chunked: bool,
49    /// Number of chunks generated (1 = no chunking, >1 = chunked)
50    pub total_chunks: usize,
51    /// Which chunk was requested (1 = first/default, >1 = navigation)
52    pub chunk_number: usize,
53    /// Pagination metadata from the provider (offset, limit, total, has_more)
54    pub provider_pagination: Option<Pagination>,
55    /// Sort metadata from the provider (current sort, available sorts)
56    pub provider_sort: Option<SortInfo>,
57    /// L0 dedup savings as a fraction of baseline tokens (0.0 = no
58    /// dedup hit on this response). Always `0.0` on the typed-domain
59    /// path; populated by the MCP-server's layered pipeline when a
60    /// hint is emitted.
61    #[serde(default)]
62    pub dedup_savings_pct: f32,
63    /// L1/L2 encoder savings as a fraction of baseline tokens, computed
64    /// over the *L0-miss* portion of the response. Equals
65    /// `1.0 - encoded_tokens / baseline_tokens` for the typed-domain
66    /// path.
67    #[serde(default)]
68    pub encoder_savings_pct: f32,
69    /// Multiplicative combination of dedup and encoder savings, per
70    /// the §Savings Accounting reporting rule: `combined = dedup +
71    /// (1 - dedup) * encoder`.
72    #[serde(default)]
73    pub combined_savings_pct: f32,
74    /// Baseline against which the percentages are taken
75    /// (e.g. `"json_pretty"`, `"json_compact"`, `"toon"`). Required by
76    /// the reporting rule — savings without a named baseline are not
77    /// comparable across systems.
78    #[serde(default)]
79    pub baseline: String,
80    /// Tokenizer used to compute `estimated_tokens` and the savings
81    /// percentages above (e.g. `"o200k_base"`, `"cl100k_base"`,
82    /// `"heuristic"`).
83    #[serde(default)]
84    pub tokenizer: String,
85}
86
87/// Result of formatting a tool output — content + metadata.
88#[derive(Debug, Clone, Serialize)]
89pub struct FormatResult {
90    /// Formatted text content (TOON, JSON, etc.)
91    pub content: String,
92    /// Formatting metadata (sizes, compression, tokens)
93    pub metadata: FormatMetadata,
94}
95
96/// Format a `ToolOutput` to text using the pipeline.
97///
98/// Returns `FormatResult` with content and metadata (compression stats, token estimates).
99///
100/// # Arguments
101/// * `output` — typed result from executor
102/// * `format` — output format string ("toon", "json"), defaults to "toon"
103/// * `tool_name` — tool name (reserved for future strategy resolution)
104/// * `config` — optional pipeline config override
105pub 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    // Override format in config
123    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    // Active tokenizer + baseline are reported alongside savings numbers
135    // so downstream consumers can compare measurements taken under
136    // different conditions. The typed-domain path always serialises
137    // through `serde_json::to_string_pretty` first, so the implicit
138    // baseline is `json_pretty`.
139    //
140    // Honest disclosure (Copilot review on PR #207): the typed-domain
141    // pipeline does not yet plumb the runtime `AdaptiveConfig.profiles
142    // .tokenizer` choice into this code path, so token counts are
143    // produced by the chars/3.5 heuristic regardless of what the
144    // operator configured. We label that explicitly here; BPE-accurate
145    // counts apply on the L0/L1/L2 hot path inside `LayeredPipeline`
146    // and via `tune analyze`, not on `format_output`. Tracked as a
147    // follow-up in §Implementation Status.
148    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    // Extract provider metadata before consuming output
156    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    // Helper: convert TransformOutput to FormatResult
160    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        // output_chars = pure content size (TOON/JSON), used for compression metrics
167        // content includes hints/chunk index on top, but metrics should reflect pipeline savings
168        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        // Extract chunk metrics from page_index
181        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        // §Savings Accounting — *token*-denominated, not byte-denominated.
188        // We can't see the raw input here so we approximate baseline
189        // tokens from `raw_chars` using the same tokenizer; the encoder
190        // savings then live in token space (which is what the LLM is
191        // billed on), independent of the chars/token ratio of either
192        // format. Fixes Copilot review on PR #207.
193        let baseline_tokens = if raw_chars > 0 {
194            // `Tokenizer::Heuristic` matches the `estimated_tokens`
195            // formula below; if a future change starts plumbing the
196            // active profile, both must move together.
197            (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        // Typed-domain path has no L0 dedup hop, so combined == encoder.
208        let combined_savings_pct = encoder_savings_pct;
209
210        FormatResult {
211            metadata: FormatMetadata {
212                raw_chars,
213                // output_chars = pipeline content size (without hints/chunk index)
214                // Used for compression ratio and saved tokens calculation
215                output_chars: content_chars,
216                pre_trim_chars: pre_trim,
217                // estimated_tokens = full output including hints (what LLM actually consumes)
218                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    // Helper: wrap plain text (no pipeline transform)
244    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        // Jira Structure outputs — serialize as JSON. Match the
439        // `Relations` branch above: surface serialisation errors as
440        // `InvalidData` rather than silently emitting an empty body.
441        ToolOutput::Structures(items, _meta) => {
442            let json = serde_json::to_string_pretty(&items).map_err(|e| {
443                devboy_core::Error::InvalidData(format!("failed to serialize structures: {e}"))
444            })?;
445            Ok(text_result(json, None, None))
446        }
447        ToolOutput::StructureForest(forest) => {
448            let json = serde_json::to_string_pretty(&*forest).map_err(|e| {
449                devboy_core::Error::InvalidData(format!(
450                    "failed to serialize structure forest: {e}"
451                ))
452            })?;
453            Ok(text_result(json, None, None))
454        }
455        ToolOutput::StructureValues(values) => {
456            let json = serde_json::to_string_pretty(&*values).map_err(|e| {
457                devboy_core::Error::InvalidData(format!(
458                    "failed to serialize structure values: {e}"
459                ))
460            })?;
461            Ok(text_result(json, None, None))
462        }
463        ToolOutput::StructureViews(views, _meta) => {
464            let json = serde_json::to_string_pretty(&views).map_err(|e| {
465                devboy_core::Error::InvalidData(format!("failed to serialize structure views: {e}"))
466            })?;
467            Ok(text_result(json, None, None))
468        }
469        ToolOutput::ForestModified(result) => {
470            let json = serde_json::to_string_pretty(&result).map_err(|e| {
471                devboy_core::Error::InvalidData(format!(
472                    "failed to serialize forest modification result: {e}"
473                ))
474            })?;
475            Ok(text_result(json, None, None))
476        }
477        ToolOutput::ProjectVersions(versions, _meta) => Ok(text_result(
478            format_project_versions(&versions, provider_pagination.as_ref()),
479            provider_pagination,
480            provider_sort,
481        )),
482        ToolOutput::SingleProjectVersion(version) => Ok(text_result(
483            format_single_project_version(&version),
484            None,
485            None,
486        )),
487        ToolOutput::Sprints(sprints, _meta) => Ok(text_result(
488            format_sprints(&sprints),
489            provider_pagination,
490            provider_sort,
491        )),
492        ToolOutput::CustomFields(fields, _meta) => Ok(text_result(
493            format_custom_fields(&fields, provider_pagination.as_ref()),
494            provider_pagination,
495            provider_sort,
496        )),
497        ToolOutput::Text(text) => Ok(text_result(text, None, None)),
498    }
499}
500
501/// Format messenger chats as readable text.
502fn format_messenger_chats(chats: &[devboy_core::MessengerChat]) -> String {
503    if chats.is_empty() {
504        return "No chats found.".to_string();
505    }
506
507    let mut output = format!("# Messenger Chats ({})\n\n", chats.len());
508    for chat in chats {
509        let description = chat.description.as_deref().unwrap_or("-");
510        let members = chat
511            .member_count
512            .map(|count| count.to_string())
513            .unwrap_or_else(|| "-".to_string());
514        let active = if chat.is_active { "active" } else { "inactive" };
515        let chat_type = match chat.chat_type {
516            devboy_core::types::ChatType::Direct => "direct",
517            devboy_core::types::ChatType::Group => "group",
518            devboy_core::types::ChatType::Channel => "channel",
519        };
520        output.push_str(&format!(
521            "- {} [{}] id=`{}` members={} status={} desc={}\n",
522            chat.name, chat_type, chat.id, members, active, description
523        ));
524    }
525    output
526}
527
528/// Format messenger messages as readable text.
529fn format_messenger_messages(messages: &[devboy_core::MessengerMessage]) -> String {
530    if messages.is_empty() {
531        return "No messages found.".to_string();
532    }
533
534    let mut output = format!("# Messages ({})\n\n", messages.len());
535    for message in messages {
536        output.push_str(&format_single_messenger_message(message));
537        output.push('\n');
538    }
539    output
540}
541
542/// Format a single messenger message as one line.
543fn format_single_messenger_message(message: &devboy_core::MessengerMessage) -> String {
544    let text = message.text.replace('\r', "\\r").replace('\n', "\\n");
545    let mut line = format!(
546        "- [{}] {} ({}) in `{}`: {}",
547        message.timestamp, message.author.name, message.author.id, message.chat_id, text
548    );
549    if let Some(thread_id) = message.thread_id.as_deref() {
550        line.push_str(&format!(" thread=`{}`", thread_id));
551    }
552    if !message.attachments.is_empty() {
553        line.push_str(&format!(" attachments={}", message.attachments.len()));
554    }
555    line
556}
557
558/// Format issue statuses as a markdown table.
559fn format_statuses(statuses: &[devboy_core::IssueStatus]) -> String {
560    if statuses.is_empty() {
561        return "No statuses found.".to_string();
562    }
563
564    let mut output = String::from("# Available Statuses\n\n");
565    output.push_str("| ID | Name | Category | Color | Order |\n");
566    output.push_str("|---|---|---|---|---|\n");
567
568    for s in statuses {
569        let color = s.color.as_deref().unwrap_or("-");
570        let order = s
571            .order
572            .map(|o| o.to_string())
573            .unwrap_or_else(|| "-".to_string());
574        output.push_str(&format!(
575            "| {} | {} | {} | {} | {} |\n",
576            s.id, s.name, s.category, color, order
577        ));
578    }
579
580    output
581}
582
583/// Format project versions as a compact markdown table.
584///
585/// Paper 2 / format-adaptive encoding: tabular flat-record data is
586/// denser as a table than as JSON. Truncates `description` to ~120
587/// chars (with ellipsis) — full description stays in the structured
588/// `ToolOutput::ProjectVersions` payload.
589///
590/// `pagination` (when supplied) is used to emit a Paper 1 §Chunk Index
591/// hint when the underlying provider had to truncate to fit the limit
592/// — without it the renderer can't tell the LLM that more results exist.
593fn format_project_versions(
594    versions: &[devboy_core::ProjectVersion],
595    pagination: Option<&devboy_core::Pagination>,
596) -> String {
597    if versions.is_empty() {
598        return "No project versions found.".to_string();
599    }
600
601    let total = pagination
602        .and_then(|p| p.total)
603        .unwrap_or(versions.len() as u32);
604    let shown = versions.len() as u32;
605    let header = if total > shown {
606        format!("# Project Versions ({} of {})\n\n", shown, total)
607    } else {
608        format!("# Project Versions ({})\n\n", shown)
609    };
610    let mut output = header;
611    output.push_str("| Name | Released | Release Date | Issues | Description |\n");
612    output.push_str("|---|---|---|---|---|\n");
613
614    for v in versions {
615        let released = if v.released { "yes" } else { "no" };
616        let release_date = v.release_date.as_deref().unwrap_or("-");
617        // Cell intentionally surfaces both numbers when both exist so a
618        // mixed-flavor result set isn't silently misaligned (Codex review
619        // on PR #239). On Cloud only `total` is set; on Server/DC only
620        // `unresolved` — the marker after the number disambiguates.
621        let issue_count = match (v.issue_count, v.unresolved_issue_count) {
622            (Some(t), Some(u)) => format!("{t} ({u} open)"),
623            (Some(t), None) => t.to_string(),
624            (None, Some(u)) => format!("{u} open"),
625            (None, None) => "-".to_string(),
626        };
627        let description = match v.description.as_deref() {
628            None | Some("") => "-".to_string(),
629            Some(d) => escape_table_cell(&truncate_for_table(d, 120)),
630        };
631        let archived_marker = if v.archived { " (archived)" } else { "" };
632        output.push_str(&format!(
633            "| {}{} | {} | {} | {} | {} |\n",
634            escape_table_cell(&v.name),
635            archived_marker,
636            released,
637            release_date,
638            issue_count,
639            description
640        ));
641    }
642
643    if total > shown {
644        let omitted = total - shown;
645        // The hard upper bound on `limit` is 200 (set in tools.rs); never
646        // suggest a value above that — the caller would just get a 400
647        // back. `archived: "all"` is the right enum value to *include*
648        // archived versions; `archived: true` would *only* return
649        // archived ones (Codex review on PR #239).
650        let suggested_limit = total.min(MAX_VERSION_LIMIT);
651        output.push_str(&format!(
652            "\n[+{omitted} more — call with `limit: {suggested_limit}` (or `archived: \"all\"` to include archived versions)]\n"
653        ));
654    }
655
656    output
657}
658
659/// Format sprints from `get_board_sprints` as a compact markdown table.
660fn format_sprints(sprints: &[devboy_core::Sprint]) -> String {
661    if sprints.is_empty() {
662        return "No sprints found.".to_string();
663    }
664
665    let mut output = format!("# Sprints ({})\n\n", sprints.len());
666    output.push_str("| Id | Name | State | Start | End | Goal |\n");
667    output.push_str("|---|---|---|---|---|---|\n");
668    for s in sprints {
669        let start = s.start_date.as_deref().unwrap_or("-");
670        let end = s.end_date.as_deref().unwrap_or("-");
671        let goal = match s.goal.as_deref() {
672            None | Some("") => "-".to_string(),
673            Some(g) => escape_table_cell(&truncate_for_table(g, 120)),
674        };
675        output.push_str(&format!(
676            "| {} | {} | {} | {} | {} | {} |\n",
677            s.id,
678            escape_table_cell(&s.name),
679            s.state,
680            start,
681            end,
682            goal,
683        ));
684    }
685    output
686}
687
688/// Format custom-field descriptors as a compact markdown table.
689fn format_custom_fields(
690    fields: &[devboy_core::CustomFieldDescriptor],
691    pagination: Option<&devboy_core::Pagination>,
692) -> String {
693    if fields.is_empty() {
694        return "No custom fields found.".to_string();
695    }
696
697    let total = pagination
698        .and_then(|p| p.total)
699        .unwrap_or(fields.len() as u32);
700    let shown = fields.len() as u32;
701    let header = if total > shown {
702        format!("# Custom Fields ({} of {})\n\n", shown, total)
703    } else {
704        format!("# Custom Fields ({})\n\n", shown)
705    };
706    let mut output = header;
707    output.push_str("| Id | Name | Type |\n");
708    output.push_str("|---|---|---|\n");
709    for f in fields {
710        let field_type = if f.field_type.is_empty() {
711            "-"
712        } else {
713            &f.field_type
714        };
715        output.push_str(&format!(
716            "| `{}` | {} | {} |\n",
717            escape_table_cell(&f.id),
718            escape_table_cell(&f.name),
719            escape_table_cell(field_type),
720        ));
721    }
722    if total > shown {
723        let omitted = total - shown;
724        output.push_str(&format!(
725            "\n[+{omitted} more — call with `limit: {}` (max 200) or narrow with `search`]\n",
726            total.min(200)
727        ));
728    }
729    output
730}
731
732/// Maximum value the `list_project_versions` schema accepts for `limit`.
733/// Mirrors the `Some(200.0)` cap declared in
734/// `crates/devboy-executor/src/tools.rs`.
735const MAX_VERSION_LIMIT: u32 = 200;
736
737/// Escape a string for safe inclusion in a markdown-table cell:
738/// `|` becomes `\|` (would otherwise start a new column), and the
739/// backslash itself is escaped. Newlines are out of scope here — the
740/// caller flattens them via `truncate_for_table`.
741fn escape_table_cell(s: &str) -> String {
742    s.replace('\\', "\\\\").replace('|', "\\|")
743}
744
745/// Format a single project version as a small detail block (used by
746/// the `upsert_project_version` response so the caller can confirm what
747/// they wrote).
748fn format_single_project_version(v: &devboy_core::ProjectVersion) -> String {
749    // Detail block — heading is plain markdown text (not a table cell)
750    // so pipe-escaping isn't needed here, but flatten newlines so a
751    // multi-line `name` doesn't break the heading.
752    let safe_name = v.name.replace(['\n', '\r'], " ");
753    let mut output = format!("# {} (project {})\n\n", safe_name, v.project);
754    output.push_str(&format!("- **id:** {}\n", v.id));
755    output.push_str(&format!(
756        "- **released:** {}\n",
757        if v.released { "yes" } else { "no" }
758    ));
759    output.push_str(&format!(
760        "- **archived:** {}\n",
761        if v.archived { "yes" } else { "no" }
762    ));
763    if let Some(ref d) = v.start_date {
764        output.push_str(&format!("- **start_date:** {d}\n"));
765    }
766    if let Some(ref d) = v.release_date {
767        output.push_str(&format!("- **release_date:** {d}\n"));
768    }
769    if let Some(overdue) = v.overdue {
770        output.push_str(&format!("- **overdue:** {overdue}\n"));
771    }
772    if let Some(count) = v.issue_count {
773        output.push_str(&format!("- **issue_count:** {count}\n"));
774    }
775    if let Some(count) = v.unresolved_issue_count {
776        output.push_str(&format!("- **unresolved_issue_count:** {count}\n"));
777    }
778    if let Some(ref desc) = v.description.as_deref().filter(|d| !d.is_empty()) {
779        output.push_str(&format!("\n## Description\n\n{desc}\n"));
780    }
781    output
782}
783
784/// Truncate a string to `max_chars` characters (Unicode-safe), appending
785/// an ellipsis when something was cut. Newlines are flattened to spaces
786/// so the cell stays on one row of the markdown table.
787fn truncate_for_table(s: &str, max_chars: usize) -> String {
788    let single_line: String = s
789        .chars()
790        .map(|c| if c == '\n' || c == '\r' { ' ' } else { c })
791        .collect();
792    let count = single_line.chars().count();
793    if count <= max_chars {
794        return single_line;
795    }
796    let mut out: String = single_line.chars().take(max_chars).collect();
797    out.push('…');
798    out
799}
800
801/// Format users as a markdown table.
802fn format_users(users: &[devboy_core::User]) -> String {
803    if users.is_empty() {
804        return "No users found.".to_string();
805    }
806
807    let mut output = String::from("# Users\n\n");
808    output.push_str("| ID | Username | Name | Email |\n");
809    output.push_str("|---|---|---|---|\n");
810
811    for u in users {
812        let name = u.name.as_deref().unwrap_or("-");
813        let email = u.email.as_deref().unwrap_or("-");
814        output.push_str(&format!(
815            "| {} | {} | {} | {} |\n",
816            u.id, u.username, name, email
817        ));
818    }
819
820    output
821}
822
823/// Format meeting notes as markdown.
824fn format_meeting_notes(meetings: &[devboy_core::MeetingNote]) -> String {
825    if meetings.is_empty() {
826        return "No meeting notes found.".to_string();
827    }
828
829    let mut output = format!("# Meeting Notes ({} results)\n\n", meetings.len());
830
831    for m in meetings {
832        output.push_str(&format!("## {}\n", m.title));
833        if let Some(ref date) = m.meeting_date {
834            output.push_str(&format!("**Date:** {date}\n"));
835        }
836        if let Some(secs) = m.duration_seconds {
837            let mins = secs / 60;
838            output.push_str(&format!("**Duration:** {mins} min\n"));
839        }
840        if let Some(ref host) = m.host_email {
841            output.push_str(&format!("**Host:** {host}\n"));
842        }
843        if !m.participants.is_empty() {
844            output.push_str(&format!(
845                "**Participants:** {}\n",
846                m.participants.join(", ")
847            ));
848        }
849        if let Some(ref summary) = m.summary {
850            output.push_str(&format!("\n{summary}\n"));
851        }
852        if !m.action_items.is_empty() {
853            output.push_str("\n**Action Items:**\n");
854            for item in &m.action_items {
855                output.push_str(&format!("- {item}\n"));
856            }
857        }
858        if !m.keywords.is_empty() {
859            output.push_str(&format!("**Keywords:** {}\n", m.keywords.join(", ")));
860        }
861        output.push('\n');
862    }
863
864    output
865}
866
867/// Format a meeting transcript as compact text.
868fn format_meeting_transcript(transcript: &devboy_core::MeetingTranscript) -> String {
869    let title = transcript.title.as_deref().unwrap_or("Meeting Transcript");
870    let mut output = format!("# {title}\n\n");
871    output.push_str(&format!(
872        "Showing {} sentences\n\n",
873        transcript.sentences.len()
874    ));
875
876    for s in &transcript.sentences {
877        let fallback = if s.speaker_id.is_empty() {
878            "Unknown speaker".to_string()
879        } else {
880            format!("Speaker {}", s.speaker_id)
881        };
882        let speaker = s.speaker_name.as_deref().unwrap_or(&fallback);
883        let time = format_time(s.start_time);
884        output.push_str(&format!("[{time}] {speaker}: {}\n", s.text));
885    }
886
887    output
888}
889
890fn format_knowledge_base_spaces(spaces: &[devboy_core::KbSpace]) -> String {
891    if spaces.is_empty() {
892        return "No knowledge base spaces found.".to_string();
893    }
894
895    let mut output = format!("# Knowledge Base Spaces ({})\n\n", spaces.len());
896    for space in spaces {
897        output.push_str(&format!("- {} (`{}`)\n", space.name, space.key));
898        if let Some(description) = &space.description {
899            output.push_str(&format!("  {description}\n"));
900        }
901        if let Some(url) = &space.url {
902            output.push_str(&format!("  {url}\n"));
903        }
904    }
905    output
906}
907
908fn format_knowledge_base_pages(pages: &[devboy_core::KbPage]) -> String {
909    if pages.is_empty() {
910        return "No knowledge base pages found.".to_string();
911    }
912
913    let mut output = format!("# Knowledge Base Pages ({})\n\n", pages.len());
914    for page in pages {
915        output.push_str(&format!("- {} (`{}`)\n", page.title, page.id));
916        if let Some(space_key) = &page.space_key {
917            output.push_str(&format!("  space: {space_key}\n"));
918        }
919        if let Some(author) = &page.author {
920            output.push_str(&format!("  author: {author}\n"));
921        }
922        if let Some(last_modified) = &page.last_modified {
923            output.push_str(&format!("  updated: {last_modified}\n"));
924        }
925        if let Some(excerpt) = &page.excerpt {
926            output.push_str(&format!("  excerpt: {excerpt}\n"));
927        }
928        if let Some(url) = &page.url {
929            output.push_str(&format!("  {url}\n"));
930        }
931    }
932    output
933}
934
935fn format_knowledge_base_page_summary(page: &devboy_core::KbPage) -> String {
936    let mut output = format!("# Knowledge Base Page\n\n{} (`{}`)\n", page.title, page.id);
937    if let Some(space_key) = &page.space_key {
938        output.push_str(&format!("space: {space_key}\n"));
939    }
940    if let Some(author) = &page.author {
941        output.push_str(&format!("author: {author}\n"));
942    }
943    if let Some(last_modified) = &page.last_modified {
944        output.push_str(&format!("updated: {last_modified}\n"));
945    }
946    if let Some(url) = &page.url {
947        output.push_str(&format!("url: {url}\n"));
948    }
949    output
950}
951
952fn format_knowledge_base_page(page: &devboy_core::KbPageContent) -> String {
953    let mut output = format!("# {}\n\n", page.page.title);
954    output.push_str(&format!("id: `{}`\n", page.page.id));
955    if let Some(space_key) = &page.page.space_key {
956        output.push_str(&format!("space: `{space_key}`\n"));
957    }
958    output.push_str(&format!("content_type: `{}`\n", page.content_type));
959    if !page.labels.is_empty() {
960        output.push_str(&format!("labels: {}\n", page.labels.join(", ")));
961    }
962    if !page.ancestors.is_empty() {
963        let chain = page
964            .ancestors
965            .iter()
966            .map(|ancestor| ancestor.title.as_str())
967            .collect::<Vec<_>>()
968            .join(" > ");
969        output.push_str(&format!("ancestors: {chain}\n"));
970    }
971    if let Some(url) = &page.page.url {
972        output.push_str(&format!("url: {url}\n"));
973    }
974    output.push('\n');
975    output.push_str(&page.content);
976    output
977}
978
979/// Format seconds as [MM:SS] or [HH:MM:SS].
980fn format_time(seconds: f64) -> String {
981    let total_secs = seconds as u64;
982    let hours = total_secs / 3600;
983    let minutes = (total_secs % 3600) / 60;
984    let secs = total_secs % 60;
985    if hours > 0 {
986        format!("{hours:02}:{minutes:02}:{secs:02}")
987    } else {
988        format!("{minutes:02}:{secs:02}")
989    }
990}
991
992/// Format pipeline status as markdown.
993fn format_pipeline(info: &devboy_core::PipelineInfo) -> String {
994    let status_icon = match info.status {
995        devboy_core::PipelineStatus::Success => "✅",
996        devboy_core::PipelineStatus::Failed => "❌",
997        devboy_core::PipelineStatus::Running => "🔄",
998        devboy_core::PipelineStatus::Pending => "⏳",
999        devboy_core::PipelineStatus::Canceled => "🚫",
1000        _ => "❓",
1001    };
1002
1003    let mut output = format!(
1004        "# Pipeline {}\n\n{} **Status:** {} | **Ref:** `{}` | **SHA:** `{}`",
1005        info.id,
1006        status_icon,
1007        info.status.as_str(),
1008        info.reference,
1009        &info.sha[..info
1010            .sha
1011            .char_indices()
1012            .nth(7)
1013            .map(|(i, _)| i)
1014            .unwrap_or(info.sha.len())]
1015    );
1016
1017    if let Some(url) = &info.url {
1018        output.push_str(&format!("\n🔗 {url}"));
1019    }
1020
1021    if let Some(duration) = info.duration {
1022        output.push_str(&format!("\n⏱️ Duration: {}s", duration));
1023    }
1024
1025    // Summary
1026    let s = &info.summary;
1027    output.push_str(&format!(
1028        "\n\n**Summary:** {} total | ✅ {} | ❌ {} | 🔄 {} | ⏳ {} | 🚫 {} | ⏭️ {}",
1029        s.total, s.success, s.failed, s.running, s.pending, s.canceled, s.skipped
1030    ));
1031
1032    // Stages/jobs
1033    for stage in &info.stages {
1034        output.push_str(&format!("\n\n## {}\n", stage.name));
1035        for job in &stage.jobs {
1036            let job_icon = match job.status {
1037                devboy_core::PipelineStatus::Success => "✅",
1038                devboy_core::PipelineStatus::Failed => "❌",
1039                devboy_core::PipelineStatus::Running => "🔄",
1040                devboy_core::PipelineStatus::Pending => "⏳",
1041                _ => "❓",
1042            };
1043            let dur = job.duration.map(|d| format!(" ({d}s)")).unwrap_or_default();
1044            output.push_str(&format!("\n{} **{}**{}", job_icon, job.name, dur));
1045            if let Some(url) = &job.url {
1046                output.push_str(&format!(" — [logs]({url})"));
1047            }
1048        }
1049    }
1050
1051    // Failed jobs with errors
1052    if !info.failed_jobs.is_empty() {
1053        output.push_str("\n\n## Failed Jobs\n");
1054        for fj in &info.failed_jobs {
1055            output.push_str(&format!("\n### ❌ {} (job {})\n", fj.name, fj.id));
1056            if let Some(snippet) = &fj.error_snippet {
1057                output.push_str(&format!("\n```\n{snippet}\n```\n"));
1058            }
1059        }
1060    }
1061
1062    output
1063}
1064
1065/// Format job log output as markdown.
1066fn format_job_log(log: &devboy_core::JobLogOutput) -> String {
1067    let mut output = format!("# Job Log ({})\n\n", log.job_id);
1068    output.push_str(&format!("**Mode:** {}", log.mode));
1069    if let Some(total) = log.total_lines {
1070        output.push_str(&format!(" | **Total lines:** {total}"));
1071    }
1072    output.push_str(&format!("\n\n```\n{}\n```", log.content));
1073    output
1074}
1075
1076/// Convenience: execute a tool and format the output in one call.
1077///
1078/// Extracts `format` from args before passing to executor.
1079pub async fn execute_and_format(
1080    executor: &crate::executor::Executor,
1081    tool: &str,
1082    args: serde_json::Value,
1083    ctx: &crate::context::AdditionalContext,
1084    pipeline_config: Option<PipelineConfig>,
1085) -> Result<FormatResult> {
1086    // Extract format and budget from args before execution
1087    let format = args
1088        .get("format")
1089        .and_then(|v| v.as_str())
1090        .map(String::from);
1091
1092    let budget = args
1093        .get("budget")
1094        .and_then(|v| v.as_u64())
1095        .map(|b| b as usize);
1096
1097    // Apply budget override to pipeline config
1098    let pipeline_config = if let Some(b) = budget {
1099        let mut config = pipeline_config.unwrap_or_default();
1100        // Convert token budget to max_chars (tokens * 3.5)
1101        config.max_chars = (b as f64 * 3.5).floor() as usize;
1102        Some(config)
1103    } else {
1104        pipeline_config
1105    };
1106
1107    let output = executor.execute(tool, args, ctx).await?;
1108    format_output(output, format.as_deref(), Some(tool), pipeline_config)
1109}
1110
1111#[cfg(test)]
1112mod tests {
1113    use super::*;
1114    use devboy_core::Issue;
1115
1116    fn sample_issue() -> Issue {
1117        Issue {
1118            key: "gh#1".into(),
1119            title: "Test Issue".into(),
1120            description: Some("Test description".into()),
1121            state: "open".into(),
1122            source: "github".into(),
1123            priority: None,
1124            labels: vec!["bug".into()],
1125            author: None,
1126            assignees: vec![],
1127            url: Some("https://github.com/test/repo/issues/1".into()),
1128            created_at: Some("2024-01-01T00:00:00Z".into()),
1129            updated_at: Some("2024-01-02T00:00:00Z".into()),
1130            attachments_count: None,
1131            parent: None,
1132            subtasks: vec![],
1133            custom_fields: std::collections::HashMap::new(),
1134        }
1135    }
1136
1137    #[test]
1138    fn test_format_issues_toon() {
1139        let output = ToolOutput::Issues(vec![sample_issue()], None);
1140        let result = format_output(output, Some("toon"), None, None)
1141            .unwrap()
1142            .content;
1143        assert!(result.contains("gh#1"));
1144        assert!(result.contains("Test Issue"));
1145    }
1146
1147    #[test]
1148    fn test_format_metadata_toon_compression() {
1149        let output = ToolOutput::Issues(vec![sample_issue()], None);
1150        let result = format_output(output, Some("toon"), None, None).unwrap();
1151
1152        assert!(result.metadata.raw_chars > 0, "raw_chars should be > 0");
1153        assert!(
1154            result.metadata.output_chars > 0,
1155            "output_chars should be > 0"
1156        );
1157        assert!(result.metadata.estimated_tokens > 0, "tokens should be > 0");
1158        assert_eq!(result.metadata.format, "toon");
1159        assert!(!result.metadata.truncated);
1160        // Compression ratio should be reasonable (TOON may slightly expand very small inputs)
1161        assert!(
1162            result.metadata.compression_ratio < 2.0,
1163            "compression_ratio should be reasonable, got {}",
1164            result.metadata.compression_ratio
1165        );
1166    }
1167
1168    #[test]
1169    fn test_format_metadata_text_passthrough() {
1170        let output = ToolOutput::Text("plain text".into());
1171        let result = format_output(output, None, None, None).unwrap();
1172
1173        assert_eq!(result.metadata.raw_chars, 10);
1174        assert_eq!(result.metadata.output_chars, 10);
1175        assert_eq!(result.metadata.compression_ratio, 1.0);
1176        assert_eq!(result.metadata.format, "text");
1177        assert!(!result.metadata.truncated);
1178    }
1179
1180    #[test]
1181    fn test_format_metadata_savings_split() {
1182        // Multi-issue payload so the encoder actually compresses below
1183        // the JSON pretty baseline.
1184        let issues: Vec<_> = (0..20).map(|_| sample_issue()).collect();
1185        let output = ToolOutput::Issues(issues, None);
1186        let result = format_output(output, Some("toon"), None, None).unwrap();
1187
1188        // Typed-domain path: dedup contributes nothing here.
1189        assert_eq!(result.metadata.dedup_savings_pct, 0.0);
1190        // Encoder savings must be in [0, 1).
1191        assert!(
1192            (0.0..1.0).contains(&result.metadata.encoder_savings_pct),
1193            "encoder savings out of range: {}",
1194            result.metadata.encoder_savings_pct
1195        );
1196        // Combined == encoder when dedup is zero.
1197        assert_eq!(
1198            result.metadata.combined_savings_pct,
1199            result.metadata.encoder_savings_pct
1200        );
1201        // §Savings Accounting demands a named baseline + tokenizer.
1202        assert_eq!(result.metadata.baseline, "json_pretty");
1203        assert!(
1204            !result.metadata.tokenizer.is_empty(),
1205            "tokenizer must be set"
1206        );
1207    }
1208
1209    #[test]
1210    fn test_format_metadata_passthrough_savings_zero() {
1211        // Plain-text passthrough has no encoder hop, so all three savings
1212        // must be zero — but baseline / tokenizer still populate.
1213        let output = ToolOutput::Text("nothing to compress".into());
1214        let result = format_output(output, None, None, None).unwrap();
1215        assert_eq!(result.metadata.dedup_savings_pct, 0.0);
1216        assert_eq!(result.metadata.encoder_savings_pct, 0.0);
1217        assert_eq!(result.metadata.combined_savings_pct, 0.0);
1218        assert_eq!(result.metadata.baseline, "json_pretty");
1219        assert!(!result.metadata.tokenizer.is_empty());
1220    }
1221
1222    #[test]
1223    fn test_format_metadata_truncated() {
1224        let output = ToolOutput::Issues(vec![sample_issue()], None);
1225        let config = PipelineConfig {
1226            max_chars: 50, // very small — will truncate
1227            ..PipelineConfig::default()
1228        };
1229        let result = format_output(output, Some("toon"), None, Some(config)).unwrap();
1230
1231        assert!(result.metadata.truncated);
1232        // output_chars tracks content size (may include hint text appended after truncation)
1233        assert!(
1234            result.metadata.output_chars < result.metadata.raw_chars,
1235            "truncated output ({}) should be smaller than raw ({})",
1236            result.metadata.output_chars,
1237            result.metadata.raw_chars
1238        );
1239    }
1240
1241    #[test]
1242    fn test_format_issues_json() {
1243        let output = ToolOutput::Issues(vec![sample_issue()], None);
1244        let result = format_output(output, Some("json"), None, None)
1245            .unwrap()
1246            .content;
1247        assert!(result.contains("gh#1"));
1248    }
1249
1250    #[test]
1251    fn test_format_issues_toon_explicit() {
1252        let output = ToolOutput::Issues(vec![sample_issue()], None);
1253        let result = format_output(output, Some("toon"), None, None)
1254            .unwrap()
1255            .content;
1256        assert!(result.contains("gh#1"));
1257    }
1258
1259    #[test]
1260    fn test_format_text_passthrough() {
1261        let output = ToolOutput::Text("Comment created".into());
1262        let result = format_output(output, None, None, None).unwrap().content;
1263        assert_eq!(result, "Comment created");
1264    }
1265
1266    #[test]
1267    fn test_format_default_is_toon() {
1268        let output = ToolOutput::Issues(vec![sample_issue()], None);
1269        let result = format_output(output, None, None, None).unwrap().content;
1270        assert!(result.contains("gh#1"));
1271    }
1272
1273    #[test]
1274    fn test_format_single_issue() {
1275        let output = ToolOutput::SingleIssue(Box::new(sample_issue()));
1276        let result = format_output(output, Some("toon"), None, None)
1277            .unwrap()
1278            .content;
1279        assert!(result.contains("gh#1"));
1280    }
1281
1282    fn sample_mr() -> devboy_core::MergeRequest {
1283        devboy_core::MergeRequest {
1284            key: "pr#1".into(),
1285            title: "Test PR".into(),
1286            description: None,
1287            state: "open".into(),
1288            source: "github".into(),
1289            source_branch: "feature".into(),
1290            target_branch: "main".into(),
1291            author: None,
1292            assignees: vec![],
1293            reviewers: vec![],
1294            labels: vec![],
1295            draft: false,
1296            url: None,
1297            created_at: None,
1298            updated_at: None,
1299        }
1300    }
1301
1302    #[test]
1303    fn test_format_merge_requests() {
1304        let output = ToolOutput::MergeRequests(vec![sample_mr()], None);
1305        let result = format_output(output, Some("toon"), None, None)
1306            .unwrap()
1307            .content;
1308        assert!(result.contains("pr#1"));
1309    }
1310
1311    #[test]
1312    fn test_format_single_merge_request() {
1313        let output = ToolOutput::SingleMergeRequest(Box::new(sample_mr()));
1314        let result = format_output(output, Some("toon"), None, None)
1315            .unwrap()
1316            .content;
1317        assert!(result.contains("pr#1"));
1318    }
1319
1320    #[test]
1321    fn test_format_discussions() {
1322        let output = ToolOutput::Discussions(
1323            vec![devboy_core::Discussion {
1324                id: "d1".into(),
1325                resolved: false,
1326                resolved_by: None,
1327                comments: vec![devboy_core::Comment {
1328                    id: "c1".into(),
1329                    body: "Review comment".into(),
1330                    author: None,
1331                    created_at: None,
1332                    updated_at: None,
1333                    position: None,
1334                }],
1335                position: None,
1336            }],
1337            None,
1338        );
1339        let result = format_output(output, Some("toon"), None, None)
1340            .unwrap()
1341            .content;
1342        assert!(result.contains("Review comment"));
1343    }
1344
1345    #[test]
1346    fn test_format_diffs() {
1347        let output = ToolOutput::Diffs(
1348            vec![devboy_core::FileDiff {
1349                file_path: "src/main.rs".into(),
1350                old_path: None,
1351                new_file: false,
1352                deleted_file: false,
1353                renamed_file: false,
1354                diff: "+added line".into(),
1355                additions: Some(1),
1356                deletions: Some(0),
1357            }],
1358            None,
1359        );
1360        let result = format_output(output, Some("toon"), None, None)
1361            .unwrap()
1362            .content;
1363        assert!(result.contains("src/main.rs"));
1364    }
1365
1366    #[test]
1367    fn test_format_comments() {
1368        let output = ToolOutput::Comments(
1369            vec![devboy_core::Comment {
1370                id: "c1".into(),
1371                body: "A comment body".into(),
1372                author: None,
1373                created_at: None,
1374                updated_at: None,
1375                position: None,
1376            }],
1377            None,
1378        );
1379        let result = format_output(output, Some("json"), None, None)
1380            .unwrap()
1381            .content;
1382        assert!(result.contains("A comment body"));
1383    }
1384
1385    #[test]
1386    fn test_format_with_custom_pipeline_config() {
1387        let output = ToolOutput::Issues(vec![sample_issue()], None);
1388        let config = PipelineConfig {
1389            max_chars: 500,
1390            ..PipelineConfig::default()
1391        };
1392        let result = format_output(output, Some("toon"), None, Some(config))
1393            .unwrap()
1394            .content;
1395        assert!(result.contains("gh#1"));
1396    }
1397
1398    #[test]
1399    fn test_format_pipeline() {
1400        let output = ToolOutput::Pipeline(Box::new(devboy_core::PipelineInfo {
1401            id: "100".into(),
1402            status: devboy_core::PipelineStatus::Failed,
1403            reference: "main".into(),
1404            sha: "abc123def".into(),
1405            url: Some("https://example.com/pipeline/100".into()),
1406            duration: Some(120),
1407            coverage: Some(85.5),
1408            summary: devboy_core::PipelineSummary {
1409                total: 3,
1410                success: 2,
1411                failed: 1,
1412                ..Default::default()
1413            },
1414            stages: vec![devboy_core::PipelineStage {
1415                name: "build".into(),
1416                jobs: vec![devboy_core::PipelineJob {
1417                    id: "1".into(),
1418                    name: "compile".into(),
1419                    status: devboy_core::PipelineStatus::Success,
1420                    url: None,
1421                    duration: Some(30),
1422                }],
1423            }],
1424            failed_jobs: vec![devboy_core::FailedJob {
1425                id: "2".into(),
1426                name: "test".into(),
1427                url: None,
1428                error_snippet: Some("error: test failed".into()),
1429            }],
1430        }));
1431        let result = format_output(output, None, None, None).unwrap().content;
1432        assert!(result.contains("Pipeline 100"));
1433        assert!(result.contains("failed"));
1434        assert!(result.contains("main"));
1435        assert!(result.contains("120s"));
1436        assert!(result.contains("compile"));
1437        assert!(result.contains("error: test failed"));
1438    }
1439
1440    #[test]
1441    fn test_format_job_log() {
1442        let output = ToolOutput::JobLog(Box::new(devboy_core::JobLogOutput {
1443            job_id: "202".into(),
1444            job_name: Some("test".into()),
1445            content: "error: assertion failed\nat src/test.rs:42".into(),
1446            mode: "smart".into(),
1447            total_lines: Some(100),
1448        }));
1449        let result = format_output(output, None, None, None).unwrap().content;
1450        assert!(result.contains("Job Log"));
1451        assert!(result.contains("202"));
1452        assert!(result.contains("smart"));
1453        assert!(result.contains("assertion failed"));
1454    }
1455
1456    // --- Pipeline formatting ---
1457
1458    #[test]
1459    fn test_format_pipeline_success_status() {
1460        let output = ToolOutput::Pipeline(Box::new(devboy_core::PipelineInfo {
1461            id: "200".into(),
1462            status: devboy_core::PipelineStatus::Success,
1463            reference: "develop".into(),
1464            sha: "deadbeefcafe".into(),
1465            url: None,
1466            duration: None,
1467            coverage: None,
1468            summary: devboy_core::PipelineSummary {
1469                total: 5,
1470                success: 5,
1471                ..Default::default()
1472            },
1473            stages: vec![],
1474            failed_jobs: vec![],
1475        }));
1476        let result = format_output(output, None, None, None).unwrap().content;
1477        assert!(result.contains("Pipeline 200"));
1478        assert!(result.contains("success"));
1479        assert!(result.contains("develop"));
1480        assert!(result.contains("deadbee")); // sha truncated to 7
1481    }
1482
1483    #[test]
1484    fn test_format_pipeline_running_status() {
1485        let output = ToolOutput::Pipeline(Box::new(devboy_core::PipelineInfo {
1486            id: "301".into(),
1487            status: devboy_core::PipelineStatus::Running,
1488            reference: "feature".into(),
1489            sha: "1234567890abcdef".into(),
1490            url: Some("https://ci.example.com/301".into()),
1491            duration: Some(60),
1492            coverage: None,
1493            summary: devboy_core::PipelineSummary {
1494                total: 3,
1495                running: 1,
1496                success: 1,
1497                pending: 1,
1498                ..Default::default()
1499            },
1500            stages: vec![],
1501            failed_jobs: vec![],
1502        }));
1503        let result = format_output(output, None, None, None).unwrap().content;
1504        assert!(result.contains("running"));
1505        assert!(result.contains("https://ci.example.com/301"));
1506        assert!(result.contains("60s"));
1507    }
1508
1509    #[test]
1510    fn test_format_pipeline_pending_status() {
1511        let output = ToolOutput::Pipeline(Box::new(devboy_core::PipelineInfo {
1512            id: "302".into(),
1513            status: devboy_core::PipelineStatus::Pending,
1514            reference: "main".into(),
1515            sha: "aabbccdd".into(),
1516            url: None,
1517            duration: None,
1518            coverage: None,
1519            summary: Default::default(),
1520            stages: vec![],
1521            failed_jobs: vec![],
1522        }));
1523        let result = format_output(output, None, None, None).unwrap().content;
1524        assert!(result.contains("pending"));
1525    }
1526
1527    #[test]
1528    fn test_format_pipeline_canceled_status() {
1529        let output = ToolOutput::Pipeline(Box::new(devboy_core::PipelineInfo {
1530            id: "303".into(),
1531            status: devboy_core::PipelineStatus::Canceled,
1532            reference: "main".into(),
1533            sha: "1122334455".into(),
1534            url: None,
1535            duration: None,
1536            coverage: None,
1537            summary: Default::default(),
1538            stages: vec![],
1539            failed_jobs: vec![],
1540        }));
1541        let result = format_output(output, None, None, None).unwrap().content;
1542        assert!(result.contains("canceled"));
1543    }
1544
1545    #[test]
1546    fn test_format_pipeline_with_job_url() {
1547        let output = ToolOutput::Pipeline(Box::new(devboy_core::PipelineInfo {
1548            id: "400".into(),
1549            status: devboy_core::PipelineStatus::Failed,
1550            reference: "main".into(),
1551            sha: "abcdef1234567".into(),
1552            url: None,
1553            duration: None,
1554            coverage: None,
1555            summary: Default::default(),
1556            stages: vec![devboy_core::PipelineStage {
1557                name: "test".into(),
1558                jobs: vec![devboy_core::PipelineJob {
1559                    id: "j1".into(),
1560                    name: "unit-test".into(),
1561                    status: devboy_core::PipelineStatus::Failed,
1562                    url: Some("https://ci.example.com/jobs/j1".into()),
1563                    duration: None,
1564                }],
1565            }],
1566            failed_jobs: vec![],
1567        }));
1568        let result = format_output(output, None, None, None).unwrap().content;
1569        assert!(result.contains("[logs](https://ci.example.com/jobs/j1)"));
1570    }
1571
1572    #[test]
1573    fn test_format_pipeline_failed_job_without_snippet() {
1574        let output = ToolOutput::Pipeline(Box::new(devboy_core::PipelineInfo {
1575            id: "401".into(),
1576            status: devboy_core::PipelineStatus::Failed,
1577            reference: "main".into(),
1578            sha: "abcdef1234567".into(),
1579            url: None,
1580            duration: None,
1581            coverage: None,
1582            summary: Default::default(),
1583            stages: vec![],
1584            failed_jobs: vec![devboy_core::FailedJob {
1585                id: "fj1".into(),
1586                name: "lint".into(),
1587                url: None,
1588                error_snippet: None,
1589            }],
1590        }));
1591        let result = format_output(output, None, None, None).unwrap().content;
1592        assert!(result.contains("lint"));
1593        assert!(result.contains("fj1"));
1594        assert!(!result.contains("```")); // no code block when no snippet
1595    }
1596
1597    // --- Statuses formatting ---
1598
1599    #[test]
1600    fn test_format_statuses() {
1601        let output = ToolOutput::Statuses(
1602            vec![
1603                devboy_core::IssueStatus {
1604                    id: "1".into(),
1605                    name: "To Do".into(),
1606                    category: "todo".into(),
1607                    color: Some("#blue".into()),
1608                    order: Some(0),
1609                },
1610                devboy_core::IssueStatus {
1611                    id: "2".into(),
1612                    name: "In Progress".into(),
1613                    category: "in_progress".into(),
1614                    color: None,
1615                    order: None,
1616                },
1617            ],
1618            None,
1619        );
1620        let result = format_output(output, None, None, None).unwrap().content;
1621        assert!(result.contains("Available Statuses"));
1622        assert!(result.contains("To Do"));
1623        assert!(result.contains("In Progress"));
1624        assert!(result.contains("#blue"));
1625        assert!(result.contains("todo"));
1626        assert!(result.contains("| - |")); // None values become "-"
1627    }
1628
1629    #[test]
1630    fn test_format_statuses_empty() {
1631        let output = ToolOutput::Statuses(vec![], None);
1632        let result = format_output(output, None, None, None).unwrap().content;
1633        assert_eq!(result, "No statuses found.");
1634    }
1635
1636    // --- Users formatting ---
1637
1638    #[test]
1639    fn test_format_users() {
1640        let output = ToolOutput::Users(
1641            vec![
1642                devboy_core::User {
1643                    id: "u1".into(),
1644                    username: "johndoe".into(),
1645                    name: Some("John Doe".into()),
1646                    email: Some("john@example.com".into()),
1647                    avatar_url: None,
1648                },
1649                devboy_core::User {
1650                    id: "u2".into(),
1651                    username: "janesmith".into(),
1652                    name: None,
1653                    email: None,
1654                    avatar_url: None,
1655                },
1656            ],
1657            None,
1658        );
1659        let result = format_output(output, None, None, None).unwrap().content;
1660        assert!(result.contains("# Users"));
1661        assert!(result.contains("johndoe"));
1662        assert!(result.contains("John Doe"));
1663        assert!(result.contains("john@example.com"));
1664        assert!(result.contains("janesmith"));
1665        assert!(result.contains("| - |")); // None values
1666    }
1667
1668    #[test]
1669    fn test_format_users_empty() {
1670        let output = ToolOutput::Users(vec![], None);
1671        let result = format_output(output, None, None, None).unwrap().content;
1672        assert_eq!(result, "No users found.");
1673    }
1674
1675    // --- Project versions formatting (issue #238) ---
1676
1677    fn sample_project_version(name: &str) -> devboy_core::ProjectVersion {
1678        devboy_core::ProjectVersion {
1679            id: "1".into(),
1680            project: "PROJ".into(),
1681            name: name.into(),
1682            description: Some("Initial release".into()),
1683            start_date: Some("2025-01-01".into()),
1684            release_date: Some("2025-02-01".into()),
1685            released: true,
1686            archived: false,
1687            overdue: Some(false),
1688            issue_count: Some(7),
1689            unresolved_issue_count: None,
1690            source: "jira".into(),
1691        }
1692    }
1693
1694    #[test]
1695    fn format_project_versions_empty_returns_canonical_message() {
1696        let output = ToolOutput::ProjectVersions(vec![], None);
1697        let result = format_output(output, None, None, None).unwrap().content;
1698        assert_eq!(result, "No project versions found.");
1699    }
1700
1701    #[test]
1702    fn format_project_versions_renders_table_with_counts_and_dates() {
1703        let output = ToolOutput::ProjectVersions(vec![sample_project_version("3.18.0")], None);
1704        let result = format_output(output, None, None, None).unwrap().content;
1705        assert!(result.contains("# Project Versions (1)"), "{result}");
1706        assert!(result.contains("| Name |"), "{result}");
1707        assert!(result.contains("| 3.18.0 |"), "{result}");
1708        assert!(result.contains("| yes |"), "{result}");
1709        assert!(result.contains("2025-02-01"), "{result}");
1710        assert!(result.contains("Initial release"), "{result}");
1711    }
1712
1713    #[test]
1714    fn format_project_versions_marks_archived_inline() {
1715        let mut v = sample_project_version("0.9.0");
1716        v.archived = true;
1717        let output = ToolOutput::ProjectVersions(vec![v], None);
1718        let result = format_output(output, None, None, None).unwrap().content;
1719        assert!(
1720            result.contains("0.9.0 (archived)"),
1721            "expected archived marker, got {result}"
1722        );
1723    }
1724
1725    #[test]
1726    fn format_project_versions_truncates_long_descriptions() {
1727        let mut v = sample_project_version("1.0.0");
1728        v.description = Some("x".repeat(200));
1729        let output = ToolOutput::ProjectVersions(vec![v], None);
1730        let result = format_output(output, None, None, None).unwrap().content;
1731        assert!(result.contains('…'), "expected ellipsis, got {result}");
1732    }
1733
1734    #[test]
1735    fn format_single_project_version_renders_detail_block() {
1736        let v = sample_project_version("3.18.0");
1737        let output = ToolOutput::SingleProjectVersion(Box::new(v));
1738        let result = format_output(output, None, None, None).unwrap().content;
1739        assert!(result.contains("# 3.18.0 (project PROJ)"), "{result}");
1740        assert!(result.contains("- **id:** 1"), "{result}");
1741        assert!(result.contains("- **released:** yes"), "{result}");
1742        assert!(result.contains("## Description"), "{result}");
1743        assert!(result.contains("Initial release"), "{result}");
1744    }
1745
1746    #[test]
1747    fn format_project_versions_escapes_pipes_in_name_and_description() {
1748        // Copilot review on PR #239 — release notes can carry `|` chars
1749        // that would otherwise break the markdown table.
1750        let mut v = sample_project_version("v|1.0");
1751        v.description = Some("Highlights | breaking changes".into());
1752        let output = ToolOutput::ProjectVersions(vec![v], None);
1753        let result = format_output(output, None, None, None).unwrap().content;
1754        assert!(
1755            result.contains("v\\|1.0"),
1756            "name pipe not escaped: {result}"
1757        );
1758        assert!(
1759            result.contains("Highlights \\| breaking changes"),
1760            "description pipe not escaped: {result}"
1761        );
1762        // And the resulting table still has 5 columns, not 6 — header line
1763        // is split into 6 fields (5 cells + leading/trailing empty).
1764        let line = result
1765            .lines()
1766            .find(|l| l.starts_with("| v\\|1.0"))
1767            .expect("expected table row, got: {result}");
1768        let cells = line.split(" | ").count();
1769        assert!(cells <= 6, "row split into too many cells: {line:?}");
1770    }
1771
1772    #[test]
1773    fn format_project_versions_emits_more_hint_when_truncated() {
1774        // Copilot review #4 on PR #239 — Paper 1 §Chunk Index. When the
1775        // provider trimmed the list, the renderer must surface that fact
1776        // so the agent can ask for the rest.
1777        let pagination = devboy_core::Pagination {
1778            offset: 0,
1779            limit: 1,
1780            total: Some(35),
1781            has_more: true,
1782            next_cursor: None,
1783        };
1784        let v = sample_project_version("3.18.0");
1785        let output = ToolOutput::ProjectVersions(
1786            vec![v],
1787            Some(crate::output::ResultMeta {
1788                pagination: Some(pagination),
1789                sort_info: None,
1790            }),
1791        );
1792        let result = format_output(output, None, None, None).unwrap().content;
1793        assert!(
1794            result.contains("Project Versions (1 of 35)"),
1795            "expected 'X of Y' header: {result}"
1796        );
1797        assert!(
1798            result.contains("[+34 more"),
1799            "expected +N more hint: {result}"
1800        );
1801        assert!(
1802            result.contains("`limit: 35`"),
1803            "expected limit suggestion: {result}"
1804        );
1805    }
1806
1807    #[test]
1808    fn format_project_versions_hint_caps_limit_at_max_and_uses_archived_all() {
1809        // Codex review on PR #239 — `limit` is capped at 200 by the
1810        // schema and "include archived" is `archived: "all"` (the union),
1811        // not `archived: true` (which means "archived only").
1812        let pagination = devboy_core::Pagination {
1813            offset: 0,
1814            limit: 1,
1815            total: Some(5_000),
1816            has_more: true,
1817            next_cursor: None,
1818        };
1819        let v = sample_project_version("3.18.0");
1820        let output = ToolOutput::ProjectVersions(
1821            vec![v],
1822            Some(crate::output::ResultMeta {
1823                pagination: Some(pagination),
1824                sort_info: None,
1825            }),
1826        );
1827        let result = format_output(output, None, None, None).unwrap().content;
1828        assert!(
1829            result.contains("`limit: 200`"),
1830            "limit suggestion should clamp at 200, got: {result}"
1831        );
1832        assert!(
1833            result.contains("`archived: \"all\"`"),
1834            "expected archived hint to suggest 'all', got: {result}"
1835        );
1836        assert!(
1837            !result.contains("`archived: true`"),
1838            "must not suggest archived: true (means 'archived only'), got: {result}"
1839        );
1840    }
1841
1842    #[test]
1843    fn format_project_versions_renders_unresolved_only_cell() {
1844        // Codex review #3 on PR #239 — Server/DC sets only
1845        // unresolved_issue_count; the table cell must still convey that
1846        // it's an unresolved count (not a misleading total).
1847        let mut v = sample_project_version("3.18.0");
1848        v.issue_count = None;
1849        v.unresolved_issue_count = Some(4);
1850        let output = ToolOutput::ProjectVersions(vec![v], None);
1851        let result = format_output(output, None, None, None).unwrap().content;
1852        assert!(
1853            result.contains("4 open"),
1854            "expected '4 open' marker, got: {result}"
1855        );
1856    }
1857
1858    #[test]
1859    fn format_single_project_version_renders_unresolved_count() {
1860        let mut v = sample_project_version("3.18.0");
1861        v.issue_count = Some(20);
1862        v.unresolved_issue_count = Some(7);
1863        let output = ToolOutput::SingleProjectVersion(Box::new(v));
1864        let result = format_output(output, None, None, None).unwrap().content;
1865        assert!(result.contains("- **issue_count:** 20"), "{result}");
1866        assert!(
1867            result.contains("- **unresolved_issue_count:** 7"),
1868            "{result}"
1869        );
1870    }
1871
1872    #[test]
1873    fn format_project_versions_no_hint_when_not_truncated() {
1874        let pagination = devboy_core::Pagination {
1875            offset: 0,
1876            limit: 5,
1877            total: Some(1),
1878            has_more: false,
1879            next_cursor: None,
1880        };
1881        let v = sample_project_version("3.18.0");
1882        let output = ToolOutput::ProjectVersions(
1883            vec![v],
1884            Some(crate::output::ResultMeta {
1885                pagination: Some(pagination),
1886                sort_info: None,
1887            }),
1888        );
1889        let result = format_output(output, None, None, None).unwrap().content;
1890        assert!(
1891            !result.contains("more"),
1892            "shouldn't suggest more results: {result}"
1893        );
1894    }
1895
1896    #[test]
1897    fn escape_table_cell_handles_backslash_and_pipe() {
1898        assert_eq!(escape_table_cell("a|b"), "a\\|b");
1899        assert_eq!(escape_table_cell("a\\b"), "a\\\\b");
1900        // Backslashes are doubled *first*, so a literal `\|` doesn't
1901        // collapse into an over-escaped `\\|`.
1902        assert_eq!(escape_table_cell("a\\|b"), "a\\\\\\|b");
1903        assert_eq!(escape_table_cell("plain"), "plain");
1904    }
1905
1906    // --- JobLog without total_lines ---
1907
1908    #[test]
1909    fn test_format_job_log_no_total_lines() {
1910        let output = ToolOutput::JobLog(Box::new(devboy_core::JobLogOutput {
1911            job_id: "999".into(),
1912            job_name: Some("build".into()),
1913            content: "Building...".into(),
1914            mode: "full".into(),
1915            total_lines: None,
1916        }));
1917        let result = format_output(output, None, None, None).unwrap().content;
1918        assert!(result.contains("Job Log (999)"));
1919        assert!(result.contains("**Mode:** full"));
1920        assert!(!result.contains("Total lines"));
1921        assert!(result.contains("Building..."));
1922    }
1923
1924    // --- Text passthrough variations ---
1925
1926    #[test]
1927    fn test_format_text_empty_string() {
1928        let output = ToolOutput::Text("".into());
1929        let result = format_output(output, None, None, None).unwrap().content;
1930        assert_eq!(result, "");
1931    }
1932
1933    #[test]
1934    fn test_format_text_with_json_format_param() {
1935        // Even with "json" format, Text variant just passes through
1936        let output = ToolOutput::Text("raw text".into());
1937        let result = format_output(output, Some("json"), None, None)
1938            .unwrap()
1939            .content;
1940        assert_eq!(result, "raw text");
1941    }
1942
1943    // --- Meeting notes formatting ---
1944
1945    #[test]
1946    fn test_format_meeting_notes() {
1947        let meetings = vec![devboy_core::MeetingNote {
1948            id: "m1".into(),
1949            title: "Sprint Planning".into(),
1950            meeting_date: Some("2025-01-15T10:00:00Z".into()),
1951            duration_seconds: Some(2700), // 45 min
1952            host_email: Some("host@example.com".into()),
1953            participants: vec!["alice@example.com".into(), "bob@example.com".into()],
1954            action_items: vec!["Review PR #42".into(), "Update docs".into()],
1955            keywords: vec!["sprint".into(), "planning".into()],
1956            summary: Some("Discussed sprint goals.".into()),
1957            ..Default::default()
1958        }];
1959        let output = ToolOutput::MeetingNotes(meetings, None);
1960        let result = format_output(output, None, None, None).unwrap().content;
1961        assert!(result.contains("Sprint Planning"));
1962        assert!(result.contains("2025-01-15T10:00:00Z"));
1963        assert!(result.contains("45 min"));
1964        assert!(result.contains("host@example.com"));
1965        assert!(result.contains("alice@example.com"));
1966        assert!(result.contains("Review PR #42"));
1967        assert!(result.contains("Update docs"));
1968        assert!(result.contains("sprint"));
1969        assert!(result.contains("Discussed sprint goals."));
1970    }
1971
1972    #[test]
1973    fn test_format_meeting_notes_empty() {
1974        let output = ToolOutput::MeetingNotes(vec![], None);
1975        let result = format_output(output, None, None, None).unwrap().content;
1976        assert_eq!(result, "No meeting notes found.");
1977    }
1978
1979    #[test]
1980    fn test_format_meeting_transcript() {
1981        let transcript = devboy_core::MeetingTranscript {
1982            meeting_id: "m1".into(),
1983            title: Some("Sprint Planning".into()),
1984            sentences: vec![
1985                devboy_core::TranscriptSentence {
1986                    speaker_id: "s1".into(),
1987                    speaker_name: Some("Alice".into()),
1988                    text: "Let's start the meeting.".into(),
1989                    start_time: 0.0,
1990                    end_time: 3.0,
1991                },
1992                devboy_core::TranscriptSentence {
1993                    speaker_id: "s2".into(),
1994                    speaker_name: Some("Bob".into()),
1995                    text: "Sounds good.".into(),
1996                    start_time: 5.0,
1997                    end_time: 7.0,
1998                },
1999            ],
2000        };
2001        let output = ToolOutput::MeetingTranscript(Box::new(transcript));
2002        let result = format_output(output, None, None, None).unwrap().content;
2003        assert!(result.contains("Sprint Planning"));
2004        assert!(result.contains("2 sentences"));
2005        assert!(result.contains("[00:00] Alice: Let's start the meeting."));
2006        assert!(result.contains("[00:05] Bob: Sounds good."));
2007    }
2008
2009    #[test]
2010    fn test_format_meeting_transcript_unknown_speaker() {
2011        let transcript = devboy_core::MeetingTranscript {
2012            meeting_id: "m1".into(),
2013            title: None,
2014            sentences: vec![devboy_core::TranscriptSentence {
2015                speaker_id: "".into(),
2016                speaker_name: None,
2017                text: "Hello".into(),
2018                start_time: 0.0,
2019                end_time: 1.0,
2020            }],
2021        };
2022        let output = ToolOutput::MeetingTranscript(Box::new(transcript));
2023        let result = format_output(output, None, None, None).unwrap().content;
2024        assert!(result.contains("Meeting Transcript"));
2025        assert!(result.contains("Unknown speaker"));
2026    }
2027
2028    // --- Relations formatting ---
2029
2030    #[test]
2031    fn test_format_relations() {
2032        let relations = devboy_core::IssueRelations {
2033            parent: Some(sample_issue()),
2034            subtasks: vec![sample_issue()],
2035            blocks: vec![devboy_core::IssueLink {
2036                issue: sample_issue(),
2037                link_type: "Blocks".into(),
2038            }],
2039            blocked_by: vec![],
2040            related_to: vec![],
2041            duplicates: vec![],
2042            epic_key: None,
2043        };
2044        let output = ToolOutput::Relations(Box::new(relations));
2045        let result = format_output(output, None, None, None).unwrap().content;
2046        // Relations format uses JSON serialization
2047        assert!(result.contains("gh#1"));
2048        assert!(result.contains("Blocks"));
2049        assert!(result.contains("Test Issue"));
2050    }
2051
2052    #[test]
2053    fn test_format_relations_empty() {
2054        let relations = devboy_core::IssueRelations::default();
2055        let output = ToolOutput::Relations(Box::new(relations));
2056        let result = format_output(output, None, None, None).unwrap().content;
2057        // Empty relations should still produce valid JSON
2058        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
2059        assert!(parsed.is_object());
2060    }
2061
2062    // --- format_time edge cases ---
2063
2064    #[test]
2065    fn test_format_time_zero() {
2066        assert_eq!(format_time(0.0), "00:00");
2067    }
2068
2069    #[test]
2070    fn test_format_time_seconds_only() {
2071        assert_eq!(format_time(45.0), "00:45");
2072    }
2073
2074    #[test]
2075    fn test_format_time_minutes_and_seconds() {
2076        assert_eq!(format_time(125.0), "02:05");
2077    }
2078
2079    #[test]
2080    fn test_format_time_hours() {
2081        assert_eq!(format_time(3661.0), "01:01:01");
2082    }
2083
2084    #[test]
2085    fn test_format_time_fractional_seconds() {
2086        // Fractional seconds are truncated
2087        assert_eq!(format_time(59.9), "00:59");
2088    }
2089
2090    // ---------------------------------------------------------------
2091    // Knowledge-base formatters
2092    // ---------------------------------------------------------------
2093
2094    fn sample_kb_space() -> devboy_core::KbSpace {
2095        devboy_core::KbSpace {
2096            id: "100".into(),
2097            key: "ENG".into(),
2098            name: "Engineering".into(),
2099            description: Some("Team docs".into()),
2100            url: Some("https://wiki.example.com/spaces/ENG".into()),
2101            ..Default::default()
2102        }
2103    }
2104
2105    fn sample_kb_page() -> devboy_core::KbPage {
2106        devboy_core::KbPage {
2107            id: "12345".into(),
2108            title: "Architecture".into(),
2109            space_key: Some("ENG".into()),
2110            url: Some("https://wiki.example.com/pages/12345".into()),
2111            author: Some("alice".into()),
2112            last_modified: Some("2026-04-01T10:00:00Z".into()),
2113            excerpt: Some("Top-level architecture overview".into()),
2114            ..Default::default()
2115        }
2116    }
2117
2118    #[test]
2119    fn format_kb_spaces_empty_returns_canonical_message() {
2120        assert_eq!(
2121            format_knowledge_base_spaces(&[]),
2122            "No knowledge base spaces found."
2123        );
2124    }
2125
2126    #[test]
2127    fn format_kb_spaces_includes_count_name_key_description_url() {
2128        let out = format_knowledge_base_spaces(&[sample_kb_space()]);
2129        assert!(out.contains("# Knowledge Base Spaces (1)"));
2130        assert!(out.contains("Engineering"));
2131        assert!(out.contains("`ENG`"));
2132        assert!(out.contains("Team docs"));
2133        assert!(out.contains("https://wiki.example.com/spaces/ENG"));
2134    }
2135
2136    #[test]
2137    fn format_kb_pages_empty_returns_canonical_message() {
2138        assert_eq!(
2139            format_knowledge_base_pages(&[]),
2140            "No knowledge base pages found."
2141        );
2142    }
2143
2144    #[test]
2145    fn format_kb_pages_renders_all_optional_fields_when_present() {
2146        let out = format_knowledge_base_pages(&[sample_kb_page()]);
2147        assert!(out.contains("# Knowledge Base Pages (1)"));
2148        assert!(out.contains("Architecture"));
2149        assert!(out.contains("`12345`"));
2150        assert!(out.contains("space: ENG"));
2151        assert!(out.contains("author: alice"));
2152        assert!(out.contains("updated: 2026-04-01T10:00:00Z"));
2153        assert!(out.contains("excerpt: Top-level architecture overview"));
2154        assert!(out.contains("https://wiki.example.com/pages/12345"));
2155    }
2156
2157    #[test]
2158    fn format_kb_pages_omits_absent_optional_fields() {
2159        let mut bare = sample_kb_page();
2160        bare.space_key = None;
2161        bare.author = None;
2162        bare.last_modified = None;
2163        bare.excerpt = None;
2164        bare.url = None;
2165        let out = format_knowledge_base_pages(&[bare]);
2166        assert!(!out.contains("space:"));
2167        assert!(!out.contains("author:"));
2168        assert!(!out.contains("updated:"));
2169        assert!(!out.contains("excerpt:"));
2170        assert!(!out.contains("https://"));
2171    }
2172
2173    #[test]
2174    fn format_kb_page_summary_includes_metadata_lines() {
2175        let out = format_knowledge_base_page_summary(&sample_kb_page());
2176        assert!(out.contains("# Knowledge Base Page"));
2177        assert!(out.contains("Architecture"));
2178        assert!(out.contains("`12345`"));
2179        assert!(out.contains("space: ENG"));
2180        assert!(out.contains("author: alice"));
2181        assert!(out.contains("updated: 2026-04-01T10:00:00Z"));
2182        assert!(out.contains("url: https://wiki.example.com/pages/12345"));
2183    }
2184
2185    #[test]
2186    fn format_kb_page_summary_skips_absent_fields() {
2187        let bare = devboy_core::KbPage {
2188            id: "x".into(),
2189            title: "Bare".into(),
2190            ..Default::default()
2191        };
2192        let out = format_knowledge_base_page_summary(&bare);
2193        assert!(out.contains("# Knowledge Base Page"));
2194        assert!(out.contains("Bare"));
2195        assert!(!out.contains("space:"));
2196        assert!(!out.contains("author:"));
2197        assert!(!out.contains("url:"));
2198    }
2199
2200    #[test]
2201    fn format_kb_page_renders_full_content_with_ancestors_and_labels() {
2202        let parent = devboy_core::KbPage {
2203            id: "p1".into(),
2204            title: "Parent".into(),
2205            ..Default::default()
2206        };
2207        let grandparent = devboy_core::KbPage {
2208            id: "p0".into(),
2209            title: "Root".into(),
2210            ..Default::default()
2211        };
2212        let content = devboy_core::KbPageContent {
2213            page: sample_kb_page(),
2214            content: "## Body\n\nFull markdown body.".into(),
2215            content_type: "markdown".into(),
2216            ancestors: vec![grandparent, parent],
2217            labels: vec!["arch".into(), "draft".into()],
2218        };
2219
2220        let out = format_knowledge_base_page(&content);
2221        assert!(out.starts_with("# Architecture\n"));
2222        assert!(out.contains("id: `12345`"));
2223        assert!(out.contains("space: `ENG`"));
2224        assert!(out.contains("content_type: `markdown`"));
2225        assert!(out.contains("labels: arch, draft"));
2226        assert!(out.contains("ancestors: Root > Parent"));
2227        assert!(out.contains("url: https://wiki.example.com/pages/12345"));
2228        assert!(out.contains("Full markdown body."));
2229    }
2230
2231    #[test]
2232    fn format_kb_page_omits_ancestors_and_labels_when_empty() {
2233        let content = devboy_core::KbPageContent {
2234            page: devboy_core::KbPage {
2235                id: "x".into(),
2236                title: "Solo".into(),
2237                ..Default::default()
2238            },
2239            content: "No metadata.".into(),
2240            content_type: "markdown".into(),
2241            ..Default::default()
2242        };
2243        let out = format_knowledge_base_page(&content);
2244        assert!(!out.contains("ancestors:"));
2245        assert!(!out.contains("labels:"));
2246        assert!(!out.contains("space:"));
2247        assert!(out.contains("No metadata."));
2248    }
2249}