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