Skip to main content

devboy_executor/
format.rs

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