use super::metadata::Metadata;
pub(super) fn render(meta: &Metadata) -> String {
let mut out = String::new();
out.push_str("# Koda debug bundle\n\n");
out.push_str(&format!("Captured at: {}\n", meta.captured_at));
out.push_str(&format!("Session ID: {}\n", meta.session_id));
if let Some(title) = &meta.session_title {
out.push_str(&format!("Session: {title}\n"));
}
out.push_str(&format!("Koda version: {}\n", meta.koda_version));
if let Some(sha) = &meta.git_sha {
out.push_str(&format!("Git SHA: {sha}\n"));
}
if let Some(model) = &meta.model {
out.push_str(&format!("Model: {model}\n"));
}
if let Some(provider) = &meta.provider {
out.push_str(&format!("Provider: {provider}\n"));
}
out.push_str(&format!(
"Platform: {}/{}",
meta.platform.os, meta.platform.arch
));
if let Some(term) = &meta.platform.term {
out.push_str(&format!(" ({term})"));
}
out.push('\n');
out.push_str(
r#"
## What's in this bundle
Read in this order if you're a human debugging:
1. **`README.md`** — you are here.
2. **`metadata.json`** — session/runtime context at a glance. Open this
first to know what you're looking at.
3. **`conversation.md`** — the conversation as it appeared on the user's
screen. Faithful to the live TUI render — same per-tool formatting,
same status icons, same preview lines. Strip-styled to plain text so
any text editor can read it.
4. **`messages.json`** — the raw DB dump of every message row. The
model's-eye view of the session. Consult when `conversation.md`
leaves a question unanswered ("did the model actually see X?").
5. **`logs/koda-<PID>.log`** — the full per-process tracing log for the
koda invocation that wrote this bundle. Look here for engine events
(sub-agent spawn, tool dispatch, retry, cancel) and warnings.
6. **`logs/panic.log`** (if present) — forensic record of any panics
captured during the lifetime of THIS koda config dir. Not just this
session — the panic log is shared across all koda processes that
share `~/.config/koda/`. Filter by timestamp if you need to scope to
one session. Absent file means no panics ever recorded.
7. **`env.txt`** — environment variables filtered through a hardcoded
allowlist. Credentials are length-redacted (the question "is
$OPENAI_API_KEY set?" is debuggable; the value is not in the bundle).
Most user/path env vars are omitted entirely to avoid corp-ID leakage.
## What's NOT in this bundle
- The koda config file (`~/.config/koda/config.toml`) — may contain
provider URLs or other deployment-sensitive info. Re-run with
`--config` if reproduction needs a config tweak.
- The session database file itself — `messages.json` is the relevant
slice. The DB has every other session you've ever run.
- Files referenced via `@file` attachments — those were inlined into
the messages at the time and appear in `conversation.md` /
`messages.json` already.
## Sharing this bundle
Bundles are designed to be shareable with colleagues OR pasted into
another LLM for second-opinion debugging. The redaction policy assumes
either consumer is acceptable. **Re-check `env.txt` before sharing
externally** — some allowlisted vars (e.g. `KODA_*` deploy hints) might
still be sensitive in your specific deployment.
"#,
);
out
}
#[cfg(test)]
mod tests {
use super::super::metadata::{Platform, Totals};
use super::*;
fn fixture_meta() -> Metadata {
Metadata {
session_id: "abc-123".to_string(),
session_title: Some("test session".to_string()),
model: Some("claude-sonnet-4-6".to_string()),
provider: Some("anthropic".to_string()),
context_window: Some(200_000),
totals: Totals::default(),
koda_version: "0.2.25".to_string(),
git_sha: Some("deadbeef".to_string()),
started_at: Some("2026-04-30T10:00:00Z".to_string()),
captured_at: "2026-04-30T11:00:00Z".to_string(),
platform: Platform {
os: "macos".to_string(),
arch: "aarch64".to_string(),
term: Some("xterm-256color".to_string()),
},
}
}
#[test]
fn header_includes_session_and_version() {
let readme = render(&fixture_meta());
assert!(readme.contains("# Koda debug bundle"));
assert!(readme.contains("abc-123"));
assert!(readme.contains("0.2.25"));
assert!(readme.contains("test session"));
assert!(readme.contains("claude-sonnet-4-6"));
}
#[test]
fn optional_fields_omitted_when_absent() {
let mut meta = fixture_meta();
meta.session_title = None;
meta.git_sha = None;
meta.model = None;
meta.provider = None;
let readme = render(&meta);
assert!(!readme.contains("Session: "), "session title leaked");
assert!(!readme.contains("Git SHA: "), "git sha leaked");
assert!(!readme.contains("Model: "), "model leaked");
assert!(!readme.contains("Provider: "), "provider leaked");
assert!(readme.contains("abc-123"));
assert!(readme.contains("0.2.25"));
}
#[test]
fn lists_all_documented_files() {
let readme = render(&fixture_meta());
for expected in [
"README.md",
"metadata.json",
"conversation.md",
"messages.json",
"logs/koda-",
"panic.log",
"env.txt",
] {
assert!(
readme.contains(expected),
"README missing reference to {expected}"
);
}
}
#[test]
fn warns_about_external_sharing() {
let readme = render(&fixture_meta());
assert!(
readme.contains("Re-check"),
"external-sharing warning trigger phrase removed"
);
assert!(
readme.contains("externally"),
"external-sharing warning recipient phrase removed"
);
}
}