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